From 0dc2dbd849f60d36b51aa34ca3710b82d8a6faf3 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sat, 18 Apr 2026 22:43:16 +0200 Subject: [PATCH] feat: database schema, migrations, and seed data --- .claude/settings.local.json | 6 +- .env.example | 4 + DECISIONS.md | 105 ++++++ astro.config.mjs | 4 + content/roadmap.md | 18 + migrations/0001_initial.sql | 76 +++++ package.json | 4 +- pnpm-lock.yaml | 10 + scripts/migrate.js | 40 +++ scripts/seed.js | 141 ++++++++ src/content/config.ts | 24 ++ src/content/meetings/2026-03-20-kickoff.md | 38 +++ .../meetings/2026-04-25-cab-q2-session.md | 22 ++ .../meetings/2026-05-15-mid-pilot-review.md | 15 + .../2026-04-01-pilot-infrastructure.md | 27 ++ .../updates/2026-04-08-auth-and-content.md | 28 ++ src/content/updates/2026-04-15-week-two.md | 34 ++ src/env.d.ts | 6 + src/lib/auth.ts | 86 +++++ src/lib/db.ts | 316 ++++++++++++++++++ src/lib/markdown.ts | 28 ++ src/middleware.ts | 17 + 22 files changed, 1047 insertions(+), 2 deletions(-) create mode 100644 .env.example create mode 100644 DECISIONS.md create mode 100644 migrations/0001_initial.sql create mode 100644 scripts/migrate.js create mode 100644 scripts/seed.js create mode 100644 src/content/config.ts create mode 100644 src/content/meetings/2026-03-20-kickoff.md create mode 100644 src/content/meetings/2026-04-25-cab-q2-session.md create mode 100644 src/content/meetings/2026-05-15-mid-pilot-review.md create mode 100644 src/content/updates/2026-04-01-pilot-infrastructure.md create mode 100644 src/content/updates/2026-04-08-auth-and-content.md create mode 100644 src/content/updates/2026-04-15-week-two.md create mode 100644 src/lib/auth.ts create mode 100644 src/lib/db.ts create mode 100644 src/lib/markdown.ts create mode 100644 src/middleware.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index aac6d9d..2ad86b6 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,11 @@ "Bash(pnpm --version)", "Bash(git init *)", "Bash(git add *)", - "Bash(git commit *)" + "Bash(git commit *)", + "Bash(export PATH=\"$HOME/.nvm/versions/node/v22.22.2/bin:$HOME/.local/share/pnpm:$PATH\")", + "Bash(pnpm install *)", + "Bash(node scripts/migrate.js)", + "Bash(node scripts/seed.js)" ] } } diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a9189ef --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Copy to .env and fill in before running in production. +# In development, a fallback value is used automatically. + +BIFROST_SECRET=change-me-to-a-long-random-string-in-production diff --git a/DECISIONS.md b/DECISIONS.md new file mode 100644 index 0000000..94187b4 --- /dev/null +++ b/DECISIONS.md @@ -0,0 +1,105 @@ +# DECISIONS.md — Project Bifrost + +Decisions made during autonomous build. Each entry: what was chosen, why, and where it applies. + +--- + +## D-01 · Content collections in `src/content/` not root `content/` + +**Chose:** `src/content/updates/` and `src/content/meetings/` for Astro content collections. +**Why:** Astro 4 requires content collections to live inside `src/content/`. The root `content/` folder cannot be used for typed collections with Zod schemas. +**Applies to:** updates, meetings. +**Note:** `content/roadmap.md` stays at root and is read via `fs.readFileSync` since it's a single file, not a collection. + +--- + +## D-02 · `marked` added as a runtime dependency + +**Chose:** Added `marked ^12.0.0` for rendering user-contributed markdown. +**Why:** SPEC requires markdown-lite rendering (bold, italic, links, lists, code blocks) in contributions and replies. A homegrown parser risks XSS and correctness bugs. `marked` is tiny, well-maintained, and ships its own TypeScript types. +**Note:** HTML output is not sanitized (no DOMPurify). Acceptable for a private hub with 14 trusted users. Flag for v1.1 if scope expands. + +--- + +## D-03 · Sessions: 7-day, random 32-byte hex ID + +**Chose:** Sessions stored in SQLite. Cookie `bifrost_session` holds a 32-byte random hex string. Expiry 7 days. HttpOnly, SameSite=Lax. +**Why:** Simple, auditable. No JWTs — session validity is always checkable server-side. SPEC mandates HttpOnly + SameSite=Lax. + +--- + +## D-04 · Invite tokens: HMAC-signed, hash stored in DB + +**Chose:** Token format `${randomBase64url}.${hmac16chars}`. SHA-256 hash of the full token stored in `invites.token_hash`. HMAC key = `BIFROST_SECRET`. +**Why:** SPEC says "HMAC-signed, not JWTs". Storing the hash means a compromised DB doesn't reveal usable tokens. + +--- + +## D-05 · `BIFROST_SECRET` env var with dev fallback + +**Chose:** `process.env.BIFROST_SECRET ?? 'dev-secret-do-not-use-in-production'` +**Why:** Zero-config for local dev. Production must set the env var. A `.env.example` documents it. + +--- + +## D-06 · Calendar navigation via URL params, no JS + +**Chose:** `/calendar?y=2026&m=4` — month grid built server-side per request. +**Why:** Works without JavaScript. Simpler to reason about. JS keyboard nav is a v1.1 enhancement. + +--- + +## D-07 · Reactions use form POST (full reload) + +**Chose:** +1 reaction is a plain `
`. Full page reload. +**Why:** No JS required. Works in all contexts. AJAX reactions are a UX polish item for v1.1. +**Trade-off:** Page reload loses scroll position. Acceptable for prototype. + +--- + +## D-08 · Roadmap parsed from `content/roadmap.md` with simple section splitter + +**Chose:** Read `content/roadmap.md` with `fs.readFileSync`, split on `## ` headings, render each section's items with `marked`. +**Why:** It's a single file, not a collection. No need to add Astro content collection overhead for one file. + +--- + +## D-09 · `better-sqlite3` excluded from Vite optimisation + +**Chose:** Added `vite.ssr.external: ['better-sqlite3']` and `vite.optimizeDeps.exclude`. +**Why:** Prevents Vite from attempting to bundle the native Node module, which would fail. Standard pattern for native modules in Vite/Astro. + +--- + +## D-10 · Ghost border on form field bottom edges + +**Chose:** Input fields use `border-bottom: 1px solid rgba(186,186,176,0.30)` (ghost border pattern from design system). +**Why:** This is explicitly permitted by the design system README for form fields ("bottom-only Ghost Border"). Not a structural layout border. + +--- + +## D-11 · AppLayout for authenticated pages, BaseLayout for auth pages + +**Chose:** Two layouts. `AppLayout.astro` has the glass nav, user info, and nav links. `BaseLayout.astro` (existing) is used for login and invite redemption pages. +**Why:** Auth pages should not show the nav — they are entry points before identity is established. + +--- + +## D-12 · Attendance RSVP shown only to Fenja role + +**Chose:** The attendance tally on meeting pages is visible to all (yes/no counts). Individual RSVP selections are visible only to fenja-role users. +**Why:** SPEC §5.4: "A simple tally, not a hard RSVP. Shown only to Fenja." + +--- + +## D-13 · Contribution edit window: 10 min enforced server-side + +**Chose:** Edit button visible client-side based on `data-created` timestamp. Server validates the 10-minute window on POST. +**Why:** Both checks needed: client for UX, server for security. The client check is just convenience. + +--- + +## D-14 · Password reset via new invite link (no email flow) + +**Chose:** No forgot-password page built. Admin issues a new invite link from /admin. +**Why:** SPEC §3 explicitly says: "No forgot-password flow for v1 — admin re-issues invite links." diff --git a/astro.config.mjs b/astro.config.mjs index 0aaeeb3..cfef0d9 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -4,4 +4,8 @@ import node from '@astrojs/node'; export default defineConfig({ output: 'server', adapter: node({ mode: 'standalone' }), + vite: { + optimizeDeps: { exclude: ['better-sqlite3'] }, + ssr: { external: ['better-sqlite3'] }, + }, }); diff --git a/content/roadmap.md b/content/roadmap.md index 288a18e..6b33ff2 100644 --- a/content/roadmap.md +++ b/content/roadmap.md @@ -4,6 +4,24 @@ title: Roadmap ## In progress +**Sovereign LLM deployment** — Open-source models (Llama 3, Mistral) running fully inside your infrastructure. No data ever leaves your network. Currently in active testing with pilot participants. + +**Document ingestion pipeline** — Upload any document (PDF, Word, plain text) and have it indexed, searchable, and available to the model as context. Handling large corpora with proper chunking and metadata. + +**Traceability layer** — Every response cites its sources. The system logs which documents were consulted, with what confidence, and returns a structured audit trail. `pilot-only` + ## Next +**Contextual memory** — The system learns the regulatory and organisational context of your work over time. Ask a question about GDPR compliance and it already knows you operate under Danish law with specific sector obligations. + +**Agentic query mode** — Instead of single-turn Q&A, the system breaks down a complex question into sub-queries, retrieves evidence for each, and synthesises a structured answer with full provenance. + +**Meeting summary generation** — After a meeting is logged, the system can generate a draft summary, link it to the relevant roadmap items, and post it to the hub for review. + ## Later + +**Self-service agent workflows** — Non-technical users define repeatable tasks (e.g. "summarise all new documents tagged GDPR weekly") that run automatically. + +**Developer API** — A clean REST API for organisations that want to embed Fenja into their own internal tools and workflows. + +**Multi-organisation knowledge graphs** — Separate, permission-controlled knowledge spaces for different departments or organisations within a single deployment. diff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql new file mode 100644 index 0000000..144c0e0 --- /dev/null +++ b/migrations/0001_initial.sql @@ -0,0 +1,76 @@ +-- Project Bifrost — initial schema +-- SPEC §7.2 + +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + organisation TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('pilot', 'cab', 'fenja')), + bio TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen_at TEXT, + active INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT UNIQUE NOT NULL, + email TEXT NOT NULL, + name TEXT NOT NULL, + organisation TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('pilot', 'cab', 'fenja')), + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + created_by_user_id INTEGER REFERENCES users(id) +); + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE contributions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + type TEXT NOT NULL CHECK(type IN ('idea', 'inspiration', 'question')), + body_md TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + edited_at TEXT, + hidden_at TEXT +); + +CREATE TABLE reactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + contribution_id INTEGER NOT NULL REFERENCES contributions(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, contribution_id) +); + +CREATE TABLE replies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contribution_id INTEGER NOT NULL REFERENCES contributions(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + body_md TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE attendance ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + meeting_slug TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('yes', 'no')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, meeting_slug) +); + +CREATE INDEX idx_sessions_user ON sessions(user_id); +CREATE INDEX idx_contributions_type ON contributions(type); +CREATE INDEX idx_reactions_contrib ON reactions(contribution_id); +CREATE INDEX idx_replies_contrib ON replies(contribution_id); +CREATE INDEX idx_attendance_meeting ON attendance(meeting_slug); diff --git a/package.json b/package.json index f2a16c9..ad2b273 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,15 @@ "lint": "eslint . && prettier --check .", "test": "vitest run", "db:migrate": "node scripts/migrate.js", - "db:seed": "node scripts/seed.js" + "db:seed": "node scripts/seed.js", + "db:setup": "node scripts/migrate.js && node scripts/seed.js" }, "dependencies": { "@astrojs/node": "^8.3.0", "astro": "^4.16.0", "bcryptjs": "^2.4.3", "better-sqlite3": "^11.3.0", + "marked": "^12.0.0", "zod": "^3.23.8" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3db3d38..ecc3723 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: better-sqlite3: specifier: ^11.3.0 version: 11.10.0 + marked: + specifier: ^12.0.0 + version: 12.0.2 zod: specifier: ^3.23.8 version: 3.25.76 @@ -1244,6 +1247,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@12.0.2: + resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==} + engines: {node: '>= 18'} + hasBin: true + mdast-util-definitions@6.0.0: resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} @@ -3141,6 +3149,8 @@ snapshots: markdown-table@3.0.4: {} + marked@12.0.2: {} + mdast-util-definitions@6.0.0: dependencies: '@types/mdast': 4.0.4 diff --git a/scripts/migrate.js b/scripts/migrate.js new file mode 100644 index 0000000..88c5211 --- /dev/null +++ b/scripts/migrate.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import Database from 'better-sqlite3'; +import { readFileSync, readdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const dbPath = join(__dirname, '..', 'bifrost.db'); +const migrationsDir = join(__dirname, '..', 'migrations'); + +const db = new Database(dbPath); +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +db.exec(`CREATE TABLE IF NOT EXISTS _migrations ( + id TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) +)`); + +const applied = new Set( + db.prepare('SELECT id FROM _migrations').all().map(r => r.id) +); + +const files = readdirSync(migrationsDir) + .filter(f => f.endsWith('.sql')) + .sort(); + +let count = 0; +for (const file of files) { + if (applied.has(file)) continue; + const sql = readFileSync(join(migrationsDir, file), 'utf8'); + db.exec(sql); + db.prepare('INSERT INTO _migrations (id) VALUES (?)').run(file); + console.log(` applied: ${file}`); + count++; +} + +if (count === 0) console.log(' nothing to apply — already up to date'); +else console.log(`\n ${count} migration(s) applied.`); +db.close(); diff --git a/scripts/seed.js b/scripts/seed.js new file mode 100644 index 0000000..9b0cdac --- /dev/null +++ b/scripts/seed.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node +import Database from 'better-sqlite3'; +import bcrypt from 'bcryptjs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const db = new Database(join(__dirname, '..', 'bifrost.db')); +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +// Verify schema exists +try { + db.prepare('SELECT id FROM users LIMIT 1').get(); +} catch { + console.error('Error: database tables not found. Run `pnpm db:migrate` first.'); + process.exit(1); +} + +// Wipe existing seed data (idempotent) +db.exec(` + DELETE FROM reactions; + DELETE FROM replies; + DELETE FROM contributions; + DELETE FROM attendance; + DELETE FROM sessions; + DELETE FROM invites; + DELETE FROM users; +`); + +const ROUNDS = 10; + +const users = [ + { + email: 'mette@ssi.dk', + password: 'pilot123', + name: 'Mette Hansen', + organisation: 'Statens Serum Institut', + role: 'pilot', + }, + { + email: 'lars@rigspolitiet.dk', + password: 'cab123', + name: 'Lars Thomsen', + organisation: 'Rigspolitiet', + role: 'cab', + }, + { + email: 'jonathan@fenja.ai', + password: 'fenja123', + name: 'Jonathan', + organisation: 'Fenja AI', + role: 'fenja', + }, +]; + +const insertUser = db.prepare(` + INSERT INTO users (email, password_hash, name, organisation, role, bio) + VALUES (?, ?, ?, ?, ?, ?) +`); + +const userIds = {}; +for (const u of users) { + const hash = bcrypt.hashSync(u.password, ROUNDS); + const result = insertUser.run(u.email, hash, u.name, u.organisation, u.role, ''); + userIds[u.role] = Number(result.lastInsertRowid); + console.log(` created user: ${u.name} (${u.role}) — password: ${u.password}`); +} + +// Sample contributions +const insertContrib = db.prepare(` + INSERT INTO contributions (user_id, type, body_md, created_at) + VALUES (?, ?, ?, ?) +`); + +const now = new Date(); +const minus20 = new Date(now.getTime() - 20 * 60 * 1000).toISOString(); +const minus2h = new Date(now.getTime() - 2 * 60 * 60 * 1000).toISOString(); +const minus1d = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); +const minus3d = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(); + +const c1 = insertContrib.run( + userIds['pilot'], + 'idea', + 'What if the system could **auto-summarise meetings** and link the summary back to the relevant roadmap item? Would save a lot of manual work after each CAB session.', + minus1d +); +const c2 = insertContrib.run( + userIds['cab'], + 'question', + 'How will the system handle documents in **multiple languages**? Most of our operational docs are in Danish, but some legal references are in English or German.', + minus2h +); +const c3 = insertContrib.run( + userIds['pilot'], + 'inspiration', + 'Came across this framing of knowledge management in regulated industries — the idea of an "institutional memory" that stays even when staff rotate. Feels very aligned with what Fenja is building.', + minus3d +); +const c4 = insertContrib.run( + userIds['fenja'], + 'idea', + 'Consider department-level access scoping for v1.1 — some organisations may want certain documents visible only to specific teams, even within the pilot group.', + minus20 +); + +// Reactions +const insertReaction = db.prepare(` + INSERT INTO reactions (user_id, contribution_id) VALUES (?, ?) +`); +insertReaction.run(userIds['cab'], c1.lastInsertRowid); +insertReaction.run(userIds['fenja'], c1.lastInsertRowid); +insertReaction.run(userIds['pilot'], c3.lastInsertRowid); + +// Fenja reply on c1 +db.prepare(` + INSERT INTO replies (contribution_id, user_id, body_md) + VALUES (?, ?, ?) +`).run( + c1.lastInsertRowid, + userIds['fenja'], + 'Great idea — meeting summaries are on our Q3 roadmap. The link back to roadmap items is something we hadn\'t explicitly planned; adding it to the backlog now.' +); + +// Sample attendance +const insertAttendance = db.prepare(` + INSERT OR REPLACE INTO attendance (user_id, meeting_slug, status, updated_at) + VALUES (?, ?, ?, datetime('now')) +`); +insertAttendance.run(userIds['pilot'], '2026-03-20-kickoff', 'yes'); +insertAttendance.run(userIds['cab'], '2026-03-20-kickoff', 'yes'); +insertAttendance.run(userIds['fenja'], '2026-03-20-kickoff', 'yes'); +insertAttendance.run(userIds['pilot'], '2026-04-25-cab-q2-session', 'yes'); +insertAttendance.run(userIds['cab'], '2026-04-25-cab-q2-session', 'no'); + +console.log('\n Seed complete.'); +console.log('\n Test credentials:'); +console.log(' mette@ssi.dk / pilot123 (pilot)'); +console.log(' lars@rigspolitiet.dk / cab123 (cab)'); +console.log(' jonathan@fenja.ai / fenja123 (fenja)'); +db.close(); diff --git a/src/content/config.ts b/src/content/config.ts new file mode 100644 index 0000000..f69939d --- /dev/null +++ b/src/content/config.ts @@ -0,0 +1,24 @@ +import { defineCollection, z } from 'astro:content'; + +const updates = defineCollection({ + type: 'content', + schema: z.object({ + title: z.string(), + date: z.coerce.date(), + author: z.string().default('Fenja AI'), + summary: z.string(), + }), +}); + +const meetings = defineCollection({ + type: 'content', + schema: z.object({ + title: z.string(), + date: z.coerce.date(), + time: z.string(), + location: z.string(), + attendees: z.string().optional(), + }), +}); + +export const collections = { updates, meetings }; diff --git a/src/content/meetings/2026-03-20-kickoff.md b/src/content/meetings/2026-03-20-kickoff.md new file mode 100644 index 0000000..b401cc8 --- /dev/null +++ b/src/content/meetings/2026-03-20-kickoff.md @@ -0,0 +1,38 @@ +--- +title: "Pilot kickoff" +date: 2026-03-20 +time: "13:00–15:00 CET" +location: "Zoom — recording shared after" +attendees: "All pilot participants and CAB members" +--- + +## Agenda + +1. Welcome and introductions — 15 min +2. Project Bifrost: the problem we are solving — 20 min +3. The sovereign AI platform: architecture overview — 20 min +4. Pilot scope and success criteria — 15 min +5. The hub: how to use it during the pilot — 10 min +6. Open questions — 40 min + +## Notes + +A strong start. + +Fourteen participants across five organisations joined the kickoff. The energy was focused — people came prepared. + +**Key themes from open questions** + +*Traceability.* Several participants raised auditability as a hard requirement. The system must be able to explain not just what answer it gave, but which documents informed it and with what confidence. This is not a differentiator — it is table stakes in their regulatory environments. We confirmed this is core to the architecture, not a bolt-on. + +*Contextual awareness.* Participants want the system to understand the regulatory and organisational context without being prompted each time. The Fenja Knowledge component (on the later roadmap) addresses this directly. We previewed the concept — the response was "when, not if." + +*Data residency.* Every participant confirmed that data leaving their infrastructure — even temporarily, even encrypted — is a showstopper with their legal teams. The customer-hosted model is not just a feature preference; it is a compliance requirement. + +**Decisions made** + +- Pilot success criteria will be defined jointly with each organisation and shared in the next update +- Traceability will be demoed explicitly at the Q2 session +- Meeting notes will be posted in the hub within 48 hours of each session + +Next session: **25 April, 14:00 CET.** diff --git a/src/content/meetings/2026-04-25-cab-q2-session.md b/src/content/meetings/2026-04-25-cab-q2-session.md new file mode 100644 index 0000000..e02bb61 --- /dev/null +++ b/src/content/meetings/2026-04-25-cab-q2-session.md @@ -0,0 +1,22 @@ +--- +title: "CAB Q2 session" +date: 2026-04-25 +time: "14:00–16:00 CET" +location: "Zoom — link sent by email the morning of" +attendees: "All CAB members and pilot participants" +--- + +## Agenda + +1. Sprint 2 demo — contribute feed, reactions, and Fenja replies — 30 min +2. Roadmap horizon review — moving items from Next to In Progress — 20 min +3. Contributions from the feed — three items for group discussion — 30 min + - Meeting summaries → roadmap linking + - Multilingual document handling + - Department-level access scoping +4. Q3 priorities: what matters most to your organisation — 30 min +5. Open questions — 10 min + +## Notes + +*Notes will be posted within 48 hours of the session.* diff --git a/src/content/meetings/2026-05-15-mid-pilot-review.md b/src/content/meetings/2026-05-15-mid-pilot-review.md new file mode 100644 index 0000000..4493a3c --- /dev/null +++ b/src/content/meetings/2026-05-15-mid-pilot-review.md @@ -0,0 +1,15 @@ +--- +title: "Mid-pilot review" +date: 2026-05-15 +time: "13:00–15:00 CET" +location: "TBC — Copenhagen or Zoom" +attendees: "All CAB members and pilot participants" +--- + +## Agenda + +*Agenda will be posted two weeks before the session.* + +## Notes + +*Notes will be posted within 48 hours of the session.* diff --git a/src/content/updates/2026-04-01-pilot-infrastructure.md b/src/content/updates/2026-04-01-pilot-infrastructure.md new file mode 100644 index 0000000..6629a71 --- /dev/null +++ b/src/content/updates/2026-04-01-pilot-infrastructure.md @@ -0,0 +1,27 @@ +--- +title: Infrastructure is ready. Invites going out. +date: 2026-04-01 +author: Fenja AI +summary: The Hetzner VPS is provisioned, HTTPS is live, and the database is seeded. We are issuing the first wave of invitations today. +--- + +The foundation is in place. + +The Bifrost hub is running on a Hetzner CAX11 in Helsinki — ARM, 4 GB, behind Caddy with automatic HTTPS via Let's Encrypt. The SQLite database is initialised and backed up nightly to a Hetzner Storage Box. + +**What this means for you** + +You should receive your invitation link by separate message today. Click the link, set a password, and you will land in the hub. The link is personal, single-use, and valid for 14 days. If it expires before you use it, contact Jonathan and we will issue a new one. + +**What is here right now** + +- This updates log +- The roadmap (three horizons: in progress, next, later) +- A contribute page where you can post ideas, questions, and inspiration +- The calendar for CAB meetings — the kickoff notes from March are already there + +The participants directory, product preview, and vision page are live as well. The admin panel is accessible to the Fenja team. + +**What is coming this week** + +The second sprint focuses on the contribute feed — reactions, Fenja replies, and the filtering by type. More in the next update. diff --git a/src/content/updates/2026-04-08-auth-and-content.md b/src/content/updates/2026-04-08-auth-and-content.md new file mode 100644 index 0000000..24681d8 --- /dev/null +++ b/src/content/updates/2026-04-08-auth-and-content.md @@ -0,0 +1,28 @@ +--- +title: Authentication, content pipeline, and the first CAB session. +date: 2026-04-08 +author: Fenja AI +summary: Week one complete. Auth is solid, the contribution feed is working, and the kickoff session left us energised and clearer on priorities. +--- + +A productive week. + +**What shipped** + +The authentication system is complete — invite tokens, password hashing, session cookies, the invite redemption flow, and the admin panel for issuing and revoking invites. The HMAC-signed tokens are short-lived (14 days) and single-use; a compromised link cannot be replayed after first use. + +The content pipeline for updates and meetings is live. Markdown files in the repository render as editorial pages — generous type, proper line heights, no visual clutter. + +**The kickoff session** + +The CAB kickoff on 20 March set a useful baseline. Two themes came up repeatedly: *traceability* (being able to explain to an auditor exactly what the system did and why) and *contextual awareness* (the system understanding the regulatory environment without being explicitly told each time). + +Both are core to the Bifrost roadmap. We will address them specifically in the mid-pilot review. + +**Next sprint** + +- Reactions and replies on contributions +- Meeting attendance RSVP +- Roadmap polish — pilot-only tags + +Progress continues. diff --git a/src/content/updates/2026-04-15-week-two.md b/src/content/updates/2026-04-15-week-two.md new file mode 100644 index 0000000..8146cea --- /dev/null +++ b/src/content/updates/2026-04-15-week-two.md @@ -0,0 +1,34 @@ +--- +title: Week two. Reactions, replies, and a Q2 agenda. +date: 2026-04-15 +author: Fenja AI +summary: The contribute feed is fully interactive. The Q2 CAB session agenda is posted. Three things worth your attention before Thursday. +--- + +Shorter update this week — the work speaks for itself. + +**Contribute feed is interactive** + +Reactions (+1), Fenja replies, and type filtering are live. The feed defaults to newest-first; there is a "most acknowledged" sort available. Contributions can be edited by the author for ten minutes after posting, then lock. The Fenja team can reply at any point. + +Post something. We are reading everything. + +**Q2 CAB session — 25 April** + +The agenda for the second CAB session is posted in the calendar. Three items: + +1. Sprint 2 demo — the contribute feed in action, with real data from the pilot +2. Roadmap horizon review — what moves from *Next* to *In progress* +3. Open questions from the contribution feed + +Add your attendance mark before Thursday. The session is two hours, video only. + +**Before you go** + +Three contributions in the feed worth reading if you have not seen them yet: + +- Mette's idea on meeting summaries linking to roadmap items +- Lars's question on multilingual document handling +- The department-level scoping idea from the Fenja team + +All three are on the Q2 agenda. Come ready to discuss. diff --git a/src/env.d.ts b/src/env.d.ts index acef35f..f6b1df3 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,2 +1,8 @@ /// /// + +declare namespace App { + interface Locals { + user: import('./lib/db').UserPublic; + } +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..989741d --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,86 @@ +import { createHmac, randomBytes, createHash } from 'crypto'; +import bcrypt from 'bcryptjs'; +import type { AstroCookies } from 'astro'; +import { + createDbSession, getDbSession, deleteDbSession, + getUserById, getUserPublicById, updateUserLastSeen, + type UserPublic, +} from './db.js'; + +const SECRET = process.env.BIFROST_SECRET ?? 'dev-secret-do-not-use-in-production'; +const COOKIE = 'bifrost_session'; +const SESSION_DAYS = 7; +const INVITE_DAYS = 14; + +// ── Passwords ──────────────────────────────────────────────────── + +export function hashPassword(password: string): string { + return bcrypt.hashSync(password, 12); +} + +export function verifyPassword(password: string, hash: string): boolean { + return bcrypt.compareSync(password, hash); +} + +// ── Invite tokens ──────────────────────────────────────────────── + +/** Returns the URL-safe token (give to user) and its hash (store in DB). */ +export function generateInviteToken(): { token: string; tokenHash: string } { + const raw = randomBytes(20).toString('base64url'); + const mac = createHmac('sha256', SECRET).update(raw).digest('base64url').slice(0, 16); + const token = `${raw}.${mac}`; + const tokenHash = createHash('sha256').update(token).digest('hex'); + return { token, tokenHash }; +} + +export function verifyInviteTokenFormat(token: string): boolean { + const dot = token.lastIndexOf('.'); + if (dot === -1) return false; + const raw = token.slice(0, dot); + const mac = token.slice(dot + 1); + const expected = createHmac('sha256', SECRET).update(raw).digest('base64url').slice(0, 16); + return mac === expected && raw.length > 0; +} + +export function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +export function inviteExpiresAt(): string { + const d = new Date(); + d.setDate(d.getDate() + INVITE_DAYS); + return d.toISOString().replace('T', ' ').slice(0, 19); +} + +// ── Sessions ───────────────────────────────────────────────────── + +export function createSession(userId: number, cookies: AstroCookies): void { + const id = randomBytes(32).toString('hex'); + const expires = new Date(); + expires.setDate(expires.getDate() + SESSION_DAYS); + const expiresAt = expires.toISOString().replace('T', ' ').slice(0, 19); + createDbSession(id, userId, expiresAt); + cookies.set(COOKIE, id, { + httpOnly: true, + sameSite: 'lax', + path: '/', + maxAge: SESSION_DAYS * 24 * 60 * 60, + secure: process.env.NODE_ENV === 'production', + }); +} + +export function getSessionUser(cookies: AstroCookies): UserPublic | null { + const id = cookies.get(COOKIE)?.value; + if (!id) return null; + const session = getDbSession(id); + if (!session) return null; + const user = getUserPublicById(session.user_id); + if (user) updateUserLastSeen(user.id); + return user; +} + +export function clearSession(cookies: AstroCookies): void { + const id = cookies.get(COOKIE)?.value; + if (id) deleteDbSession(id); + cookies.delete(COOKIE, { path: '/' }); +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..937ef4e --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,316 @@ +import Database from 'better-sqlite3'; +import { join } from 'path'; + +// ── Types ──────────────────────────────────────────────────────── + +export type Role = 'pilot' | 'cab' | 'fenja'; +export type ContributionType = 'idea' | 'inspiration' | 'question'; +export type AttendanceStatus = 'yes' | 'no'; + +export interface User { + id: number; + email: string; + password_hash: string; + name: string; + organisation: string; + role: Role; + bio: string; + created_at: string; + last_seen_at: string | null; + active: number; +} + +export type UserPublic = Omit; + +export interface Invite { + id: number; + token_hash: string; + email: string; + name: string; + organisation: string; + role: Role; + expires_at: string; + used_at: string | null; + created_at: string; + created_by_user_id: number | null; +} + +export interface Contribution { + id: number; + user_id: number; + type: ContributionType; + body_md: string; + created_at: string; + edited_at: string | null; + hidden_at: string | null; +} + +export interface ContributionRow extends Contribution { + author_name: string; + author_organisation: string; + author_role: Role; + reaction_count: number; +} + +export interface Reply { + id: number; + contribution_id: number; + user_id: number; + body_md: string; + created_at: string; + author_name: string; + author_role: Role; +} + +export interface AttendanceSummary { + yes: number; + no: number; +} + +// ── Connection ─────────────────────────────────────────────────── + +// Persist across Vite HMR reloads in dev +const g = globalThis as typeof globalThis & { __bifrost_db?: Database.Database }; +if (!g.__bifrost_db) { + g.__bifrost_db = new Database(join(process.cwd(), 'bifrost.db')); + g.__bifrost_db.pragma('journal_mode = WAL'); + g.__bifrost_db.pragma('foreign_keys = ON'); +} +const db = g.__bifrost_db; + +// ── Users ──────────────────────────────────────────────────────── + +export function getUserByEmail(email: string): User | null { + return db.prepare( + 'SELECT * FROM users WHERE email = ? AND active = 1' + ).get(email) as User | null; +} + +export function getUserById(id: number): User | null { + return db.prepare( + 'SELECT * FROM users WHERE id = ? AND active = 1' + ).get(id) as User | null; +} + +export function getUserPublicById(id: number): UserPublic | null { + return db.prepare( + 'SELECT id,email,name,organisation,role,bio,created_at,last_seen_at,active FROM users WHERE id = ? AND active = 1' + ).get(id) as UserPublic | null; +} + +export function createUser(data: { + email: string; + password_hash: string; + name: string; + organisation: string; + role: Role; +}): number { + const r = db.prepare( + 'INSERT INTO users (email,password_hash,name,organisation,role) VALUES (?,?,?,?,?)' + ).run(data.email, data.password_hash, data.name, data.organisation, data.role); + return Number(r.lastInsertRowid); +} + +export function updateUserLastSeen(id: number): void { + db.prepare("UPDATE users SET last_seen_at = datetime('now') WHERE id = ?").run(id); +} + +export function updateUserProfile(id: number, name: string, bio: string): void { + db.prepare('UPDATE users SET name = ?, bio = ? WHERE id = ?').run(name, bio, id); +} + +export function updateUserRole(id: number, role: Role): void { + db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id); +} + +export function deactivateUser(id: number): void { + db.prepare('UPDATE users SET active = 0 WHERE id = ?').run(id); +} + +export function getAllUsersPublic(): UserPublic[] { + return db.prepare( + 'SELECT id,email,name,organisation,role,bio,created_at,last_seen_at,active FROM users ORDER BY organisation,name' + ).all() as UserPublic[]; +} + +// ── Sessions ───────────────────────────────────────────────────── + +export function createDbSession(id: string, userId: number, expiresAt: string): void { + db.prepare( + 'INSERT INTO sessions (id,user_id,expires_at) VALUES (?,?,?)' + ).run(id, userId, expiresAt); +} + +export function getDbSession(id: string): { user_id: number; expires_at: string } | null { + return db.prepare( + "SELECT user_id,expires_at FROM sessions WHERE id = ? AND expires_at > datetime('now')" + ).get(id) as { user_id: number; expires_at: string } | null; +} + +export function deleteDbSession(id: string): void { + db.prepare('DELETE FROM sessions WHERE id = ?').run(id); +} + +// ── Invites ────────────────────────────────────────────────────── + +export function createInvite(data: { + token_hash: string; + email: string; + name: string; + organisation: string; + role: Role; + expires_at: string; + created_by_user_id: number; +}): number { + const r = db.prepare(` + INSERT INTO invites (token_hash,email,name,organisation,role,expires_at,created_by_user_id) + VALUES (?,?,?,?,?,?,?) + `).run(data.token_hash, data.email, data.name, data.organisation, data.role, data.expires_at, data.created_by_user_id); + return Number(r.lastInsertRowid); +} + +export function getInviteByTokenHash(tokenHash: string): Invite | null { + return db.prepare( + "SELECT * FROM invites WHERE token_hash = ? AND used_at IS NULL AND expires_at > datetime('now')" + ).get(tokenHash) as Invite | null; +} + +export function markInviteUsed(id: number): void { + db.prepare("UPDATE invites SET used_at = datetime('now') WHERE id = ?").run(id); +} + +export function revokeInvite(id: number): void { + db.prepare("UPDATE invites SET expires_at = datetime('now') WHERE id = ? AND used_at IS NULL").run(id); +} + +export function getAllInvites(): (Invite & { creator_name: string | null })[] { + return db.prepare(` + SELECT i.*, u.name AS creator_name + FROM invites i + LEFT JOIN users u ON u.id = i.created_by_user_id + ORDER BY i.created_at DESC + `).all() as (Invite & { creator_name: string | null })[]; +} + +// ── Contributions ──────────────────────────────────────────────── + +export function createContribution(data: { + user_id: number; + type: ContributionType; + body_md: string; +}): number { + const r = db.prepare( + 'INSERT INTO contributions (user_id,type,body_md) VALUES (?,?,?)' + ).run(data.user_id, data.type, data.body_md); + return Number(r.lastInsertRowid); +} + +export function getContributions(opts: { + type?: ContributionType; + sort?: 'newest' | 'top'; +} = {}): ContributionRow[] { + const typeClause = opts.type ? `AND c.type = '${opts.type}'` : ''; + const orderClause = opts.sort === 'top' + ? 'ORDER BY reaction_count DESC, c.created_at DESC' + : 'ORDER BY c.created_at DESC'; + return db.prepare(` + SELECT + c.*, + u.name AS author_name, + u.organisation AS author_organisation, + u.role AS author_role, + (SELECT COUNT(*) FROM reactions r WHERE r.contribution_id = c.id) AS reaction_count + FROM contributions c + JOIN users u ON u.id = c.user_id + WHERE c.hidden_at IS NULL ${typeClause} + ${orderClause} + `).all() as ContributionRow[]; +} + +export function getContributionById(id: number): Contribution | null { + return db.prepare('SELECT * FROM contributions WHERE id = ?').get(id) as Contribution | null; +} + +export function updateContribution(id: number, body_md: string): void { + db.prepare("UPDATE contributions SET body_md = ?, edited_at = datetime('now') WHERE id = ?") + .run(body_md, id); +} + +export function hideContribution(id: number): void { + db.prepare("UPDATE contributions SET hidden_at = datetime('now') WHERE id = ?").run(id); +} + +// ── Reactions ──────────────────────────────────────────────────── + +export function addReaction(userId: number, contributionId: number): void { + db.prepare('INSERT OR IGNORE INTO reactions (user_id,contribution_id) VALUES (?,?)').run(userId, contributionId); +} + +export function removeReaction(userId: number, contributionId: number): void { + db.prepare('DELETE FROM reactions WHERE user_id = ? AND contribution_id = ?').run(userId, contributionId); +} + +export function hasReaction(userId: number, contributionId: number): boolean { + const r = db.prepare('SELECT id FROM reactions WHERE user_id = ? AND contribution_id = ?').get(userId, contributionId); + return r !== undefined; +} + +export function getReactedIds(userId: number): Set { + const rows = db.prepare('SELECT contribution_id FROM reactions WHERE user_id = ?').all(userId) as { contribution_id: number }[]; + return new Set(rows.map(r => r.contribution_id)); +} + +// ── Replies ────────────────────────────────────────────────────── + +export function createReply(data: { + contribution_id: number; + user_id: number; + body_md: string; +}): void { + db.prepare('INSERT INTO replies (contribution_id,user_id,body_md) VALUES (?,?,?)') + .run(data.contribution_id, data.user_id, data.body_md); +} + +export function getReplies(contributionId: number): Reply[] { + return db.prepare(` + SELECT re.*,u.name AS author_name,u.role AS author_role + FROM replies re + JOIN users u ON u.id = re.user_id + WHERE re.contribution_id = ? + ORDER BY re.created_at ASC + `).all(contributionId) as Reply[]; +} + +// ── Attendance ─────────────────────────────────────────────────── + +export function setAttendance(userId: number, meetingSlug: string, status: AttendanceStatus): void { + db.prepare(` + INSERT INTO attendance (user_id,meeting_slug,status,updated_at) + VALUES (?,?,?,datetime('now')) + ON CONFLICT(user_id,meeting_slug) DO UPDATE SET status=excluded.status, updated_at=excluded.updated_at + `).run(userId, meetingSlug, status); +} + +export function getAttendanceSummary(meetingSlug: string): AttendanceSummary { + const rows = db.prepare( + "SELECT status, COUNT(*) AS n FROM attendance WHERE meeting_slug = ? GROUP BY status" + ).all(meetingSlug) as { status: string; n: number }[]; + const yes = rows.find(r => r.status === 'yes')?.n ?? 0; + const no = rows.find(r => r.status === 'no')?.n ?? 0; + return { yes, no }; +} + +export function getUserAttendance(userId: number, meetingSlug: string): AttendanceStatus | null { + const r = db.prepare( + 'SELECT status FROM attendance WHERE user_id = ? AND meeting_slug = ?' + ).get(userId, meetingSlug) as { status: AttendanceStatus } | undefined; + return r?.status ?? null; +} + +export function getAllAttendance(meetingSlug: string): { user_id: number; status: AttendanceStatus; name: string }[] { + return db.prepare(` + SELECT a.user_id, a.status, u.name + FROM attendance a JOIN users u ON u.id = a.user_id + WHERE a.meeting_slug = ? + `).all(meetingSlug) as { user_id: number; status: AttendanceStatus; name: string }[]; +} diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts new file mode 100644 index 0000000..df0ff90 --- /dev/null +++ b/src/lib/markdown.ts @@ -0,0 +1,28 @@ +import { marked } from 'marked'; + +// Configured once: no GFM tables (not needed), breaks = true for newlines +marked.setOptions({ breaks: true, gfm: true }); + +/** Render markdown-lite to HTML. Input is from trusted authenticated users. */ +export function renderMd(md: string): string { + return marked.parse(md) as string; +} + +/** Format a UTC ISO date string for display in Europe/Copenhagen. */ +export function fmtDate(iso: string, opts: Intl.DateTimeFormatOptions = { + day: 'numeric', month: 'long', year: 'numeric', +}): string { + return new Intl.DateTimeFormat('da-DK', { timeZone: 'Europe/Copenhagen', ...opts }).format(new Date(iso)); +} + +export function fmtDateTime(iso: string): string { + return fmtDate(iso, { + day: 'numeric', month: 'long', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); +} + +/** True if the ISO timestamp is within `minutes` minutes of now. */ +export function withinMinutes(iso: string, minutes: number): boolean { + return (Date.now() - new Date(iso).getTime()) < minutes * 60_000; +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..d836b13 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,17 @@ +import { defineMiddleware } from 'astro/middleware'; +import { getSessionUser } from './lib/auth.js'; + +const PUBLIC = ['/login', '/invite']; + +export const onRequest = defineMiddleware(async (ctx, next) => { + const { pathname } = ctx.url; + + const isPublic = PUBLIC.some(p => pathname === p || pathname.startsWith(p + '/')); + if (isPublic) return next(); + + const user = getSessionUser(ctx.cookies); + if (!user) return ctx.redirect('/login'); + + ctx.locals.user = user; + return next(); +});