dorfteich/apps/api/prisma/seed.ts
Claude Fable 5 f0a82bad20
Some checks failed
CD / Build and push images (push) Successful in 3m16s
CI / Lint, typecheck, test (push) Successful in 3m5s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Failing after 3m35s
CD / Promote to Int (push) Has been skipped
CI / Auth e2e pack (push) Successful in 5m6s
CI / Import/export fidelity gate (push) Successful in 43s
CI / Build container images (push) Has been skipped
Add the first-run setup wizard API with env-backed secret store (#80)
When the api runs against a database without the setup.completedAt
marker, a global SetupGuard answers every non-exempt route with 503
setup_required; only /setup/*, health probes, and the session routes
stay reachable. The wizard steps (POST /setup/admin|instance|smtp|
registration|complete) write straight to their production homes; the
Site Admin step signs its creator in, later steps require that session.
Completing sets the marker and locks every step permanently (410, also
across restarts, and not reopenable via PATCH /admin/settings).

SMTP entered in the wizard is verified with a live delivery test first
(failure blocks the step with the transport error as detail) and then
persisted to the new env-backed secret store: a mode-600 dotenv file on
the new `secrets` volume (SECRETS_FILE). Explicit container env always
wins over the store; empty compose-passed strings count as unset. The
mail transport now resolves lazily through SmtpConfigService so wizard
changes apply without a restart.

SETUP_ADMIN_* env pre-seeds the whole wizard at boot for automated
deploys; a backfill migration marks instances that already have a Site
Admin as completed, and seed/vitest global-setup do the same for
fixture databases. The setup e2e suite provisions its own fresh
database (CREATE DATABASE + migrate deploy) per run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 15:10:28 +02:00

408 lines
13 KiB
TypeScript

/**
* Development/Test fixture seeding. Idempotent: running it twice must not
* duplicate anything. Never run against production data.
*
* Fixture matrix (documented in apps/web/e2e/README.md):
* fixture-admin active, Site Admin
* fixture-user active, regular account
* fixture-editor active, regular account (a second non-admin for the
* collab permission packs: reader/editor of another's pond)
* fixture-viewer active, regular account, never a member (for the
* `authenticated`/`public` access-rule cases, issue #55)
* fixture-pending registered but e-mail not verified
*
* All fixture accounts share the password below — they exist only on
* dev machines and disposable CI/Test databases. On shared stages
* (test/int), set FIXTURE_ADMIN_PASSWORD / FIXTURE_USER_PASSWORD to give
* those two accounts non-public passwords.
*
* Content fixtures (issue #32): a shared pond owned by fixture-user with
* two pages — "Every Element" (every editor schema node/mark, #24, loaded
* from the checked-in `fixtures/content-page.yjs`; regenerate via
* `pnpm --filter @dorfteich/api fixtures:regenerate` after editing
* `fixtures/content-page.md`) and "Fixture Image" (one real, servable
* uploaded image) — for the content regression pack and manual QA.
*/
import { readFileSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import {
docToHtml,
docToMarkdown,
docToPlainText,
editorSchema,
extractOutline,
slugify,
} from '@dorfteich/shared';
import { Prisma, PrismaClient, UserStatus } from '@prisma/client';
import { generateKeyBetween } from 'fractional-indexing';
import { prosemirrorJSONToYXmlFragment, yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror';
import * as Y from 'yjs';
import { Node as ProseMirrorNode } from 'prosemirror-model';
import { hashPassword } from '../src/users/password';
export const FIXTURE_PASSWORD = 'fixture passwort 123';
const PASSWORD_OVERRIDES: Record<string, string | undefined> = {
'fixture-admin': process.env.FIXTURE_ADMIN_PASSWORD,
'fixture-user': process.env.FIXTURE_USER_PASSWORD,
};
const prisma = new PrismaClient();
interface FixtureUser {
username: string;
displayName: string;
status: UserStatus;
isSiteAdmin: boolean;
}
const FIXTURES: FixtureUser[] = [
{ username: 'fixture-admin', displayName: 'Fixture Admin', status: 'ACTIVE', isSiteAdmin: true },
{ username: 'fixture-user', displayName: 'Fixture User', status: 'ACTIVE', isSiteAdmin: false },
{
username: 'fixture-editor',
displayName: 'Fixture Editor',
status: 'ACTIVE',
isSiteAdmin: false,
},
{
// A signed-in, non-admin, non-member account: for `authenticated`/`public`
// access-rule cases (issue #55) where the viewer must be no one's member.
username: 'fixture-viewer',
displayName: 'Fixture Viewer',
status: 'ACTIVE',
isSiteAdmin: false,
},
{
// The "foreign user" of the permission matrix (issue #60): signed in, but
// a member of nothing — should see 404 everywhere.
username: 'fixture-outsider',
displayName: 'Fixture Outsider',
status: 'ACTIVE',
isSiteAdmin: false,
},
{
username: 'fixture-pending',
displayName: 'Fixture Pending',
status: 'PENDING_VERIFICATION',
isSiteAdmin: false,
},
];
/**
* Every pond needs its owner's Pond Admin grant — access is decided solely
* by role_grants from M5 on (issue #52). Idempotent, matching the
* owner_admin_grants migration backfill.
*/
async function ensureOwnerAdminGrant(pondId: string, ownerId: string): Promise<void> {
const existing = await prisma.roleGrant.findFirst({
where: {
pondId,
subjectType: 'USER',
subjectId: ownerId,
role: 'POND_ADMIN',
scopeType: 'POND',
},
select: { id: true },
});
if (existing) return;
await prisma.roleGrant.create({
data: {
pondId,
subjectType: 'USER',
subjectId: ownerId,
role: 'POND_ADMIN',
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: ownerId,
},
});
}
async function upsertFixtureUser(fixture: FixtureUser): Promise<string> {
const email = `${fixture.username}@dorfteich.test`;
const user = await prisma.user.upsert({
where: { username: fixture.username },
create: {
username: fixture.username,
email,
displayName: fixture.displayName,
locale: 'de',
status: fixture.status,
isSiteAdmin: fixture.isSiteAdmin,
emailVerifiedAt: fixture.status === 'ACTIVE' ? new Date() : null,
},
update: {
status: fixture.status,
isSiteAdmin: fixture.isSiteAdmin,
},
});
// Re-hash on every run so a changed override takes effect on re-seed.
const credential = await hashPassword(PASSWORD_OVERRIDES[fixture.username] ?? FIXTURE_PASSWORD);
await prisma.userIdentity.upsert({
where: { provider_subject: { provider: 'password', subject: user.id } },
create: {
userId: user.id,
provider: 'password',
subject: user.id,
credential,
},
update: { credential },
});
// Active accounts get their personal pond, mirroring what e-mail
// verification does for real signups (issue #21).
if (fixture.status === 'ACTIVE') {
const existing = await prisma.pond.findFirst({
where: { ownerId: user.id, type: 'PERSONAL' },
select: { id: true },
});
const pondId =
existing?.id ??
(
await prisma.pond.create({
data: {
slug: slugify(fixture.displayName) || fixture.username,
name: fixture.displayName,
type: 'PERSONAL',
ownerId: user.id,
},
})
).id;
// Also for a pre-existing pond: a dev database shared with test suites can
// lose the owner grant to a cleanup — re-seeding must heal it (the seed's
// documented contract is "idempotent", not "first run only").
await ensureOwnerAdminGrant(pondId, user.id);
// The instance default for additional_ponds is 0 (ADR 0011) — give
// the fixtures headroom so pond flows are exercisable in dev/e2e.
await prisma.quotaOverride.upsert({
where: {
subjectType_subjectId_quotaKey: {
subjectType: 'USER',
subjectId: user.id,
quotaKey: 'additional_ponds',
},
},
create: {
subjectType: 'USER',
subjectId: user.id,
quotaKey: 'additional_ponds',
value: 100,
},
update: { value: 100 },
});
}
return user.id;
}
interface DerivedContent {
plainText: string;
markdown: string;
html: string;
outline: Prisma.InputJsonValue;
}
function deriveContentOf(doc: ProseMirrorNode): DerivedContent {
return {
plainText: docToPlainText(doc),
markdown: docToMarkdown(doc),
html: docToHtml(doc),
outline: extractOutline(doc) as unknown as Prisma.InputJsonValue,
};
}
function docFromYjsState(state: Uint8Array): ProseMirrorNode {
const ydoc = new Y.Doc();
Y.applyUpdate(ydoc, state);
const doc = yXmlFragmentToProseMirrorRootNode(ydoc.getXmlFragment('default'), editorSchema);
ydoc.destroy();
return doc;
}
async function upsertFixturePage(
pondId: string,
slug: string,
title: string,
ownerId: string,
state: Uint8Array,
content: DerivedContent,
): Promise<string> {
const existing = await prisma.page.findFirst({ where: { pondId, slug }, select: { id: true } });
if (existing) {
await prisma.page.update({
where: { id: existing.id },
data: {
ydocState: state,
contentCache: { upsert: { create: content, update: content } },
},
});
return existing.id;
}
const last = await prisma.page.findFirst({
where: { pondId },
orderBy: { sortKey: 'desc' },
select: { sortKey: true },
});
const page = await prisma.page.create({
data: {
pondId,
title,
slug,
sortKey: generateKeyBetween(last?.sortKey ?? null, null),
ydocState: state,
createdBy: ownerId,
contentCache: { create: content },
},
});
return page.id;
}
const FIXTURE_POND_SLUG = 'content-fixtures';
// 1x1 transparent PNG — only the magic bytes matter for a real, servable
// (if visually trivial) fixture image.
const FIXTURE_IMAGE_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
const FIXTURE_IMAGE_ID = '00000000-0000-4000-8000-000000000001';
/** Shared content pond + fixture pages (issue #32). */
async function seedContentFixtures(ownerId: string): Promise<void> {
let pond = await prisma.pond.findUnique({ where: { slug: FIXTURE_POND_SLUG } });
if (!pond) {
pond = await prisma.pond.create({
data: {
slug: FIXTURE_POND_SLUG,
name: 'Content Fixtures',
type: 'SHARED',
ownerId,
},
});
}
await ensureOwnerAdminGrant(pond.id, ownerId);
await prisma.quotaOverride.upsert({
where: {
subjectType_subjectId_quotaKey: {
subjectType: 'POND',
subjectId: pond.id,
quotaKey: 'storage_bytes',
},
},
create: {
subjectType: 'POND',
subjectId: pond.id,
quotaKey: 'storage_bytes',
value: 100 * 1024 * 1024,
},
update: { value: 100 * 1024 * 1024 },
});
// "Every Element": the checked-in Yjs snapshot regenerated from
// fixtures/content-page.md — covers every editor schema node/mark (#24).
const everyElementState = readFileSync(join(__dirname, 'fixtures/content-page.yjs'));
const everyElementDoc = docFromYjsState(everyElementState);
await upsertFixturePage(
pond.id,
'every-element',
'Every Element',
ownerId,
new Uint8Array(everyElementState),
deriveContentOf(everyElementDoc),
);
// "Fixture Image": one real, servable uploaded image (the Markdown
// fixture above only carries a placeholder fileId for round-trip
// testing — this is the one that actually resolves via /media/:fileId).
const uploadsDir = process.env.UPLOADS_DIR ?? './data/uploads';
const imageBytes = Buffer.from(FIXTURE_IMAGE_PNG_BASE64, 'base64');
await mkdir(join(uploadsDir, pond.id), { recursive: true });
await writeFile(join(uploadsDir, pond.id, FIXTURE_IMAGE_ID), imageBytes);
await prisma.attachment.upsert({
where: { id: FIXTURE_IMAGE_ID },
create: {
id: FIXTURE_IMAGE_ID,
pondId: pond.id,
fileName: 'fixture.png',
mimeType: 'image/png',
sizeBytes: imageBytes.length,
storagePath: `${pond.id}/${FIXTURE_IMAGE_ID}`,
uploadedBy: ownerId,
},
update: {},
});
await prisma.pondUsage.upsert({
where: { pondId: pond.id },
create: { pondId: pond.id, storageBytesUsed: imageBytes.length },
update: {},
});
const imageDoc = editorSchema.node('doc', null, [
editorSchema.node('heading', { level: 1 }, [editorSchema.text('Fixture Image')]),
editorSchema.node('paragraph', null, [
editorSchema.node('image', {
fileId: FIXTURE_IMAGE_ID,
alt: 'A tiny fixture image',
width: null,
}),
]),
]);
const imageYdoc = new Y.Doc();
prosemirrorJSONToYXmlFragment(
editorSchema,
imageDoc.toJSON(),
imageYdoc.getXmlFragment('default'),
);
const imageState = new Uint8Array(Y.encodeStateAsUpdate(imageYdoc));
imageYdoc.destroy();
const imagePageId = await upsertFixturePage(
pond.id,
'fixture-image',
'Fixture Image',
ownerId,
imageState,
deriveContentOf(imageDoc),
);
await prisma.attachment.update({
where: { id: FIXTURE_IMAGE_ID },
data: { pageId: imagePageId },
});
}
async function main(): Promise<void> {
// Fresh rate-limit budget for e2e runs — seed targets are always
// disposable dev/CI databases, never production.
await prisma.rateLimit.deleteMany({});
let contentOwnerId: string | undefined;
for (const fixture of FIXTURES) {
const userId = await upsertFixtureUser(fixture);
if (fixture.username === 'fixture-user') contentOwnerId = userId;
}
if (contentOwnerId) await seedContentFixtures(contentOwnerId);
// Seeded environments are configured by definition: mark the first-run
// setup wizard (issue #80) as completed so e2e stacks and stages never
// hit the setup gate. Never overwrite an existing (real) completion.
const setupMarker = await prisma.instanceSetting.findUnique({
where: { key: 'setup.completedAt' },
});
if (!setupMarker) {
await prisma.instanceSetting.create({
data: { key: 'setup.completedAt', value: new Date().toISOString() },
});
}
await prisma.instanceSetting.upsert({
where: { key: 'seed.marker' },
create: { key: 'seed.marker', value: { seededAt: new Date().toISOString() } },
update: { value: { seededAt: new Date().toISOString() } },
});
console.log(`seed: done (${FIXTURES.length} fixture users)`);
}
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());