@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,298 @@
1
+ import { createAccount, modifyAccountCredentials } from '@convex-dev/auth/server'
2
+ import { internalActionGeneric, makeFunctionReference } from 'convex/server'
3
+ import type { AnyDataModel, GenericMutationCtx } from 'convex/server'
4
+ import { v } from 'convex/values'
5
+ import type { GenericId } from 'convex/values'
6
+ import { DEV_USERS, DEV_USER_ROLES, classifyInviteSignup, normalizeInviteEmail } from '@elytracms/core/persistence'
7
+ import { gateSessionCreation } from './auth'
8
+ import { assertDeclaredRole, recordAuthorityAudit } from './authority'
9
+ import { internalMutation } from './functions'
10
+ import type { MutationCtx } from './functions'
11
+ import { purgeOrphanCredentials } from './invites'
12
+
13
+ /**
14
+ * EC-672 — fixture accounts for the two DEMO deployments (owner-ratified 2026-10-28: both
15
+ * demo deployments are agent-owned, re-seedable fixtures). Provisions the seed's dev users
16
+ * (`DEV_USER_ROLES`: admin, editor, viewer — the same three the playground signs in as) as
17
+ * real Convex Auth accounts, so an agent's connected studio and `elytra seed --drop` can sign
18
+ * in, and two browsers are two users.
19
+ *
20
+ * **Confined structurally, not by a check.** No consumer deployment has these functions: a
21
+ * Convex deployment runs only what its own `convex/` directory exports, and only the in-repo
22
+ * demo (`apps/demo/studio/convex/fixtureAccounts.ts`) re-exports this module — the
23
+ * `create-elytra` template and both kits re-export nothing of it, which
24
+ * `packages/create-elytra/test/template-fixture-accounts.test.js` pins. Belt and braces, every
25
+ * function here refuses unless the deployment carries `ELYTRA_FIXTURE_ACCOUNTS=1` (set on the
26
+ * two demo deployments only).
27
+ *
28
+ * **The password is a per-deployment secret**, `ELYTRA_FIXTURE_PASSWORD`, read from the
29
+ * deployment env — never a literal in this repo. The playground's `LOCAL_DEV_CREDENTIALS`
30
+ * ship in the published studio and are shown on the playground sign-in screen, so a real
31
+ * account carrying one of them would have a publicly known password (critic-on-plan,
32
+ * wave-0098). The owner sets the secret once per deployment from a generator piped straight
33
+ * into `npx convex env set`; the agent studio's connected mode and `pnpm demo:reseed` read it
34
+ * with `npx convex env get` into a child process env, never printed.
35
+ *
36
+ * **Through the provider's own path, so the role/invite gate applies.** Per account:
37
+ *
38
+ * 1. `prepare` (one transaction) does what an admin's invite does (`invites:create`): it
39
+ * refuses a role the deployed policy does not declare (`assertDeclaredRole`), purges
40
+ * member-less credentials for the email (EC-358 — an invite never adopts pre-existing
41
+ * credentials; a crashed earlier run's half-made account is one), and writes the invite
42
+ * when the deployment's own decision (`classifyInviteSignup`) would otherwise reject the
43
+ * sign-up. It is the invite's equivalent because it is INTERNAL: only a holder of the
44
+ * deployment's admin key (the CLI, the dashboard) can run it — strictly above
45
+ * `project.administer`, the capability `invites:create` requires.
46
+ * 2. `createAccount` (Convex Auth) — exactly the call the Password provider's `signUp` flow
47
+ * makes — creates the account; the engine's account-creation gate
48
+ * (`afterUserCreatedOrUpdated` → `gateAccountCreation`) runs inside it and reads that
49
+ * invite, unmodified. An existing account whose secret matches is returned untouched (the
50
+ * library's own idempotency); one whose secret differs means the owner rotated the
51
+ * deployment secret, and `modifyAccountCredentials` follows it.
52
+ * 3. `settle` grants the membership by the gate every sign-in runs (`gateSessionCreation`:
53
+ * bootstrap on an empty deployment, grant + consume the invite otherwise), restores a
54
+ * drifted fixture role (audited), and drops a leftover invite for the email.
55
+ *
56
+ * Idempotent: a second run creates, rotates, grants and writes nothing — every account
57
+ * reports `unchanged`. Creating no session, it never signs anyone in.
58
+ *
59
+ * Run from the demo studio dir (the reseed script does it for you):
60
+ * `CONVEX_DEPLOYMENT=dev:<name> npx convex run fixtureAccounts:provision`.
61
+ */
62
+
63
+ /** The deployment env flag every function here requires, exactly `'1'`. */
64
+ export const FIXTURE_ACCOUNTS_FLAG = 'ELYTRA_FIXTURE_ACCOUNTS'
65
+ /** The deployment env var holding the fixture accounts' password (a per-deployment secret). */
66
+ export const FIXTURE_PASSWORD_ENV = 'ELYTRA_FIXTURE_PASSWORD'
67
+ /**
68
+ * The secret's minimum length. Above the Password provider's own 8 (which `createAccount`
69
+ * does not apply — only the provider's `signUp` flow validates): this password guards an
70
+ * admin on a deployment behind a public site, so a short one is a misconfiguration, refused.
71
+ */
72
+ export const FIXTURE_PASSWORD_MIN_LENGTH = 16
73
+ /** The provider the accounts belong to — the studio's default `Password` (`defineStudioAuth`). */
74
+ const PROVIDER = 'password'
75
+ /** The audit actor of a fixture role restore (no studio user performs it). */
76
+ const AUDIT_ACTOR = 'fixture-accounts'
77
+
78
+ /** Where the demo re-exports this module (`convex/fixtureAccounts.ts`); the action's sub-calls. */
79
+ const prepareRef = makeFunctionReference<'mutation', { email: string; role: string }, PrepareOutcome>(
80
+ 'fixtureAccounts:prepare',
81
+ )
82
+ const settleRef = makeFunctionReference<'mutation', { email: string; role: string }, SettleOutcome>(
83
+ 'fixtureAccounts:settle',
84
+ )
85
+
86
+ type Env = Record<string, string | undefined>
87
+
88
+ /** One fixture account: a seeded dev user's email and role. */
89
+ export interface FixtureAccount {
90
+ readonly email: string
91
+ readonly role: string
92
+ }
93
+
94
+ /**
95
+ * The fixture accounts, from the seed's ONE declaration of its dev users and their roles
96
+ * (`DEV_USER_ROLES` → `DEV_USERS`), in that order — admin first, so on an empty deployment
97
+ * the admin is the bootstrap principal. The agent scripts select an account by the same
98
+ * role table, so there is no second list to drift.
99
+ */
100
+ export function fixtureAccountList(): FixtureAccount[] {
101
+ const users = Object.values(DEV_USERS)
102
+ return DEV_USER_ROLES.map(({ userId, role }) => {
103
+ const user = users.find((candidate) => candidate.id === userId)
104
+ if (!user) throw new Error(`fixture-accounts: DEV_USER_ROLES names ${userId}, which DEV_USERS does not declare`)
105
+ return { email: normalizeInviteEmail(user.email), role }
106
+ })
107
+ }
108
+
109
+ /** Refuse unless this deployment opted in. Every function here calls it first. */
110
+ export function assertFixtureAccountsEnabled(env: Env): void {
111
+ if (env[FIXTURE_ACCOUNTS_FLAG] !== '1') {
112
+ throw new Error(
113
+ `fixture-accounts: refused — this deployment does not carry ${FIXTURE_ACCOUNTS_FLAG}=1. Fixture accounts exist only on the two demo deployments; nothing was written.`,
114
+ )
115
+ }
116
+ }
117
+
118
+ /** The fixture password from the deployment env, or a refusal naming what is missing (never the value). */
119
+ export function readFixturePassword(env: Env): string {
120
+ assertFixtureAccountsEnabled(env)
121
+ const password = env[FIXTURE_PASSWORD_ENV]
122
+ if (password === undefined || password === '') {
123
+ throw new Error(
124
+ `fixture-accounts: refused — ${FIXTURE_PASSWORD_ENV} is not set on this deployment. The owner sets it once, generated and piped (never typed): \`sh -c 'openssl rand -base64 24 | xargs npx convex env set ${FIXTURE_PASSWORD_ENV}'\` with the deployment inline. Nothing was written.`,
125
+ )
126
+ }
127
+ if (password.length < FIXTURE_PASSWORD_MIN_LENGTH) {
128
+ throw new Error(
129
+ `fixture-accounts: refused — ${FIXTURE_PASSWORD_ENV} is shorter than ${FIXTURE_PASSWORD_MIN_LENGTH} characters. Nothing was written.`,
130
+ )
131
+ }
132
+ return password
133
+ }
134
+
135
+ /** What `prepare` found and did for one account. */
136
+ export interface PrepareOutcome {
137
+ /** Member-less credentials for the email that were purged (EC-358). */
138
+ readonly purgedOrphans: number
139
+ /** Whether a password account for the email exists after the purge. */
140
+ readonly accountExists: boolean
141
+ /** The invite step: written / role corrected / already right / not needed (a member, or the bootstrap principal). */
142
+ readonly invite: 'written' | 'updated' | 'present' | 'not-needed'
143
+ }
144
+
145
+ /** What `settle` did for one account. */
146
+ export interface SettleOutcome {
147
+ readonly membership: 'bootstrapped' | 'granted' | 'unchanged' | 'role-restored'
148
+ /** A leftover invite for the email that was removed (the account is a member now). */
149
+ readonly inviteRemoved: boolean
150
+ }
151
+
152
+ /** One account's line in the `provision` report. */
153
+ export interface ProvisionedAccount extends FixtureAccount {
154
+ readonly account: 'created' | 'unchanged' | 'password-rotated'
155
+ readonly membership: SettleOutcome['membership']
156
+ readonly invite: PrepareOutcome['invite']
157
+ readonly purgedOrphans: number
158
+ }
159
+
160
+ async function findPasswordAccount(ctx: Pick<MutationCtx, 'db'>, email: string) {
161
+ return ctx.db
162
+ .query('authAccounts')
163
+ .withIndex('providerAndAccountId', (q) => q.eq('provider', PROVIDER).eq('providerAccountId', email))
164
+ .unique()
165
+ }
166
+
167
+ async function findMember(ctx: Pick<MutationCtx, 'db'>, userId: string) {
168
+ return ctx.db
169
+ .query('members')
170
+ .withIndex('by_user', (q) => q.eq('userId', userId))
171
+ .first()
172
+ }
173
+
174
+ async function findInvite(ctx: Pick<MutationCtx, 'db'>, email: string) {
175
+ return ctx.db
176
+ .query('invites')
177
+ .withIndex('by_email', (q) => q.eq('email', email))
178
+ .first()
179
+ }
180
+
181
+ const accountArgs = { email: v.string(), role: v.string() }
182
+
183
+ /** Step 1 — the admin-invite equivalent (see the module doc). Internal: admin key only. */
184
+ export const prepare = internalMutation({
185
+ args: accountArgs,
186
+ handler: async (ctx, args): Promise<PrepareOutcome> => {
187
+ assertFixtureAccountsEnabled(process.env)
188
+ assertDeclaredRole(args.role)
189
+ const email = normalizeInviteEmail(args.email)
190
+ const purgedOrphans = await purgeOrphanCredentials(ctx, email)
191
+ const account = await findPasswordAccount(ctx, email)
192
+ const member = account ? await findMember(ctx, String(account.userId)) : null
193
+ if (member) return { purgedOrphans, accountExists: true, invite: 'not-needed' }
194
+
195
+ const invite = await findInvite(ctx, email)
196
+ const decision = classifyInviteSignup({
197
+ hasAnyMember: (await ctx.db.query('members').first()) !== null,
198
+ existingRole: null,
199
+ invite: invite ? { role: invite.role } : null,
200
+ })
201
+ const now = new Date().toISOString()
202
+ if (decision.kind === 'bootstrap-admin') {
203
+ return { purgedOrphans, accountExists: account !== null, invite: 'not-needed' }
204
+ }
205
+ if (invite && invite.role === args.role) {
206
+ return { purgedOrphans, accountExists: account !== null, invite: 'present' }
207
+ }
208
+ if (invite) {
209
+ await ctx.db.patch(invite._id, { role: args.role, updatedAt: now })
210
+ return { purgedOrphans, accountExists: account !== null, invite: 'updated' }
211
+ }
212
+ await ctx.db.insert('invites', { email, role: args.role, createdAt: now, updatedAt: now })
213
+ return { purgedOrphans, accountExists: account !== null, invite: 'written' }
214
+ },
215
+ })
216
+
217
+ /** Step 3 — grant by the sign-in gate, restore a drifted role, drop a leftover invite. Internal. */
218
+ export const settle = internalMutation({
219
+ args: accountArgs,
220
+ handler: async (ctx, args): Promise<SettleOutcome> => {
221
+ assertFixtureAccountsEnabled(process.env)
222
+ const email = normalizeInviteEmail(args.email)
223
+ const account = await findPasswordAccount(ctx, email)
224
+ if (!account) throw new Error(`fixture-accounts: no password account for ${email} after createAccount`)
225
+ const userId = String(account.userId)
226
+ const before = await findMember(ctx, userId)
227
+ const anyMemberBefore = before !== null || (await ctx.db.query('members').first()) !== null
228
+
229
+ // The gate every sign-in runs (EC-324): bootstrap / grant + consume / existing / reject.
230
+ await gateSessionCreation(ctx as unknown as GenericMutationCtx<AnyDataModel>, {
231
+ userId: account.userId as GenericId<'users'>,
232
+ })
233
+ const member = await findMember(ctx, userId)
234
+ if (!member) throw new Error(`fixture-accounts: ${email} is not a member after the sign-in gate`)
235
+
236
+ let membership: SettleOutcome['membership'] = before ? 'unchanged' : anyMemberBefore ? 'granted' : 'bootstrapped'
237
+ if (member.role !== args.role) {
238
+ assertDeclaredRole(args.role)
239
+ await ctx.db.patch(member._id, { role: args.role, updatedAt: new Date().toISOString() })
240
+ await recordAuthorityAudit(ctx, {
241
+ actorId: AUDIT_ACTOR,
242
+ targetUserId: userId,
243
+ op: 'members.set',
244
+ outcome: 'allowed',
245
+ rolesGranted: [args.role],
246
+ detail: `fixture role restored (was ${member.role})`,
247
+ })
248
+ membership = 'role-restored'
249
+ }
250
+
251
+ const leftover = await findInvite(ctx, email)
252
+ if (leftover) await ctx.db.delete(leftover._id)
253
+ return { membership, inviteRemoved: leftover !== null }
254
+ },
255
+ })
256
+
257
+ /**
258
+ * `fixtureAccounts:provision` — the one entry point: provisions every fixture account
259
+ * (prepare → `createAccount` → settle) and returns the per-account report. Never logs or
260
+ * returns the password. Internal: admin key only.
261
+ */
262
+ export const provision = internalActionGeneric({
263
+ args: {},
264
+ handler: async (ctx): Promise<{ accounts: ProvisionedAccount[] }> => {
265
+ const password = readFixturePassword(process.env)
266
+ const accounts: ProvisionedAccount[] = []
267
+ for (const fixture of fixtureAccountList()) {
268
+ const prepared = await ctx.runMutation(prepareRef, { email: fixture.email, role: fixture.role })
269
+ let outcome: ProvisionedAccount['account'] = prepared.accountExists ? 'unchanged' : 'created'
270
+ try {
271
+ // The Password provider's own sign-up call — the account-creation gate runs inside it.
272
+ await createAccount(ctx, {
273
+ provider: PROVIDER,
274
+ account: { id: fixture.email, secret: password },
275
+ profile: { email: fixture.email },
276
+ })
277
+ } catch (error) {
278
+ // The library's "secret differs" signal on an EXISTING account (never on a new one).
279
+ const message = error instanceof Error ? error.message : String(error)
280
+ if (!prepared.accountExists || !/already exists/.test(message)) throw error
281
+ await modifyAccountCredentials(ctx, {
282
+ provider: PROVIDER,
283
+ account: { id: fixture.email, secret: password },
284
+ })
285
+ outcome = 'password-rotated'
286
+ }
287
+ const settled = await ctx.runMutation(settleRef, { email: fixture.email, role: fixture.role })
288
+ accounts.push({
289
+ ...fixture,
290
+ account: outcome,
291
+ membership: settled.membership,
292
+ invite: prepared.invite,
293
+ purgedOrphans: prepared.purgedOrphans,
294
+ })
295
+ }
296
+ return { accounts }
297
+ },
298
+ })
@@ -0,0 +1,149 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import type { ConvexHttpClient } from 'convex/browser'
4
+ import type { FunctionReference } from 'convex/server'
5
+ import { fieldPath, measureMaxDepth } from '@elytracms/core/cms-core'
6
+ import { createChangesets, createOperations } from '@elytracms/core/operations'
7
+ import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
8
+ import schema from './schema'
9
+
10
+ // convex-test fires a `runAfter(0)` on a REAL timer (the publish trigger's revalidation);
11
+ // fake timers keep it queued.
12
+ beforeEach(() => {
13
+ vi.useFakeTimers()
14
+ vi.setSystemTime('2026-10-28T00:00:00.000Z')
15
+ })
16
+ afterEach(() => {
17
+ vi.useRealTimers()
18
+ })
19
+
20
+ /**
21
+ * EC-669 — the Codex review's F21 counterexample on Convex (round 3, `races.convex.test.ts`),
22
+ * copied and INVERTED, plus the critic-on-plan's version cases: the real `cms:*` handlers,
23
+ * schema and triggers under convex-test, driven by the production operations over the
24
+ * production `ConvexPersistenceAdapter`. Deleting (`cms:deleteDocument`) or clearing
25
+ * (`cms:clearDocuments`) an identity records the revision and version it reached
26
+ * (`documentFloors`); a later create of the same `collection:id` starts above both, so a
27
+ * token the old lifetime minted names nothing in the new one. No wire change.
28
+ */
29
+
30
+ const modules = {
31
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
32
+ './_generated/api.ts': async () => ({}),
33
+ }
34
+
35
+ const projectId = 'incarnation'
36
+ const ref = { collection: 'post', id: 'p' }
37
+ const collections = () => [{ id: 'post', kind: 'document' as const, fields: [{ name: 'title', type: 'text' as const }] }]
38
+
39
+ async function world() {
40
+ const t = convexTest(schema, modules)
41
+ const stamp = '2026-10-28T00:00:00Z'
42
+ await t.run(async (ctx) => {
43
+ await ctx.db.insert('members', { userId: 'user_admin', role: 'admin', createdAt: stamp, updatedAt: stamp })
44
+ })
45
+ const admin = t.withIdentity({ subject: 'user_admin', issuer: 'https://convex.test' })
46
+ const client = {
47
+ query: (fn: FunctionReference<'query'>, args?: Record<string, unknown>) => admin.query(fn, args ?? {}),
48
+ mutation: (fn: FunctionReference<'mutation'>, args?: Record<string, unknown>) => admin.mutation(fn, args ?? {}),
49
+ } as unknown as ConvexHttpClient
50
+ const adapter = new ConvexPersistenceAdapter(client, projectId)
51
+ const options = { adapter, collections, validateDocument: () => [] }
52
+ const ops = createOperations(options)
53
+ const changesets = createChangesets(options)
54
+ const put = (title: string) =>
55
+ ops.documents.replace({ projectId, document: { ...ref, values: { title } }, precondition: { ifAbsent: true } })
56
+ return { t, adapter, ops, changesets, put }
57
+ }
58
+
59
+ it('F21 Convex: a recreated identity refuses a previous incarnation’s authoring revision', async () => {
60
+ const { adapter, ops, put } = await world()
61
+ expect((await put('old')).ok).toBe(true)
62
+ expect((await ops.documents.patch({ projectId, ref, baseRevision: 1, patches: [{ op: 'set', path: fieldPath('title'), value: 'B' }] })).ok).toBe(true)
63
+ const stale = () => ops.documents.patch({ projectId, ref, baseRevision: 1, patches: [{ op: 'set', path: fieldPath('title'), value: 'STALE' }] })
64
+ expect((await stale()).ok).toBe(false) // known-positive control (kept as written)
65
+ expect((await ops.documents.delete({ projectId, ref })).ok).toBe(true)
66
+ expect((await put('NEW')).ok).toBe(true)
67
+ // Inverted: the new lifetime starts above revision 2, and the stale patch refuses.
68
+ expect((await adapter.cms.getDocument(projectId, ref)).revision).toBe(3)
69
+ expect((await stale()).ok).toBe(false)
70
+ expect((await adapter.cms.getDocument(projectId, ref)).document.values.title).toBe('NEW')
71
+ })
72
+
73
+ describe('EC-669 Convex — revisions and versions are monotonic per identity across both removal mutations', () => {
74
+ it('control: a fresh identity still starts at revision 1 and version 1', async () => {
75
+ const { adapter, ops, put } = await world()
76
+ expect((await put('first')).ok).toBe(true)
77
+ expect((await adapter.cms.getDocument(projectId, ref)).revision).toBe(1)
78
+ expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true)
79
+ expect((await adapter.cms.getDocument(projectId, ref)).publishedVersion).toBe(1)
80
+ })
81
+
82
+ for (const removal of ['delete', 'clear'] as const) {
83
+ it(`${removal}: stale revision and version tokens of the old lifetime refuse on the new one`, async () => {
84
+ const { adapter, ops, put } = await world()
85
+ expect((await put('old v1')).ok).toBe(true)
86
+ expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true) // r2, v1
87
+ const removed = removal === 'delete' ? await ops.documents.delete({ projectId, ref }) : await ops.documents.clear({ projectId })
88
+ expect(removed.ok).toBe(true)
89
+ expect((await put('new')).ok).toBe(true)
90
+ expect((await adapter.cms.getDocument(projectId, ref)).revision).toBe(3)
91
+ expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true)
92
+ const reborn = await adapter.cms.getDocument(projectId, ref)
93
+ expect(reborn.publishedVersion).toBe(2)
94
+ expect((await ops.documents.replace({ projectId, document: { ...ref, values: { title: 'STALE' } }, precondition: { revision: 1 } })).ok).toBe(false)
95
+ expect((await ops.documents.publish({ projectId, ref, expectedRevision: 2 })).ok).toBe(false)
96
+ expect((await ops.documents.delete({ projectId, ref, precondition: { revision: 2 } })).ok).toBe(false)
97
+ const pinned = await ops.documents.pinVersion({ projectId, ref, version: 1 })
98
+ expect(pinned.ok).toBe(false)
99
+ if (!pinned.ok) expect(pinned.error.code).toBe('not-found')
100
+ const restored = await ops.documents.restore({ projectId, ref, version: 1 })
101
+ expect(restored.ok).toBe(false)
102
+ if (!restored.ok) expect(restored.error.code).toBe('not-found')
103
+ const after = await adapter.cms.getDocument(projectId, ref)
104
+ expect(after.revision).toBe(reborn.revision)
105
+ expect(after.publishedVersion).toBe(2)
106
+ expect(after.document.values.title).toBe('new')
107
+ })
108
+ }
109
+
110
+ it('the floor holds across repeated lifetimes, and a second removal raises it', async () => {
111
+ const { t, adapter, ops, put } = await world()
112
+ expect((await put('one')).ok).toBe(true)
113
+ expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true) // r2, v1
114
+ expect((await ops.documents.delete({ projectId, ref })).ok).toBe(true)
115
+ expect((await put('two')).ok).toBe(true) // r3
116
+ expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true) // r4, v2
117
+ expect((await ops.documents.clear({ projectId })).ok).toBe(true)
118
+ expect((await put('three')).ok).toBe(true)
119
+ expect((await adapter.cms.getDocument(projectId, ref)).revision).toBe(5)
120
+ expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true)
121
+ expect((await adapter.cms.getDocument(projectId, ref)).publishedVersion).toBe(3)
122
+ // One floor row per identity, raised in place (never a row per removal).
123
+ const floors = await t.run((ctx) => ctx.db.query('documentFloors').collect())
124
+ expect(floors.map(({ collection, docId, revision, version }) => ({ collection, docId, revision, version }))).toEqual([
125
+ { collection: 'post', docId: 'p', revision: 4, version: 2 },
126
+ ])
127
+ // A new stored shape stays inside Convex's 16-level cap (flat scalars).
128
+ expect(measureMaxDepth(floors[0]).depth).toBeLessThanOrEqual(16)
129
+ })
130
+
131
+ it('a changeset that re-creates a removed identity records a REMOVE inverse, and its revert removes it', async () => {
132
+ const { adapter, ops, changesets, put } = await world()
133
+ expect((await put('old')).ok).toBe(true)
134
+ expect((await ops.documents.delete({ projectId, ref })).ok).toBe(true)
135
+ const applied = await changesets.apply(
136
+ changesets.create({
137
+ title: 're-create',
138
+ steps: [{ operation: 'documents.replace', input: { projectId, document: { ...ref, values: { title: 'again' } }, precondition: { ifAbsent: true } } }],
139
+ }),
140
+ )
141
+ expect(applied.ok).toBe(true)
142
+ if (!applied.ok) throw new Error(applied.error.message)
143
+ expect(applied.value.receipts).toEqual([
144
+ expect.objectContaining({ beforeRevision: null, afterRevision: 2, inverse: { kind: 'remove' } }),
145
+ ])
146
+ expect((await changesets.revert(applied.value)).ok).toBe(true)
147
+ expect(await adapter.cms.findDocument(projectId, ref)).toBeUndefined()
148
+ })
149
+ })
@@ -13,7 +13,15 @@ import { defineRecordKind } from '@elytracms/core/persistence'
13
13
  import schema from './schema'
14
14
  import { internalMutation } from './functions'
15
15
  import { defineStudioHttp } from './http'
16
- import { claimIngestUpload, ingestHandlerArgs, parseIngestPayload, recordIngestUpload, unclaimedIngestUploads } from './ingest'
16
+ import { CONVEX_MAX_NESTING_DEPTH, measureMaxDepth } from '@elytracms/core/cms-core'
17
+ import {
18
+ claimIngestUpload,
19
+ ingestHandlerArgs,
20
+ parseIngestPayload,
21
+ recordIngestUpload,
22
+ releaseIngestUploads,
23
+ unclaimedIngestUploads,
24
+ } from './ingest'
17
25
  import type { IngestHandlerRef } from './ingest'
18
26
  import { saveRecord } from './record-functions'
19
27
  import { createIngestRoute } from '../../next/src/ingest'
@@ -178,6 +186,20 @@ async function storeFile(t: ReturnType<typeof backend>, bytes: number, contentTy
178
186
  })
179
187
  }
180
188
 
189
+ /** Whether stored bytes exist (`storage.get` yields a Blob, which is not a Convex value: answer a boolean). */
190
+ function stored(t: ReturnType<typeof backend>, storageId: string): Promise<boolean> {
191
+ return t.run(async (ctx) => (await ctx.storage.get(storageId as never)) !== null)
192
+ }
193
+
194
+ /** An upload intent's claim columns, as stored. */
195
+ function intentState(t: ReturnType<typeof backend>, uploadId: string) {
196
+ return t.run(async (ctx) => {
197
+ const row = (await ctx.db.query('records').collect()).find((r) => r.kind === 'ingestUpload' && r.recordId === uploadId)
198
+ const data = row?.data as { state?: string; storageId?: string } | undefined
199
+ return { state: data?.state, storageId: data?.storageId }
200
+ })
201
+ }
202
+
181
203
  /** Sign an arbitrary raw body (for the invalid-shape case the typed builder refuses to build). */
182
204
  async function signedIngestRequestRaw(raw: string): Promise<string> {
183
205
  const { signRevalidationBody } = await import('@elytracms/core/revalidation')
@@ -254,7 +276,7 @@ describe('the ingest receiver — dispatch (EC-617)', () => {
254
276
  expect((await signedPost(t, { ...upload, contentType: 'application/x-msdownload' })).status).toBe(415)
255
277
  })
256
278
 
257
- it('a submission claims its upload: the stored file must match the declaration; a mismatch is deleted', async () => {
279
+ it('a submission claims its upload: the stored file must match the declaration; a mismatch is refused, never deleted', async () => {
258
280
  env()['ELYTRA_INGEST_SECRET'] = SECRET
259
281
  const t = backend()
260
282
  const declare = async (contentType: string, size: number) =>
@@ -271,7 +293,9 @@ describe('the ingest receiver — dispatch (EC-617)', () => {
271
293
  const liar = await declare('image/png', 500)
272
294
  const wrongType = await storeFile(t, 10, 'text/html')
273
295
  expect((await signedPost(t, { ...SUBMISSION, uploads: [{ uploadId: liar, storageId: wrongType }] })).status).toBe(415)
274
- expect(await t.run((ctx) => ctx.storage.get(wrongType))).toBeNull() // deleted, never kept
296
+ // EC-671: refused and KEPT — a claim never deletes the id it names (it is the visitor's word).
297
+ expect(await stored(t, wrongType)).toBe(true)
298
+ expect(await intentState(t, liar)).toEqual({ state: 'rejected', storageId: wrongType })
275
299
  })
276
300
 
277
301
  it('an upload no submission claims is listed once its life is over (the visible orphan finding)', async () => {
@@ -285,6 +309,124 @@ describe('the ingest receiver — dispatch (EC-617)', () => {
285
309
  })
286
310
  })
287
311
 
312
+ /**
313
+ * EC-671 — a claim binds only bytes the deployment's own facts say are this intent's, each
314
+ * check isolated (the others pass by construction) beside a control the same harness claims;
315
+ * and `releaseIngestUploads` deletes only what a claim bound. The forms addon's end-to-end
316
+ * probe (its rollback and removal, the attacker's harvest) is
317
+ * `packages/forms/src/convex/claim-ownership.convex.test.ts`.
318
+ */
319
+ describe('the claim binds only this intent’s bytes; release deletes only bound bytes (EC-671)', () => {
320
+ const PNG = { kind: 'upload', target: 'contact', field: 'cv', contentType: 'image/png', size: 500 } as const
321
+ async function declare(t: ReturnType<typeof backend>): Promise<string> {
322
+ return ((await (await signedPost(t, PNG)).json()) as { uploadId: string }).uploadId
323
+ }
324
+ const claimWith = (t: ReturnType<typeof backend>, uploadId: string, storageId: string) =>
325
+ signedPost(t, { ...SUBMISSION, uploads: [{ uploadId, storageId }] })
326
+ async function insertAsset(t: ReturnType<typeof backend>, files: { storageKey: string; thumb?: string }) {
327
+ await t.run(async (ctx) => {
328
+ await ctx.db.insert('assets', {
329
+ assetId: `asset_${files.storageKey}`,
330
+ filename: 'hero.png',
331
+ contentType: 'image/png',
332
+ byteSize: 400,
333
+ storageKey: files.storageKey,
334
+ ...(files.thumb ? { thumb: { storageKey: files.thumb, url: 'https://cdn.invalid/t', width: 1, height: 1 } } : {}),
335
+ createdAt: '2026-10-26T12:00:00.000Z',
336
+ updatedAt: '2026-10-26T12:00:00.000Z',
337
+ })
338
+ })
339
+ }
340
+ const bindings = (t: ReturnType<typeof backend>) =>
341
+ t.run(async (ctx) =>
342
+ (await ctx.db.query('records').collect())
343
+ .filter((row) => row.kind === 'ingestBinding')
344
+ .map((row) => ({ storageId: row.recordId, uploadId: (row.data as { uploadId: string }).uploadId })),
345
+ )
346
+
347
+ it('(1) a file stored BEFORE the intent — in the same millisecond — is refused, rejected and kept; one stored after is claimed', async () => {
348
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
349
+ const t = backend()
350
+ const earlier = await storeFile(t, 400, 'image/png') // the fake clock does not move: one millisecond for all
351
+ const uploadId = await declare(t)
352
+ // Why the rule reads the intent ROW's `_creationTime`, not `issuedAt`: in this millisecond
353
+ // the earlier file is NOT before `issuedAt` (the HTTP action's clock, ms grain) — that
354
+ // comparison would have admitted it. Both `_creationTime`s are the deployment's stamps.
355
+ const facts = await t.run(async (ctx) => {
356
+ const intent = (await ctx.db.query('records').collect()).find((row) => row.recordId === uploadId)!
357
+ const file = (await ctx.db.system.get(earlier as never)) as { _creationTime: number }
358
+ return { file: file._creationTime, issuedAt: Date.parse((intent.data as { issuedAt: string }).issuedAt), intent: intent._creationTime }
359
+ })
360
+ expect(facts.file).toBeGreaterThanOrEqual(facts.issuedAt)
361
+ expect(facts.file).toBeLessThan(facts.intent)
362
+ const refused = await claimWith(t, uploadId, earlier)
363
+ expect(refused.status).toBe(422)
364
+ expect(await refused.json()).toEqual({ error: `Upload “${uploadId}” did not store the file “${earlier}”.` })
365
+ expect(await intentState(t, uploadId)).toEqual({ state: 'rejected', storageId: earlier })
366
+ expect(await stored(t, earlier)).toBe(true)
367
+ expect(await bindings(t)).toEqual([])
368
+ // Control: a file stored after its intent is claimed and bound.
369
+ const fresh = await declare(t)
370
+ const own = await storeFile(t, 400, 'image/png')
371
+ expect((await claimWith(t, fresh, own)).status).toBe(200)
372
+ expect(await bindings(t)).toEqual([{ storageId: own, uploadId: fresh }])
373
+ })
374
+
375
+ it('(2) a file another claim bound is refused for a second intent issued before it was stored', async () => {
376
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
377
+ const t = backend()
378
+ const first = await declare(t)
379
+ const second = await declare(t) // issued BEFORE the file, so check (1) passes for it too
380
+ const file = await storeFile(t, 400, 'image/png')
381
+ expect((await claimWith(t, first, file)).status).toBe(200) // control: the first claim binds it
382
+ expect((await claimWith(t, second, file)).status).toBe(422)
383
+ expect(await intentState(t, second)).toEqual({ state: 'rejected', storageId: file })
384
+ expect(await bindings(t)).toEqual([{ storageId: file, uploadId: first }])
385
+ expect(await stored(t, file)).toBe(true)
386
+ })
387
+
388
+ it('(3) a file an asset row references — as its file or as its preview — is refused; control: an unreferenced one is claimed', async () => {
389
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
390
+ const t = backend()
391
+ const [asFile, asThumb, plain] = [await declare(t), await declare(t), await declare(t)]
392
+ const original = await storeFile(t, 400, 'image/png') // all stored AFTER the three intents: check (1) passes
393
+ const preview = await storeFile(t, 40, 'image/png')
394
+ const unreferenced = await storeFile(t, 400, 'image/png')
395
+ await insertAsset(t, { storageKey: original, thumb: preview })
396
+ expect((await claimWith(t, asFile, original)).status).toBe(422)
397
+ expect((await claimWith(t, asThumb, preview)).status).toBe(422)
398
+ expect((await claimWith(t, plain, unreferenced)).status).toBe(200)
399
+ expect(await bindings(t)).toEqual([{ storageId: unreferenced, uploadId: plain }])
400
+ expect([await stored(t, original), await stored(t, preview)]).toEqual([true, true])
401
+ })
402
+
403
+ it('releaseIngestUploads deletes a bound file and its binding, keeps an unbound one (an asset’s), and dedupes', async () => {
404
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
405
+ const t = backend()
406
+ const uploadId = await declare(t)
407
+ const own = await storeFile(t, 400, 'image/png')
408
+ expect((await claimWith(t, uploadId, own)).status).toBe(200)
409
+ const assetFile = await storeFile(t, 400, 'image/png')
410
+ await insertAsset(t, { storageKey: assetFile })
411
+ const released = await t.run((ctx) => releaseIngestUploads(ctx, [own, assetFile, own]))
412
+ expect(released).toEqual({ deleted: [own], kept: [assetFile] })
413
+ expect([await stored(t, own), await stored(t, assetFile)]).toEqual([false, true])
414
+ expect(await bindings(t)).toEqual([])
415
+ })
416
+
417
+ it('the binding row is flat — its stored nesting is far inside Convex’s limit', async () => {
418
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
419
+ const t = backend()
420
+ const uploadId = await declare(t)
421
+ const own = await storeFile(t, 400, 'image/png')
422
+ expect((await claimWith(t, uploadId, own)).status).toBe(200)
423
+ const row = await t.run(async (ctx) => (await ctx.db.query('records').collect()).find((r) => r.kind === 'ingestBinding')!)
424
+ expect(row.data).toEqual({ uploadId, target: 'contact', boundAt: '2026-10-26T12:00:00.000Z' })
425
+ expect(measureMaxDepth(row).depth).toBe(2) // the row object → `data` → its string columns
426
+ expect(measureMaxDepth(row).depth).toBeLessThanOrEqual(CONVEX_MAX_NESTING_DEPTH)
427
+ })
428
+ })
429
+
288
430
  describe('the site’s route → the receiver, end to end (EC-617)', () => {
289
431
  it('a visitor’s submission through createIngestRoute lands a record; a mismatched site secret is a 502', async () => {
290
432
  env()['ELYTRA_INGEST_SECRET'] = SECRET