@elytracms/studio 0.0.95 → 0.0.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/convex/cms.ts +272 -16
  2. package/convex/delivery-folded-drift.test.ts +104 -0
  3. package/convex/delivery.ts +24 -8
  4. package/convex/depth-codec-lookalike.test.ts +192 -0
  5. package/convex/fixture-accounts.test.ts +305 -0
  6. package/convex/fixtureAccounts.ts +298 -0
  7. package/convex/incarnation.test.ts +149 -0
  8. package/convex/ingest.test.ts +145 -3
  9. package/convex/ingest.ts +123 -17
  10. package/convex/receipt-races.test.ts +184 -0
  11. package/convex/records-zod-copy.test.ts +76 -0
  12. package/convex/schema.ts +20 -1
  13. package/convex/strict-delete.test.ts +129 -0
  14. package/convex/strict-reference-race.test.ts +139 -0
  15. package/dist/components/ai/ai-chat-panel.js +15 -2
  16. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  17. package/dist/components/ai/translate-document-action.d.ts +6 -0
  18. package/dist/components/ai/translate-document-action.js.map +1 -1
  19. package/dist/components/ai/translate-document-dialog.d.ts +1 -1
  20. package/dist/components/ai/translate-document-dialog.js +5 -5
  21. package/dist/components/ai/translate-document-dialog.js.map +1 -1
  22. package/dist/components/assets/asset-detail.js +5 -2
  23. package/dist/components/assets/asset-detail.js.map +1 -1
  24. package/dist/components/assets/asset-locale.d.ts +7 -0
  25. package/dist/components/assets/asset-locale.js +16 -2
  26. package/dist/components/assets/asset-locale.js.map +1 -1
  27. package/dist/components/assets/asset-meta-drift-notice.d.ts +13 -0
  28. package/dist/components/assets/asset-meta-drift-notice.js +22 -0
  29. package/dist/components/assets/asset-meta-drift-notice.js.map +1 -0
  30. package/dist/components/assets/asset-meta-label.d.ts +2 -1
  31. package/dist/components/assets/asset-meta-label.js +4 -2
  32. package/dist/components/assets/asset-meta-label.js.map +1 -1
  33. package/dist/components/assets/asset-metadata.d.ts +33 -13
  34. package/dist/components/assets/asset-metadata.js +46 -22
  35. package/dist/components/assets/asset-metadata.js.map +1 -1
  36. package/dist/components/assets/asset-search.d.ts +6 -2
  37. package/dist/components/assets/asset-search.js +15 -5
  38. package/dist/components/assets/asset-search.js.map +1 -1
  39. package/dist/components/assets/assets-collection-view-source.d.ts +5 -3
  40. package/dist/components/assets/assets-collection-view-source.js +7 -5
  41. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  42. package/dist/components/builder/canvas-rebase.d.ts +36 -12
  43. package/dist/components/builder/canvas-rebase.js +13 -3
  44. package/dist/components/builder/canvas-rebase.js.map +1 -1
  45. package/dist/components/builder/canvas-render-context.js +5 -4
  46. package/dist/components/builder/canvas-render-context.js.map +1 -1
  47. package/dist/components/builder/composition-field-editor.d.ts +12 -6
  48. package/dist/components/builder/composition-field-editor.js +6 -5
  49. package/dist/components/builder/composition-field-editor.js.map +1 -1
  50. package/dist/components/cms/asset-picker-core.d.ts +2 -2
  51. package/dist/components/cms/asset-picker-core.js +2 -2
  52. package/dist/components/cms/asset-picker-core.js.map +1 -1
  53. package/dist/components/cms/asset-picker.js +3 -1
  54. package/dist/components/cms/asset-picker.js.map +1 -1
  55. package/dist/components/cms/canvas-commit.d.ts +6 -0
  56. package/dist/components/cms/canvas-commit.js +5 -1
  57. package/dist/components/cms/canvas-commit.js.map +1 -1
  58. package/dist/components/cms/cms-store.d.ts +24 -8
  59. package/dist/components/cms/cms-store.js +21 -14
  60. package/dist/components/cms/cms-store.js.map +1 -1
  61. package/dist/components/cms/collection-table.js +4 -11
  62. package/dist/components/cms/collection-table.js.map +1 -1
  63. package/dist/components/cms/detail-editor.js +2 -2
  64. package/dist/components/cms/detail-editor.js.map +1 -1
  65. package/dist/components/cms/draft-commit.d.ts +16 -3
  66. package/dist/components/cms/draft-commit.js +3 -2
  67. package/dist/components/cms/draft-commit.js.map +1 -1
  68. package/dist/components/cms/use-document-draft.d.ts +107 -10
  69. package/dist/components/cms/use-document-draft.js +157 -42
  70. package/dist/components/cms/use-document-draft.js.map +1 -1
  71. package/dist/components/collection-view/cms-collection-view-source.js +6 -4
  72. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  73. package/dist/components/components/transfer-dialog.js +6 -4
  74. package/dist/components/components/transfer-dialog.js.map +1 -1
  75. package/dist/lib/ai/chat-state.d.ts +7 -0
  76. package/dist/lib/ai/chat-state.js.map +1 -1
  77. package/dist/lib/ai/tools.d.ts +8 -0
  78. package/dist/lib/ai/tools.js +4 -0
  79. package/dist/lib/ai/tools.js.map +1 -1
  80. package/dist/lib/cms-views/view-model.d.ts +15 -8
  81. package/dist/lib/cms-views/view-model.js +19 -5
  82. package/dist/lib/cms-views/view-model.js.map +1 -1
  83. package/dist/lib/collection-view/__fixtures__/document-writes.js +1 -0
  84. package/dist/lib/collection-view/__fixtures__/document-writes.js.map +1 -1
  85. package/dist/lib/collection-view/collection-view-source.d.ts +18 -7
  86. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  87. package/dist/lib/data/collection-content.js +5 -0
  88. package/dist/lib/data/collection-content.js.map +1 -1
  89. package/dist/lib/engine-version.d.ts +1 -1
  90. package/dist/lib/engine-version.js +1 -1
  91. package/dist/lib/engine-version.js.map +1 -1
  92. package/dist/lib/i18n/messages.de.js +15 -1
  93. package/dist/lib/i18n/messages.de.js.map +1 -1
  94. package/dist/lib/i18n/messages.en.d.ts +13 -0
  95. package/dist/lib/i18n/messages.en.js +16 -1
  96. package/dist/lib/i18n/messages.en.js.map +1 -1
  97. package/dist/lib/operations/cms-persistence.d.ts +119 -35
  98. package/dist/lib/operations/cms-persistence.js +441 -91
  99. package/dist/lib/operations/cms-persistence.js.map +1 -1
  100. package/dist/lib/persistence/convex-adapter.js +80 -8
  101. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  102. package/dist/lib/persona/labels.js +2 -0
  103. package/dist/lib/persona/labels.js.map +1 -1
  104. package/package.json +4 -3
@@ -0,0 +1,192 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import { makeFunctionReference } from 'convex/server'
4
+ import type { ConvexHttpClient } from 'convex/browser'
5
+ import type { FunctionReference } from 'convex/server'
6
+ import { fieldPath, isFoldedEnvelope, measureMaxDepth } from '@elytracms/core/cms-core'
7
+ import type { CmsDocument, CollectionDef } from '@elytracms/core/cms-core'
8
+ import { createOperations } from '@elytracms/core/operations'
9
+ import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
10
+ import schema from './schema'
11
+
12
+ /**
13
+ * EC-666 (Codex F22) — the `convex-parity` twin of `core/src/persistence/depth-codec-lookalike.test.ts`:
14
+ * the operations over the production `ConvexPersistenceAdapter`, whose client is a
15
+ * call-through onto convex-test's REAL `cms.ts` mutations (the wiring of
16
+ * `contract-parity.test.ts`), plus raw `ctx.db` reads of what was stored and the REAL
17
+ * public `delivery:snapshot`.
18
+ *
19
+ * An authored draft shaped exactly like the codec's storage envelope must round-trip through
20
+ * every verb (replace, patch + the patch log, publish, snapshot, pin, restore), be stored
21
+ * escaped within Convex's 16 levels, and be delivered as the content it is. An envelope that
22
+ * failed to decode (EC-670) must stay a failed decode after a re-write and a publish. And a
23
+ * row stored BEFORE the change (the literal v1 envelope, inserted raw) still decodes.
24
+ */
25
+
26
+ const modules = {
27
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
28
+ './_generated/api.ts': async () => ({}),
29
+ }
30
+ const snapshot = makeFunctionReference<'query'>('delivery:snapshot')
31
+
32
+ const P = 'site'
33
+ const ADMIN = 'user_admin'
34
+ const AT = '2026-10-28T12:00:00.000Z'
35
+ const LOOKALIKE = { type: 'elytra/folded', v: 1, json: '{"title":"DECODED"}' }
36
+ const UNDECODABLE = { type: 'elytra/folded', v: 1, json: '{' }
37
+
38
+ const collections: CollectionDef[] = [
39
+ {
40
+ id: 'post',
41
+ kind: 'document',
42
+ fields: [
43
+ { name: 'title', type: 'text' },
44
+ {
45
+ name: 'payload',
46
+ type: 'object',
47
+ multiple: false,
48
+ fields: [
49
+ { name: 'type', type: 'select', options: [{ value: 'custom' }] },
50
+ { name: 'v', type: 'number' },
51
+ { name: 'json', type: 'text' },
52
+ ],
53
+ },
54
+ ],
55
+ },
56
+ ]
57
+
58
+ interface Backend {
59
+ query: (ref: FunctionReference<'query'>, args: Record<string, unknown>) => Promise<unknown>
60
+ mutation: (ref: FunctionReference<'mutation'>, args: Record<string, unknown>) => Promise<unknown>
61
+ }
62
+
63
+ function asAdapterClient(backend: Backend): ConvexHttpClient {
64
+ return {
65
+ query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => backend.query(ref, args ?? {}),
66
+ mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => backend.mutation(ref, args ?? {}),
67
+ } as unknown as ConvexHttpClient
68
+ }
69
+
70
+ async function harness() {
71
+ const t = convexTest(schema, modules)
72
+ await t.run(async (ctx) => {
73
+ await ctx.db.insert('members', { userId: ADMIN, role: 'admin', createdAt: AT, updatedAt: AT })
74
+ })
75
+ const asAdmin = t.withIdentity({ subject: ADMIN, issuer: 'https://convex.test' })
76
+ const adapter = new ConvexPersistenceAdapter(asAdapterClient(asAdmin), P)
77
+ const ops = createOperations({ adapter, collections: () => collections, validateDocument: () => [] })
78
+ const raw = async (docId: string) =>
79
+ t.run(async (ctx) => ({
80
+ draft: (await ctx.db.query('cmsDocuments').collect()).find((row) => row.docId === docId),
81
+ versions: (await ctx.db.query('documentVersions').collect()).filter((row) => row.docId === docId),
82
+ patches: (await ctx.db.query('documentPatches').collect()).filter((row) => row.docId === docId),
83
+ }))
84
+ return { t, ops, raw }
85
+ }
86
+
87
+ function ok<T>(result: { ok: true; value: T } | { ok: false; error: unknown }): T {
88
+ if (!result.ok) throw new Error(JSON.stringify(result.error))
89
+ return result.value
90
+ }
91
+
92
+ const ref = { collection: 'post', id: 'bad' }
93
+ const draft = (payload: unknown, title = 'T'): CmsDocument => ({ ...ref, values: { title, payload } })
94
+ /** A raw stored row's depth as Convex counts it (the row object is level 1). */
95
+ const rowDepth = (row: unknown) => {
96
+ const { _id, _creationTime, ...stored } = row as Record<string, unknown>
97
+ void _id
98
+ void _creationTime
99
+ return measureMaxDepth(stored).depth
100
+ }
101
+
102
+ describe('EC-666 (Codex F22) on Convex — an authored envelope lookalike through the real mutations', () => {
103
+ it('documents.replace round-trips it; the stored row holds it escaped (was: decoded on save)', async () => {
104
+ const { ops, raw } = await harness()
105
+ // Control: the ordinary draft round-trips.
106
+ ok(await ops.documents.replace({ projectId: P, document: { collection: 'post', id: 'good', values: { payload: { type: 'custom', v: 1, json: '{"title":"DECODED"}' } } }, precondition: { ifAbsent: true } }))
107
+ expect(ok(await ops.documents.get({ projectId: P, ref: { collection: 'post', id: 'good' } })).document.values.payload).toEqual({ type: 'custom', v: 1, json: '{"title":"DECODED"}' })
108
+ // F22, inverted.
109
+ const written = ok(await ops.documents.replace({ projectId: P, document: draft(LOOKALIKE), precondition: { ifAbsent: true } }))
110
+ expect(written.document.values.payload).toEqual(LOOKALIKE)
111
+ expect(ok(await ops.documents.get({ projectId: P, ref })).document.values.payload).toEqual(LOOKALIKE)
112
+ // At rest: escaped (an envelope whose payload is the lookalike), within 16 levels.
113
+ const { draft: row } = await raw('bad')
114
+ const stored = (row!.document as CmsDocument).values.payload
115
+ expect(isFoldedEnvelope(stored)).toBe(true)
116
+ expect(JSON.parse((stored as { json: string }).json)).toEqual(LOOKALIKE)
117
+ expect(rowDepth(row)).toBeLessThanOrEqual(16)
118
+ })
119
+
120
+ it('every verb returns it as written: patch + the patch log, publish, snapshot, pin, restore — and delivery serves it as content', async () => {
121
+ const { t, ops, raw } = await harness()
122
+ const created = ok(await ops.documents.replace({ projectId: P, document: draft({ type: 'custom', v: 1, json: '{}' }), precondition: { ifAbsent: true } }))
123
+ const patched = ok(await ops.documents.patch({ projectId: P, ref, baseRevision: created.revision, patches: [{ op: 'set', path: fieldPath('payload'), value: LOOKALIKE }] }))
124
+ expect(patched.document.values.payload).toEqual(LOOKALIKE)
125
+ const log = ok(await ops.documents.listPatches({ projectId: P, ref }))
126
+ expect(log.flatMap((row) => row.patches)).toContainEqual({ op: 'set', path: fieldPath('payload'), value: LOOKALIKE })
127
+
128
+ const published = ok(await ops.documents.publish({ projectId: P, ref }))
129
+ const versions = ok(await ops.documents.listVersions({ projectId: P, ref }))
130
+ expect(versions[0]?.snapshot.values.payload).toEqual(LOOKALIKE)
131
+ expect(ok(await ops.documents.listPublished({ projectId: P })).map((doc) => doc.values.payload)).toEqual([LOOKALIKE])
132
+
133
+ // The public snapshot delivers the lookalike AS CONTENT — never decoded, never withheld.
134
+ const live = (await t.query(snapshot, { projectId: P })) as { documents: CmsDocument[]; documentIssues: unknown[] }
135
+ expect(live.documents.map((doc) => doc.values.payload)).toEqual([LOOKALIKE])
136
+ expect(live.documentIssues).toEqual([])
137
+
138
+ ok(await ops.documents.replace({ projectId: P, document: draft({ type: 'custom', v: 2, json: '{}' }, 'moved'), precondition: { revision: published.revision } }))
139
+ ok(await ops.documents.snapshot({ projectId: P, ref, label: 'manual' }))
140
+ const firstVersion = versions[0]!.version
141
+ ok(await ops.documents.pinVersion({ projectId: P, ref, version: firstVersion }))
142
+ expect(ok(await ops.documents.listPublished({ projectId: P })).map((doc) => doc.values.payload)).toEqual([LOOKALIKE])
143
+ const restored = ok(await ops.documents.restore({ projectId: P, ref, version: firstVersion }))
144
+ expect(restored.document.values).toEqual({ title: 'T', payload: LOOKALIKE })
145
+
146
+ // Every stored row of the document is within Convex's 16 levels.
147
+ const rows = await raw('bad')
148
+ for (const row of [rows.draft, ...rows.versions, ...rows.patches]) expect(rowDepth(row)).toBeLessThanOrEqual(16)
149
+ })
150
+
151
+ it('an envelope that failed to decode stays a failed decode after a re-write and a publish — never laundered', async () => {
152
+ const { t, ops, raw } = await harness()
153
+ // Drift stored raw (a dashboard edit, a truncated import): a published document whose
154
+ // body is an envelope that does not decode.
155
+ const broken = { ...ref, values: { title: 'T', payload: UNDECODABLE } }
156
+ await t.run(async (ctx) => {
157
+ await ctx.db.insert('cmsDocuments', { collection: 'post', docId: 'bad', variant: 'draft', revision: 1, document: broken, publishedVersion: 1, createdAt: AT, updatedAt: AT })
158
+ await ctx.db.insert('documentVersions', { collection: 'post', docId: 'bad', version: 1, snapshot: broken, kind: 'publish', createdAt: AT })
159
+ })
160
+ // The studio read keeps the opaque value (EC-670).
161
+ const read = ok(await ops.documents.get({ projectId: P, ref }))
162
+ expect(read.document.values.payload).toEqual(UNDECODABLE)
163
+ // The editor fixes the title and publishes: the drift is carried, not re-encoded.
164
+ ok(await ops.documents.patch({ projectId: P, ref, baseRevision: read.revision, patches: [{ op: 'set', path: fieldPath('title'), value: 'edited' }] }))
165
+ ok(await ops.documents.publish({ projectId: P, ref }))
166
+ const rows = await raw('bad')
167
+ expect((rows.draft!.document as CmsDocument).values.payload).toEqual(UNDECODABLE)
168
+ expect(rows.versions.map((row) => (row.snapshot as CmsDocument).values.payload)).toEqual([UNDECODABLE, UNDECODABLE])
169
+ // The new pin is still withheld by delivery with its located issue.
170
+ const live = (await t.query(snapshot, { projectId: P })) as { documents: unknown[]; documentIssues: Array<{ documentId: string; path: unknown[] }> }
171
+ expect(live.documents).toEqual([])
172
+ expect(live.documentIssues.map((issue) => [issue.documentId, issue.path])).toEqual([['bad', ['documents', 'post:bad', 'values', 'payload']]])
173
+ })
174
+
175
+ it('a row stored before the change (the literal v1 envelope, inserted raw) still decodes — studio, patch log and delivery', async () => {
176
+ const { t, ops } = await harness()
177
+ const v1Body = { type: 'elytra/folded', v: 1, kind: 'blocks', json: '{"version":2,"content":[{"type":"paragraph","content":[]}]}' }
178
+ const structured = { version: 2, content: [{ type: 'paragraph', content: [] }] }
179
+ const stored = { collection: 'post', id: 'old', values: { title: 'Old', body: v1Body }, localized: { de: { body: { type: 'elytra/folded', v: 1, json: '{"a":[1]}' } } } }
180
+ await t.run(async (ctx) => {
181
+ await ctx.db.insert('cmsDocuments', { collection: 'post', docId: 'old', variant: 'draft', revision: 2, document: stored, publishedVersion: 1, createdAt: AT, updatedAt: AT })
182
+ await ctx.db.insert('documentVersions', { collection: 'post', docId: 'old', version: 1, snapshot: stored, kind: 'publish', createdAt: AT })
183
+ await ctx.db.insert('documentPatches', { collection: 'post', docId: 'old', variant: 'draft', revision: 2, kind: 'patch', actor: ADMIN, at: AT, patches: [{ op: 'set', path: fieldPath('body'), value: v1Body }] })
184
+ })
185
+ const expected = { collection: 'post', id: 'old', values: { title: 'Old', body: structured }, localized: { de: { body: { a: [1] } } } }
186
+ expect(ok(await ops.documents.get({ projectId: P, ref: { collection: 'post', id: 'old' } })).document).toEqual(expected)
187
+ const log = ok(await ops.documents.listPatches({ projectId: P, ref: { collection: 'post', id: 'old' } }))
188
+ expect(log.flatMap((row) => row.patches)).toEqual([{ op: 'set', path: fieldPath('body'), value: structured }])
189
+ const live = (await t.query(snapshot, { projectId: P })) as { documents: unknown[] }
190
+ expect(live.documents).toEqual([expected])
191
+ })
192
+ })
@@ -0,0 +1,305 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import { createAccount } from '@convex-dev/auth/server'
4
+ import { internalActionGeneric, makeFunctionReference } from 'convex/server'
5
+ import { DEV_USERS, DEV_USER_ROLES, NOT_INVITED_REASON } from '@elytracms/core/persistence'
6
+ import { defineStudioAuth } from './auth'
7
+ import {
8
+ FIXTURE_ACCOUNTS_FLAG,
9
+ FIXTURE_PASSWORD_ENV,
10
+ FIXTURE_PASSWORD_MIN_LENGTH,
11
+ fixtureAccountList,
12
+ } from './fixtureAccounts'
13
+ import schema from './schema'
14
+
15
+ /**
16
+ * EC-672 — `fixtureAccounts:provision` on convex-test, through the REAL Convex Auth store
17
+ * (`defineStudioAuth()` — the demo's `convex/auth.ts` one-liner — mounted as `auth.ts`, so
18
+ * `createAccount`'s `auth:store` sub-call runs the library's own account creation and the
19
+ * engine's account-creation gate inside it), the REAL invite purge and the REAL sign-in gate.
20
+ *
21
+ * The password is a per-deployment secret; the tests use throwaway values built here, and
22
+ * every assertion about it is a boolean (the account verifies it, or it does not).
23
+ */
24
+
25
+ /**
26
+ * The engine's convex modules, plus the two a deployment adds: the demo's `auth.ts`
27
+ * (`defineStudioAuth()` exports `store`, which `createAccount` calls by name) and a probe
28
+ * action that calls `createAccount` WITHOUT the fixture's invite step (the gate control).
29
+ */
30
+ const probeCreate = internalActionGeneric({
31
+ handler: async (ctx, args: { email: string; secret: string }) => {
32
+ await createAccount(ctx, {
33
+ provider: 'password',
34
+ account: { id: args.email, secret: args.secret },
35
+ profile: { email: args.email },
36
+ })
37
+ return null
38
+ },
39
+ })
40
+ const modules = {
41
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**', '!./auth.ts']),
42
+ './auth.ts': async () => defineStudioAuth(),
43
+ './probe.ts': async () => ({ create: probeCreate }),
44
+ './_generated/api.ts': async () => ({}),
45
+ }
46
+
47
+ const provision = makeFunctionReference<'action'>('fixtureAccounts:provision')
48
+ const prepare = makeFunctionReference<'mutation'>('fixtureAccounts:prepare')
49
+ const settle = makeFunctionReference<'mutation'>('fixtureAccounts:settle')
50
+ const probe = makeFunctionReference<'action'>('probe:create')
51
+
52
+ const env = (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
53
+ const SECRET_A = 'fixture-secret-a-0123456789abcdef'
54
+ const SECRET_B = 'fixture-secret-b-fedcba9876543210'
55
+ const STAMP = '2026-10-28T00:00:00.000Z'
56
+
57
+ type Provisioned = {
58
+ accounts: Array<{
59
+ email: string
60
+ role: string
61
+ account: string
62
+ membership: string
63
+ invite: string
64
+ purgedOrphans: number
65
+ }>
66
+ }
67
+
68
+ // Unset by assignment: `delete` does not take on the edge-runtime env's `process.env`
69
+ // (measured — a deleted secret was still read), and the module reads '' as unset.
70
+ function enable(password: string = SECRET_A) {
71
+ env[FIXTURE_ACCOUNTS_FLAG] = '1'
72
+ env[FIXTURE_PASSWORD_ENV] = password
73
+ }
74
+ function reset() {
75
+ env[FIXTURE_ACCOUNTS_FLAG] = ''
76
+ env[FIXTURE_PASSWORD_ENV] = ''
77
+ }
78
+
79
+ beforeEach(reset)
80
+ afterEach(reset)
81
+
82
+ /** Every row of the tables provisioning can touch, ordered — for "changed nothing" checks. */
83
+ async function dump(t: ReturnType<typeof convexTest>) {
84
+ return t.run(async (ctx) => {
85
+ const tables = ['users', 'authAccounts', 'authSessions', 'members', 'invites', 'authorityAudit'] as const
86
+ const out = {} as Record<(typeof tables)[number], unknown[]>
87
+ for (const table of tables) out[table] = await ctx.db.query(table).collect()
88
+ return out
89
+ })
90
+ }
91
+
92
+ /** The members table as `{ email → role }`, joined through the users table. */
93
+ async function roles(t: ReturnType<typeof convexTest>) {
94
+ return t.run(async (ctx) => {
95
+ const out: Record<string, string> = {}
96
+ for (const member of await ctx.db.query('members').collect()) {
97
+ const user = await ctx.db.get(member.userId as never)
98
+ out[(user as { email?: string } | null)?.email ?? member.userId] = member.role
99
+ }
100
+ return out
101
+ })
102
+ }
103
+
104
+ const SEEDED = Object.fromEntries(
105
+ DEV_USER_ROLES.map(({ userId, role }) => [Object.values(DEV_USERS).find((u) => u.id === userId)!.email, role]),
106
+ )
107
+
108
+ describe('fixtureAccountList — the seed’s own dev users, one declaration', () => {
109
+ it('is DEV_USER_ROLES joined to DEV_USERS, admin first (the bootstrap principal on an empty deployment)', () => {
110
+ const list = fixtureAccountList()
111
+ expect(list).toEqual(Object.entries(SEEDED).map(([email, role]) => ({ email, role })))
112
+ expect(list[0]).toEqual({ email: DEV_USERS.admin.email, role: 'admin' })
113
+ expect(list.map((entry) => entry.role)).toEqual(['admin', 'editor', 'viewer'])
114
+ })
115
+ })
116
+
117
+ describe('the flag and the secret — refused before anything is written', () => {
118
+ it('refuses every entry point without ELYTRA_FIXTURE_ACCOUNTS=1, writing nothing', async () => {
119
+ const t = convexTest(schema, modules)
120
+ const before = await dump(t)
121
+ env[FIXTURE_PASSWORD_ENV] = SECRET_A // the secret alone is not enough
122
+ await expect(t.action(provision, {})).rejects.toThrow(/ELYTRA_FIXTURE_ACCOUNTS=1/)
123
+ await expect(t.mutation(prepare, { email: DEV_USERS.admin.email, role: 'admin' })).rejects.toThrow(
124
+ /ELYTRA_FIXTURE_ACCOUNTS=1/,
125
+ )
126
+ await expect(t.mutation(settle, { email: DEV_USERS.admin.email, role: 'admin' })).rejects.toThrow(
127
+ /ELYTRA_FIXTURE_ACCOUNTS=1/,
128
+ )
129
+ env[FIXTURE_ACCOUNTS_FLAG] = 'true' // exactly '1'
130
+ await expect(t.action(provision, {})).rejects.toThrow(/ELYTRA_FIXTURE_ACCOUNTS=1/)
131
+ expect(await dump(t)).toEqual(before)
132
+ })
133
+
134
+ it('refuses a missing or short secret, naming the variable and never a value', async () => {
135
+ const t = convexTest(schema, modules)
136
+ const before = await dump(t)
137
+ enable('')
138
+ await expect(t.action(provision, {})).rejects.toThrow(/ELYTRA_FIXTURE_PASSWORD is not set/)
139
+ const short = 'x'.repeat(FIXTURE_PASSWORD_MIN_LENGTH - 1)
140
+ enable(short)
141
+ let caught: unknown
142
+ try {
143
+ await t.action(provision, {})
144
+ } catch (error) {
145
+ caught = error
146
+ }
147
+ const message = String((caught as Error | undefined)?.message)
148
+ expect(message).toMatch(/shorter than 16/)
149
+ expect(message.includes(short)).toBe(false)
150
+ expect(await dump(t)).toEqual(before)
151
+ })
152
+ })
153
+
154
+ describe('provision — through the provider and the gates, idempotent', () => {
155
+ it('on an EMPTY deployment: the admin bootstraps, the others are invited and granted; no session, no invite left', async () => {
156
+ const t = convexTest(schema, modules)
157
+ enable()
158
+ const report = (await t.action(provision, {})) as Provisioned
159
+ expect(report.accounts).toEqual([
160
+ {
161
+ email: 'admin@example.com',
162
+ role: 'admin',
163
+ account: 'created',
164
+ membership: 'bootstrapped',
165
+ invite: 'not-needed',
166
+ purgedOrphans: 0,
167
+ },
168
+ {
169
+ email: 'editor@example.com',
170
+ role: 'editor',
171
+ account: 'created',
172
+ membership: 'granted',
173
+ invite: 'written',
174
+ purgedOrphans: 0,
175
+ },
176
+ {
177
+ email: 'viewer@example.com',
178
+ role: 'viewer',
179
+ account: 'created',
180
+ membership: 'granted',
181
+ invite: 'written',
182
+ purgedOrphans: 0,
183
+ },
184
+ ])
185
+ expect(await roles(t)).toEqual(SEEDED)
186
+ const rows = await dump(t)
187
+ expect(rows.authAccounts).toHaveLength(3)
188
+ expect(rows.authSessions).toEqual([]) // provisioning signs nobody in
189
+ expect(rows.invites).toEqual([]) // every invite was consumed by the sign-in gate
190
+ // The stored secret is a hash, never the value.
191
+ expect(rows.authAccounts.every((row) => (row as { secret?: string }).secret !== SECRET_A)).toBe(true)
192
+ })
193
+
194
+ it('a second run changes nothing: every account unchanged, every table byte-equal', async () => {
195
+ const t = convexTest(schema, modules)
196
+ enable()
197
+ await t.action(provision, {})
198
+ const before = await dump(t)
199
+ const again = (await t.action(provision, {})) as Provisioned
200
+ expect(again.accounts.map((a) => [a.account, a.membership, a.invite])).toEqual([
201
+ ['unchanged', 'unchanged', 'not-needed'],
202
+ ['unchanged', 'unchanged', 'not-needed'],
203
+ ['unchanged', 'unchanged', 'not-needed'],
204
+ ])
205
+ expect(await dump(t)).toEqual(before)
206
+ })
207
+
208
+ it('follows a rotated secret (password-rotated), after which the new secret is the one that verifies', async () => {
209
+ const t = convexTest(schema, modules)
210
+ enable(SECRET_A)
211
+ await t.action(provision, {})
212
+ enable(SECRET_B)
213
+ const rotated = (await t.action(provision, {})) as Provisioned
214
+ expect(rotated.accounts.map((a) => a.account)).toEqual(['password-rotated', 'password-rotated', 'password-rotated'])
215
+ // `createAccount` returns an existing account only when the secret verifies, so a third
216
+ // run under SECRET_B reading `unchanged` proves the stored hash is SECRET_B's.
217
+ const third = (await t.action(provision, {})) as Provisioned
218
+ expect(third.accounts.map((a) => a.account)).toEqual(['unchanged', 'unchanged', 'unchanged'])
219
+ expect(await roles(t)).toEqual(SEEDED)
220
+ })
221
+
222
+ it('on a deployment with members already: nobody bootstraps, every fixture is invited; the existing member is untouched', async () => {
223
+ const t = convexTest(schema, modules)
224
+ const ownerId = await t.run(async (ctx) => {
225
+ const id = await ctx.db.insert('users', { email: 'owner@elytra.invalid' })
226
+ await ctx.db.insert('members', { userId: String(id), role: 'admin', createdAt: STAMP, updatedAt: STAMP })
227
+ return String(id)
228
+ })
229
+ enable()
230
+ const report = (await t.action(provision, {})) as Provisioned
231
+ expect(report.accounts.map((a) => [a.email, a.membership, a.invite])).toEqual([
232
+ ['admin@example.com', 'granted', 'written'],
233
+ ['editor@example.com', 'granted', 'written'],
234
+ ['viewer@example.com', 'granted', 'written'],
235
+ ])
236
+ const members = await t.run((ctx) => ctx.db.query('members').collect())
237
+ expect(members.find((m) => m.userId === ownerId)).toMatchObject({ role: 'admin', updatedAt: STAMP })
238
+ expect(members).toHaveLength(4)
239
+ })
240
+
241
+ it('gate control: on that same deployment the account-creation gate REJECTS a sign-up with no invite — the invite is what passes it', async () => {
242
+ const t = convexTest(schema, modules)
243
+ await t.run(async (ctx) => {
244
+ const id = await ctx.db.insert('users', { email: 'owner@elytra.invalid' })
245
+ await ctx.db.insert('members', { userId: String(id), role: 'admin', createdAt: STAMP, updatedAt: STAMP })
246
+ })
247
+ await expect(t.action(probe, { email: 'editor@example.com', secret: SECRET_A })).rejects.toThrow(
248
+ NOT_INVITED_REASON.slice(0, 12),
249
+ )
250
+ // Zero rows: the gate rolled the users insert back (EC-358).
251
+ const rows = await dump(t)
252
+ expect(rows.authAccounts).toEqual([])
253
+ expect(rows.users).toHaveLength(1)
254
+ })
255
+
256
+ it('purges member-less credentials for a fixture email first (EC-358) and creates the account fresh', async () => {
257
+ const t = convexTest(schema, modules)
258
+ const orphanId = await t.run(async (ctx) => {
259
+ const owner = await ctx.db.insert('users', { email: 'owner@elytra.invalid' })
260
+ await ctx.db.insert('members', { userId: String(owner), role: 'admin', createdAt: STAMP, updatedAt: STAMP })
261
+ // A pre-EC-358 orphan for the editor's email, carrying someone else's password hash.
262
+ const orphan = await ctx.db.insert('users', { email: 'Editor@Example.com' })
263
+ await ctx.db.insert('authAccounts', {
264
+ userId: orphan,
265
+ provider: 'password',
266
+ providerAccountId: 'editor@example.com',
267
+ secret: 'not-a-hash-of-ours',
268
+ })
269
+ return orphan
270
+ })
271
+ enable()
272
+ const report = (await t.action(provision, {})) as Provisioned
273
+ const editor = report.accounts.find((a) => a.role === 'editor')!
274
+ expect(editor).toMatchObject({ purgedOrphans: 1, account: 'created', membership: 'granted' })
275
+ expect(await t.run((ctx) => ctx.db.get(orphanId))).toBeNull()
276
+ expect((await roles(t))['editor@example.com']).toBe('editor')
277
+ })
278
+
279
+ it('restores a drifted fixture role, audited; a second run is then unchanged', async () => {
280
+ const t = convexTest(schema, modules)
281
+ enable()
282
+ await t.action(provision, {})
283
+ await t.run(async (ctx) => {
284
+ const account = await ctx.db
285
+ .query('authAccounts')
286
+ .withIndex('providerAndAccountId', (q) =>
287
+ q.eq('provider', 'password').eq('providerAccountId', 'editor@example.com'),
288
+ )
289
+ .unique()
290
+ const member = await ctx.db
291
+ .query('members')
292
+ .withIndex('by_user', (q) => q.eq('userId', String(account!.userId)))
293
+ .first()
294
+ await ctx.db.patch(member!._id, { role: 'viewer' })
295
+ })
296
+ const report = (await t.action(provision, {})) as Provisioned
297
+ expect(report.accounts.find((a) => a.role === 'editor')!.membership).toBe('role-restored')
298
+ expect((await roles(t))['editor@example.com']).toBe('editor')
299
+ const audit = await t.run((ctx) => ctx.db.query('authorityAudit').collect())
300
+ expect(audit).toHaveLength(1)
301
+ expect(audit[0]).toMatchObject({ actorId: 'fixture-accounts', op: 'members.set', rolesGranted: ['editor'] })
302
+ const again = (await t.action(provision, {})) as Provisioned
303
+ expect(again.accounts.every((a) => a.membership === 'unchanged')).toBe(true)
304
+ })
305
+ })