@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.
- package/convex/cms.ts +272 -16
- package/convex/delivery-folded-drift.test.ts +104 -0
- package/convex/delivery.ts +24 -8
- package/convex/depth-codec-lookalike.test.ts +192 -0
- package/convex/fixture-accounts.test.ts +305 -0
- package/convex/fixtureAccounts.ts +298 -0
- package/convex/incarnation.test.ts +149 -0
- package/convex/ingest.test.ts +145 -3
- package/convex/ingest.ts +123 -17
- package/convex/receipt-races.test.ts +184 -0
- package/convex/records-zod-copy.test.ts +76 -0
- package/convex/schema.ts +20 -1
- package/convex/strict-delete.test.ts +129 -0
- package/convex/strict-reference-race.test.ts +139 -0
- package/dist/components/ai/ai-chat-panel.js +15 -2
- package/dist/components/ai/ai-chat-panel.js.map +1 -1
- package/dist/components/ai/translate-document-action.d.ts +6 -0
- package/dist/components/ai/translate-document-action.js.map +1 -1
- package/dist/components/ai/translate-document-dialog.d.ts +1 -1
- package/dist/components/ai/translate-document-dialog.js +5 -5
- package/dist/components/ai/translate-document-dialog.js.map +1 -1
- package/dist/components/assets/asset-detail.js +5 -2
- package/dist/components/assets/asset-detail.js.map +1 -1
- package/dist/components/assets/asset-locale.d.ts +7 -0
- package/dist/components/assets/asset-locale.js +16 -2
- package/dist/components/assets/asset-locale.js.map +1 -1
- package/dist/components/assets/asset-meta-drift-notice.d.ts +13 -0
- package/dist/components/assets/asset-meta-drift-notice.js +22 -0
- package/dist/components/assets/asset-meta-drift-notice.js.map +1 -0
- package/dist/components/assets/asset-meta-label.d.ts +2 -1
- package/dist/components/assets/asset-meta-label.js +4 -2
- package/dist/components/assets/asset-meta-label.js.map +1 -1
- package/dist/components/assets/asset-metadata.d.ts +33 -13
- package/dist/components/assets/asset-metadata.js +46 -22
- package/dist/components/assets/asset-metadata.js.map +1 -1
- package/dist/components/assets/asset-search.d.ts +6 -2
- package/dist/components/assets/asset-search.js +15 -5
- package/dist/components/assets/asset-search.js.map +1 -1
- package/dist/components/assets/assets-collection-view-source.d.ts +5 -3
- package/dist/components/assets/assets-collection-view-source.js +7 -5
- package/dist/components/assets/assets-collection-view-source.js.map +1 -1
- package/dist/components/builder/canvas-rebase.d.ts +36 -12
- package/dist/components/builder/canvas-rebase.js +13 -3
- package/dist/components/builder/canvas-rebase.js.map +1 -1
- package/dist/components/builder/canvas-render-context.js +5 -4
- package/dist/components/builder/canvas-render-context.js.map +1 -1
- package/dist/components/builder/composition-field-editor.d.ts +12 -6
- package/dist/components/builder/composition-field-editor.js +6 -5
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/cms/asset-picker-core.d.ts +2 -2
- package/dist/components/cms/asset-picker-core.js +2 -2
- package/dist/components/cms/asset-picker-core.js.map +1 -1
- package/dist/components/cms/asset-picker.js +3 -1
- package/dist/components/cms/asset-picker.js.map +1 -1
- package/dist/components/cms/canvas-commit.d.ts +6 -0
- package/dist/components/cms/canvas-commit.js +5 -1
- package/dist/components/cms/canvas-commit.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +24 -8
- package/dist/components/cms/cms-store.js +21 -14
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/collection-table.js +4 -11
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/detail-editor.js +2 -2
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/draft-commit.d.ts +16 -3
- package/dist/components/cms/draft-commit.js +3 -2
- package/dist/components/cms/draft-commit.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +107 -10
- package/dist/components/cms/use-document-draft.js +157 -42
- package/dist/components/cms/use-document-draft.js.map +1 -1
- package/dist/components/collection-view/cms-collection-view-source.js +6 -4
- package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
- package/dist/components/components/transfer-dialog.js +6 -4
- package/dist/components/components/transfer-dialog.js.map +1 -1
- package/dist/lib/ai/chat-state.d.ts +7 -0
- package/dist/lib/ai/chat-state.js.map +1 -1
- package/dist/lib/ai/tools.d.ts +8 -0
- package/dist/lib/ai/tools.js +4 -0
- package/dist/lib/ai/tools.js.map +1 -1
- package/dist/lib/cms-views/view-model.d.ts +15 -8
- package/dist/lib/cms-views/view-model.js +19 -5
- package/dist/lib/cms-views/view-model.js.map +1 -1
- package/dist/lib/collection-view/__fixtures__/document-writes.js +1 -0
- package/dist/lib/collection-view/__fixtures__/document-writes.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +18 -7
- package/dist/lib/collection-view/collection-view-source.js.map +1 -1
- package/dist/lib/data/collection-content.js +5 -0
- package/dist/lib/data/collection-content.js.map +1 -1
- package/dist/lib/engine-version.d.ts +1 -1
- package/dist/lib/engine-version.js +1 -1
- package/dist/lib/engine-version.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +15 -1
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +13 -0
- package/dist/lib/i18n/messages.en.js +16 -1
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/cms-persistence.d.ts +119 -35
- package/dist/lib/operations/cms-persistence.js +441 -91
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +80 -8
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persona/labels.js +2 -0
- package/dist/lib/persona/labels.js.map +1 -1
- 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
|
+
})
|
package/convex/ingest.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|