From 378ee989bb36363c619433e9fd4f52f00d1ced9a Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Wed, 17 Jun 2026 11:16:40 +0200 Subject: [PATCH] feat(db): production seed script with the real pilot data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scripts/seed-production.js (db:seed:production / db:setup:production): the curated pilot data — 8 council members, 2 Fenja admins, 10 roadmap items, the launch event, the pulse vote, and the welcome dispatch — so a production DB can be built reproducibly from git instead of committing the binary bifrost.db. Idempotent (every insert guarded). No credentials in the repo: council accounts get a random unusable hash; admin temp passwords are hashed at run time from ADMIN_SEED_PASSWORD (placeholder printed if unset, change on first login). Run on deploy as: pnpm db:setup:production. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 4 +- scripts/seed-production.js | 223 +++++++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 scripts/seed-production.js diff --git a/package.json b/package.json index c3d7f69..fed1311 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "db:seed": "node scripts/seed.js", "db:seed:roadmap": "node scripts/seed-roadmap.js", "db:seed:demo": "node scripts/seed-demo.js", - "db:setup": "node scripts/migrate.js && node scripts/seed.js && node scripts/seed-roadmap.js && node scripts/seed-demo.js" + "db:seed:production": "node scripts/seed-production.js", + "db:setup": "node scripts/migrate.js && node scripts/seed.js && node scripts/seed-roadmap.js && node scripts/seed-demo.js", + "db:setup:production": "node scripts/migrate.js && node scripts/seed-production.js" }, "dependencies": { "@astrojs/node": "^8.3.0", diff --git a/scripts/seed-production.js b/scripts/seed-production.js new file mode 100644 index 0000000..a113770 --- /dev/null +++ b/scripts/seed-production.js @@ -0,0 +1,223 @@ +#!/usr/bin/env node +/* --------------------------------------------------------------------------- + * Production seed — the real pilot data, reproducible via git. + * + * Run on deploy AFTER migrations: node scripts/migrate.js && node scripts/seed-production.js + * (Do NOT run scripts/seed.js or seed-demo.js in production — those create + * fake test users and demo content.) + * + * Idempotent: every insert is guarded, so re-running does nothing new. + * + * Credentials are NOT stored in this file. Admin temp passwords are hashed at + * run time from ADMIN_SEED_PASSWORD (falls back to a printed placeholder that + * must be changed on first login). Council members use a random unusable hash + * (their emails are placeholders until real ones are set in /admin). + * ------------------------------------------------------------------------- */ + +import Database from 'better-sqlite3'; +import bcrypt from 'bcryptjs'; +import { randomBytes } from 'node:crypto'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const dbPath = process.env.BIFROST_DB_PATH ?? join(__dirname, '..', 'bifrost.db'); +const ROUNDS = 10; + +const db = new Database(dbPath); +db.pragma('foreign_keys = ON'); + +// ── helpers ──────────────────────────────────────────────────────────────── +function slugifyName(name) { + return name.toLowerCase().replace(/ø/g, 'o').replace(/æ/g, 'ae').replace(/å/g, 'a') + .normalize('NFKD').replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); +} +function uniqueSlug(preferred, name) { + const base = preferred || slugifyName(name) || 'member'; + let c = base, n = 1; + while (db.prepare('SELECT 1 FROM users WHERE slug = ?').get(c)) { n += 1; c = `${base}-${n}`; } + return c; +} +const userExists = (email) => !!db.prepare('SELECT 1 FROM users WHERE email = ?').get(email); +const unusableHash = () => bcrypt.hashSync('disabled-' + randomBytes(8).toString('hex'), ROUNDS); + +// ── data ───────────────────────────────────────────────────────────────── +const COUNCIL = [ + { name: 'Søren Friis', title: 'IT Director', org: 'DSB', email: 'soren.friis@pending.invalid' }, + { name: 'William Irving', title: 'Chief Data & Analytics Officer', org: 'Norlys', email: 'william.irving@pending.invalid' }, + { name: 'Ulla Nygaard Eliassen', title: 'Assoc. Improvement Project Director', org: 'Novo Nordisk', email: 'ulla.nygaard.eliassen@pending.invalid' }, + { name: 'Anna Jessen', title: 'Director, Process Excellence & Digitalization', org: 'Novo Nordisk', email: 'anna.jessen@pending.invalid' }, + { name: 'Mathies Laursen', title: 'CDO', org: 'Nationalbanken', email: 'mathies.laursen@pending.invalid' }, + { name: 'Torben Schütt', title: 'Office Director, Center for Cyber and Digitalization', org: 'Forsvarsministeriet', email: 'torben.schutt@pending.invalid' }, + { name: 'Mads Nyborg', title: 'Chief Consultant, Department of Data and Analytics', org: 'Københavns Kommune', email: 'mads.nyborg@pending.invalid' }, + { name: 'Håkon Daltveit', title: 'Chief Consultant, Department of Data and Analytics', org: 'Københavns Kommune', email: 'hakon.daltveit@pending.invalid' }, +]; +const CAB_JOINED = '2026-06-10'; + +const ADMINS = [ + { name: 'Jonathan Hvid', email: 'joh@fenja.ai', org: 'Fenja AI' }, + { name: 'Arlind Ukshini', email: 'aru@fenja.ai', org: 'Fenja AI' }, +]; + +const ROADMAP = [ + { title: 'MCP Usage', status: 'shipping', target: 'June 2026', o: 1, description: 'Connect Fenja to external MCP servers — databases, internal tools, and other software stacks — so the model can act directly on your existing systems.' }, + { title: 'Extended Logging', status: 'shipping', target: 'June 2026', o: 2, description: 'Granular logging and audit controls, capturing usage down to the individual user or agent.' }, + { title: 'Wiki 2.0', status: 'in_beta', target: 'June 2026', o: 3, description: 'A major wiki upgrade: version history with diffs between revisions, admin-locked pages, an approval flow for edits, and fixes carried over from v1.' }, + { title: 'Interview 2.0', status: 'planned', target: 'June 2026', o: 4, description: 'A major Interviews upgrade: build and manage interview scripts, send invites, track status, view transcripts, compile results, and run user-driven interviews.' }, + { title: 'Fenja Analyse - Alpha Launch', status: 'planned', target: 'July 2026', o: 5, description: 'The first release of Fenja Analyse: ask a question in plain language, run it as a query against a structured database, and get a clear answer backed by the underlying data and references.' }, + { title: 'Fenja Agentic - Alpha Launch', status: 'planned', target: 'August 2026', o: 6, description: 'The first release of Agents: create and orchestrate agents from an admin panel, with built-in monitoring and governance.' }, + { title: 'Fenja Dev - Alpha Launch', status: 'planned', target: 'August 2026', o: 7, description: 'The first release of Fenja Dev: a sovereign IDE with terminal and git integration, so developers can build safely in a self-contained environment.' }, + { title: 'Analyse Reports', status: 'planned', target: 'October 2026', o: 8, description: 'Structured reporting for Fenja Analyse: admins define report templates so generated reports follow the organisation’s styling and structure.' }, + { title: 'Self-Service Agents', status: 'planned', target: 'November 2026', o: 9, description: 'A major expansion of the agent experience, letting users create and run their own agents without admin involvement.' }, + { title: 'Self-service Routines & Skills', status: 'exploring', target: 'November 2026', o: 10, description: 'Personal and domain-level routines and skills: users build reusable, tailored workflows, schedule recurring tasks, and create a personal wiki.' }, +]; + +const EVENT = { + slug: 'project-bifrost-launch', + title: 'Project Bifrost Launch', + kind: 'summit', + description: 'Introduction of the advisory board members. Walkthrough of current status with product demos. Plenty of time for discussions about what should be prioritized for the coming months.', + location: 'Klosterstræde 9, 1157 København K', + starts_at: '2026-06-22 07:00:00', + ends_at: '2026-06-22 11:00:00', + capacity: 10, + duration_label: 'Half day', +}; + +const PULSE = { + question: 'What are you most excited to use Fenja for?', + options: [ + 'A sovereign AI chatbot grounded in your business data', + 'A sovereign AI coding platform', + 'An AI-powered data analysis tool', + 'A hub for building and monitoring AI agents', + ], + opens_at: '2026-06-10 08:00:00', + closes_at: '2026-06-23 21:59:59', + status: 'open', +}; + +const DISPATCH = { + title: 'Welcome to Project Bifrost', + excerpt: 'A welcome to the new council, with a short guide to what this space is for and how to take part.', + kind: 'note', + status: 'published', + published_at: '2026-06-10 09:00:00', + body: `To our new council members, + +We are genuinely glad you are here. Bringing this group together has been one of the most rewarding parts of building Fenja, and the fact that you have chosen to lend your time and judgement to it means a great deal to us. Thank you for saying yes. + +Project Bifrost is the home for our work together over the coming months. It is a small, private space for the people helping to shape a sovereign AI platform built for regulated environments. Everything here is made with this group in mind, and it will grow as the pilot does. + +## What you can do here + +**Follow the roadmap.** It shows what we are shipping, what is in beta, and what we are planning next. It is the clearest view of where the product is heading, and we keep it honest. + +**Read the dispatches.** This note is one of them. Dispatches are how we share progress, the decisions behind it, and the occasional look at how the work actually happens. When something changes, you will read about it here first. + +**Come to the gatherings.** Upcoming events live on the platform, starting with our launch on the 22nd of June. You can RSVP directly, and we would love to see you there in person. + +**Tell us what you think.** Now and then we will ask a short question through a pulse vote. Your answers feed straight into how we prioritise, so please weigh in. + +## What to expect + +Steady, plain updates. We write when there is something worth saying, so a new dispatch always means real movement. Expect the roadmap kept current, notes as the work progresses, and clear notice ahead of anything we are inviting you to. + +## How to take part + +It is simple. Read what catches your eye, vote when we ask, come to what you can, and say so when you think we have it wrong. The pilot is at its best when it is a real conversation, and your perspective is the reason you are on this council. + +Welcome aboard. We are proud to have you with us, and we are looking forward to building something we can all stand behind. + +Warmly, +The Fenja team`, +}; + +// ── seed ─────────────────────────────────────────────────────────────────── +const seed = db.transaction(() => { + const log = []; + + // Council (member numbers 1..8, in listed order) + const insUser = db.prepare( + `INSERT INTO users (email, password_hash, name, organisation, role, slug, cab_joined_date, member_number, title) + VALUES (?,?,?,?, 'cab', ?,?,?,?)` + ); + COUNCIL.forEach((m, i) => { + if (userExists(m.email)) return; + insUser.run(m.email, unusableHash(), m.name, m.org, uniqueSlug(slugifyName(m.name), m.name), CAB_JOINED, i + 1, m.title); + log.push(`council: ${m.name}`); + }); + + // Admins (fenja). Temp password from env or a printed placeholder. + const adminPw = process.env.ADMIN_SEED_PASSWORD ?? 'BifrostAdmin-change-me'; + const insAdmin = db.prepare( + `INSERT INTO users (email, password_hash, name, organisation, role, slug) + VALUES (?,?,?,?, 'fenja', ?)` + ); + ADMINS.forEach((a) => { + if (userExists(a.email)) return; + insAdmin.run(a.email, bcrypt.hashSync(adminPw, ROUNDS), a.name, a.org, uniqueSlug(slugifyName(a.name), a.name)); + log.push(`admin: ${a.name} <${a.email}> (temp password: ${adminPw})`); + }); + + // Roadmap (dedupe by title) + const insRoad = db.prepare( + `INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at) + VALUES (?,?,?,?,?,?)` + ); + for (const r of ROADMAP) { + if (db.prepare('SELECT 1 FROM roadmap_items WHERE title = ?').get(r.title)) continue; + const shippedAt = r.status === 'shipping' ? (EVENT_NOW()) : null; + insRoad.run(r.title, r.description, r.status, r.target, r.o, shippedAt); + log.push(`roadmap: ${r.title}`); + } + + // Event (dedupe by slug) + if (!db.prepare('SELECT 1 FROM events WHERE slug = ?').get(EVENT.slug)) { + const fenja = db.prepare("SELECT id FROM users WHERE role = 'fenja' ORDER BY id LIMIT 1").get(); + db.prepare( + `INSERT INTO events (slug, title, kind, description, location, starts_at, ends_at, capacity, duration_label, created_by) + VALUES (?,?,?,?,?,?,?,?,?,?)` + ).run(EVENT.slug, EVENT.title, EVENT.kind, EVENT.description, EVENT.location, + EVENT.starts_at, EVENT.ends_at, EVENT.capacity, EVENT.duration_label, fenja?.id ?? null); + log.push(`event: ${EVENT.title}`); + } + + // Pulse (dedupe by question) — capture id for the dispatch link + let pulseId = db.prepare('SELECT id FROM pulses WHERE question = ?').get(PULSE.question)?.id; + if (!pulseId) { + const author = db.prepare("SELECT id FROM users WHERE role = 'fenja' ORDER BY id LIMIT 1").get(); + pulseId = Number(db.prepare( + `INSERT INTO pulses (question, context, options, opens_at, closes_at, status, created_by) + VALUES (?,?,?,?,?,?,?)` + ).run(PULSE.question, null, JSON.stringify(PULSE.options), PULSE.opens_at, PULSE.closes_at, PULSE.status, author?.id ?? null).lastInsertRowid); + log.push(`pulse: ${PULSE.question}`); + } + + // Dispatch (dedupe by title), linked to the pulse + if (!db.prepare('SELECT 1 FROM dispatches WHERE title = ?').get(DISPATCH.title)) { + const author = db.prepare("SELECT id FROM users WHERE role = 'fenja' ORDER BY id LIMIT 1").get(); + db.prepare( + `INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, pulse_id) + VALUES (?,?,?,?,?,?,?,?)` + ).run(DISPATCH.title, DISPATCH.body, DISPATCH.excerpt, DISPATCH.kind, author?.id ?? null, DISPATCH.status, DISPATCH.published_at, pulseId); + log.push(`dispatch: ${DISPATCH.title}`); + } + + return log; +}); + +// shipped_at for shipping roadmap items — stamped at seed time. +function EVENT_NOW() { + return new Date().toISOString().slice(0, 19).replace('T', ' '); +} + +const created = seed(); +if (created.length === 0) { + console.log(' production data already present — nothing to seed.'); +} else { + console.log(` seeded ${created.length} row(s):`); + for (const l of created) console.log(' + ' + l); +} +db.close();