@elytracms/studio 0.0.103 → 0.0.105
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/ingest.test.ts +208 -13
- package/convex/ingest.ts +56 -35
- package/dist/components/app-views/module.d.ts +4 -0
- package/dist/components/app-views/module.js.map +1 -1
- package/dist/components/builder/canvas-theme-notice.js +4 -1
- package/dist/components/builder/canvas-theme-notice.js.map +1 -1
- package/dist/components/cms/document-diff-view.js +43 -7
- package/dist/components/cms/document-diff-view.js.map +1 -1
- package/dist/components/components/components-index.d.ts +0 -7
- package/dist/components/components/components-index.js +9 -2
- package/dist/components/components/components-index.js.map +1 -1
- package/dist/components/redirects/redirects-schema.d.ts +31 -0
- package/dist/components/redirects/redirects-schema.js +12 -6
- package/dist/components/redirects/redirects-schema.js.map +1 -1
- package/dist/lib/ai/translate-document.d.ts +7 -3
- package/dist/lib/ai/translate-document.js +63 -48
- package/dist/lib/ai/translate-document.js.map +1 -1
- package/dist/lib/ai/writer-form.d.ts +48 -5
- package/dist/lib/ai/writer-form.js +118 -64
- package/dist/lib/ai/writer-form.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 +25 -5
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +21 -1
- package/dist/lib/i18n/messages.en.js +29 -6
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/persona/labels.js +1 -0
- package/dist/lib/persona/labels.js.map +1 -1
- package/dist/lib/workspace/index.d.ts +9 -1
- package/dist/lib/workspace/index.js +13 -2
- package/dist/lib/workspace/index.js.map +1 -1
- package/package.json +3 -3
package/convex/ingest.test.ts
CHANGED
|
@@ -10,13 +10,14 @@ import {
|
|
|
10
10
|
signedIngestRequest,
|
|
11
11
|
} from '@elytracms/core/ingest'
|
|
12
12
|
import type { IngestHandlerResult, IngestPayload } from '@elytracms/core/ingest'
|
|
13
|
-
import { defineRecordKind } from '@elytracms/core/persistence'
|
|
13
|
+
import { defineRecordKind, resolveRecordQuery } from '@elytracms/core/persistence'
|
|
14
14
|
import schema from './schema'
|
|
15
15
|
import { internalMutation } from './functions'
|
|
16
16
|
import { defineStudioHttp } from './http'
|
|
17
17
|
import { CONVEX_MAX_NESTING_DEPTH, measureMaxDepth } from '@elytracms/core/cms-core'
|
|
18
18
|
import {
|
|
19
19
|
claimIngestUploads,
|
|
20
|
+
ingestBindingRecordKind,
|
|
20
21
|
ingestHandlerArgs,
|
|
21
22
|
parseIngestPayload,
|
|
22
23
|
recordIngestUpload,
|
|
@@ -24,7 +25,7 @@ import {
|
|
|
24
25
|
unclaimedIngestUploads,
|
|
25
26
|
} from './ingest'
|
|
26
27
|
import type { IngestHandlerRef } from './ingest'
|
|
27
|
-
import { saveRecord } from './record-functions'
|
|
28
|
+
import { findRecordDoc, saveRecord } from './record-functions'
|
|
28
29
|
import { createIngestRoute } from '../../next/src/ingest'
|
|
29
30
|
|
|
30
31
|
beforeEach(() => {
|
|
@@ -80,7 +81,7 @@ const testIngestModule = {
|
|
|
80
81
|
// EC-750: the row's id first — the claim binds every file to it (the owner release is asked for).
|
|
81
82
|
const id = `sub_${(await ctx.db.query('records').collect()).length + 1}`
|
|
82
83
|
// EC-695: one call for every upload, after every check that can refuse — all bound or none.
|
|
83
|
-
const claim = await claimIngestUploads(ctx, payload.uploads ?? [], payload.target, args.receivedAt, id)
|
|
84
|
+
const claim = await claimIngestUploads(ctx, payload.uploads ?? [], payload.target, args.receivedAt, testSubmissionKind, id)
|
|
84
85
|
if (!claim.ok) return claim
|
|
85
86
|
const files = claim.files.map((file) => file.storageId)
|
|
86
87
|
await saveRecord(
|
|
@@ -98,11 +99,56 @@ const testIngestModule = {
|
|
|
98
99
|
const handle = makeFunctionReference<'mutation'>('testIngest:handle') as unknown as IngestHandlerRef
|
|
99
100
|
const silentAuth = { addHttpRoutes: () => {} }
|
|
100
101
|
|
|
102
|
+
/**
|
|
103
|
+
* EC-762 (Codex r10 F51) — a third-party addon whose ONE submission handler writes two record
|
|
104
|
+
* kinds, each row under an EXPLICIT id the payload names (the records API accepts any nonempty
|
|
105
|
+
* id; ids are unique only within a kind). The target names the kind.
|
|
106
|
+
*/
|
|
107
|
+
const attachmentRequestKind = defineRecordKind({
|
|
108
|
+
kind: 'attachmentRequest',
|
|
109
|
+
module: 'testCases',
|
|
110
|
+
fields: { note: z.string().min(1), files: z.array(z.string()).optional() },
|
|
111
|
+
})
|
|
112
|
+
const supportCaseKind = defineRecordKind({
|
|
113
|
+
kind: 'supportCase',
|
|
114
|
+
module: 'testCases',
|
|
115
|
+
fields: { note: z.string().min(1), files: z.array(z.string()).optional() },
|
|
116
|
+
})
|
|
117
|
+
const caseKindOf = (target: string) =>
|
|
118
|
+
target === 'attachmentRequest' ? attachmentRequestKind : target === 'supportCase' ? supportCaseKind : undefined
|
|
119
|
+
|
|
120
|
+
const testCasesModule = {
|
|
121
|
+
handle: internalMutation({
|
|
122
|
+
args: ingestHandlerArgs,
|
|
123
|
+
handler: async (ctx, args): Promise<IngestHandlerResult> => {
|
|
124
|
+
const payload: IngestPayload = parseIngestPayload(args.payload)
|
|
125
|
+
if (payload.kind === 'upload') {
|
|
126
|
+
return recordIngestUpload(ctx, payload, args.receivedAt, { maxBytes: 1000, contentTypes: ['image/*'] })
|
|
127
|
+
}
|
|
128
|
+
if (payload.kind !== 'submission') return { ok: false, status: 422, message: 'Not a submission.' }
|
|
129
|
+
const kind = caseKindOf(payload.target)
|
|
130
|
+
if (!kind) return { ok: false, status: 404, message: `No case kind “${payload.target}”.` }
|
|
131
|
+
const id = payload.data['id']
|
|
132
|
+
if (typeof id !== 'string' || id.length === 0) return { ok: false, status: 422, message: 'An id is required.' }
|
|
133
|
+
const claim = await claimIngestUploads(ctx, payload.uploads ?? [], payload.target, args.receivedAt, kind, id)
|
|
134
|
+
if (!claim.ok) return claim
|
|
135
|
+
const files = claim.files.map((file) => file.storageId)
|
|
136
|
+
await saveRecord(ctx, kind, id, { note: payload.target, ...(files.length ? { files } : {}) }, {
|
|
137
|
+
createdAt: args.receivedAt,
|
|
138
|
+
updatedAt: args.receivedAt,
|
|
139
|
+
})
|
|
140
|
+
return { ok: true, id }
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
}
|
|
144
|
+
const casesHandle = makeFunctionReference<'mutation'>('testCases:handle') as unknown as IngestHandlerRef
|
|
145
|
+
|
|
101
146
|
function backend(handlers: Parameters<typeof defineStudioHttp>[0]['ingest'] = { submission: handle }) {
|
|
102
147
|
return convexTest(schema, {
|
|
103
148
|
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
104
149
|
'./_generated/api.ts': async () => ({}),
|
|
105
150
|
'./testIngest.ts': async () => testIngestModule,
|
|
151
|
+
'./testCases.ts': async () => testCasesModule,
|
|
106
152
|
'./http.ts': async () => ({ default: defineStudioHttp({ auth: silentAuth, ingest: handlers }) }),
|
|
107
153
|
})
|
|
108
154
|
}
|
|
@@ -456,13 +502,13 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
456
502
|
const assetFile = await storeFile(t, 400, 'image/png')
|
|
457
503
|
await insertAsset(t, { storageKey: assetFile })
|
|
458
504
|
expect(await owners(t)).toEqual({ [own]: owner, [other]: otherOwner })
|
|
459
|
-
const released = await t.run((ctx) => releaseIngestUploads(ctx, owner))
|
|
505
|
+
const released = await t.run((ctx) => releaseIngestUploads(ctx, testSubmissionKind, owner))
|
|
460
506
|
expect(released).toEqual({ deleted: [own], kept: [] })
|
|
461
507
|
expect([await stored(t, own), await stored(t, other), await stored(t, assetFile)]).toEqual([false, true, true])
|
|
462
508
|
expect(await bindings(t)).toEqual([{ storageId: other, uploadId: theirs }])
|
|
463
509
|
// An owner with nothing bound any more (released already, or a row that never claimed) deletes nothing.
|
|
464
|
-
expect(await t.run((ctx) => releaseIngestUploads(ctx, owner))).toEqual({ deleted: [], kept: [] })
|
|
465
|
-
expect(await t.run((ctx) => releaseIngestUploads(ctx, 'sub_never'))).toEqual({ deleted: [], kept: [] })
|
|
510
|
+
expect(await t.run((ctx) => releaseIngestUploads(ctx, testSubmissionKind, owner))).toEqual({ deleted: [], kept: [] })
|
|
511
|
+
expect(await t.run((ctx) => releaseIngestUploads(ctx, testSubmissionKind, 'sub_never'))).toEqual({ deleted: [], kept: [] })
|
|
466
512
|
})
|
|
467
513
|
|
|
468
514
|
// EC-685 (Codex r4 F20, ordering 2): a fact checked at claim time is not permanent — an
|
|
@@ -495,7 +541,7 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
495
541
|
)
|
|
496
542
|
// An editor promotes two of the uploads to an asset (its file and its preview) — after the claims.
|
|
497
543
|
await insertAsset(t, { storageKey: promoted, thumb: preview })
|
|
498
|
-
const released = await t.run((ctx) => releaseIngestUploads(ctx, owner))
|
|
544
|
+
const released = await t.run((ctx) => releaseIngestUploads(ctx, testSubmissionKind, owner))
|
|
499
545
|
expect(released).toEqual({ deleted: [unreferenced], kept: [promoted, preview] })
|
|
500
546
|
expect([await stored(t, promoted), await stored(t, preview), await stored(t, unreferenced)]).toEqual([true, true, false])
|
|
501
547
|
expect(await bindings(t)).toEqual([]) // ownership passed: no binding is left to hold the asset's bytes
|
|
@@ -520,7 +566,7 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
520
566
|
await t.run(async (ctx) => {
|
|
521
567
|
for (const row of await ctx.db.query('assets').collect()) await ctx.db.delete(row._id)
|
|
522
568
|
})
|
|
523
|
-
expect(await t.run((ctx) => releaseIngestUploads(ctx, owner))).toEqual({ deleted: [own], kept: [] })
|
|
569
|
+
expect(await t.run((ctx) => releaseIngestUploads(ctx, testSubmissionKind, owner))).toEqual({ deleted: [own], kept: [] })
|
|
524
570
|
expect(await stored(t, own)).toBe(false)
|
|
525
571
|
})
|
|
526
572
|
|
|
@@ -625,10 +671,17 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
625
671
|
const own = await storeFile(t, 400, 'image/png')
|
|
626
672
|
const owner = await ownerOf(await claimWith(t, uploadId, own))
|
|
627
673
|
const row = await t.run(async (ctx) => (await ctx.db.query('records').collect()).find((r) => r.kind === 'ingestBinding')!)
|
|
628
|
-
expect(row.data).toEqual({ uploadId, target: 'contact', owner, boundAt: '2026-10-26T12:00:00.000Z' })
|
|
629
|
-
// EC-
|
|
630
|
-
|
|
631
|
-
expect(row.
|
|
674
|
+
expect(row.data).toEqual({ uploadId, target: 'contact', ownerKind: 'testSubmission', owner, boundAt: '2026-10-26T12:00:00.000Z' })
|
|
675
|
+
// EC-762: the owner's kind fills the static `key1` slot and its id `key2`, read together
|
|
676
|
+
// through the existing `by_kind_keys` index — no schema or index change.
|
|
677
|
+
expect(row.key1).toBe('testSubmission')
|
|
678
|
+
expect(row.key2).toBe(owner)
|
|
679
|
+
expect(resolveRecordQuery(ingestBindingRecordKind, { where: { ownerKind: 'testSubmission', owner } })).toEqual({
|
|
680
|
+
index: 'by_kind_keys',
|
|
681
|
+
key1: 'testSubmission',
|
|
682
|
+
key2: owner,
|
|
683
|
+
order: 'asc',
|
|
684
|
+
})
|
|
632
685
|
expect(measureMaxDepth(row).depth).toBe(2) // the row object → `data` → its string columns
|
|
633
686
|
expect(measureMaxDepth(row).depth).toBeLessThanOrEqual(CONVEX_MAX_NESTING_DEPTH)
|
|
634
687
|
})
|
|
@@ -643,7 +696,7 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
643
696
|
),
|
|
644
697
|
)
|
|
645
698
|
const claimDirect = (t: ReturnType<typeof backend>, claims: { uploadId: string; storageId: string }[]) =>
|
|
646
|
-
t.run((ctx) => claimIngestUploads(ctx, claims, 'contact', '2026-10-26T12:00:00.000Z', 'sub_direct'))
|
|
699
|
+
t.run((ctx) => claimIngestUploads(ctx, claims, 'contact', '2026-10-26T12:00:00.000Z', testSubmissionKind, 'sub_direct'))
|
|
647
700
|
|
|
648
701
|
it('EC-695: a submission with one refused claim binds NOTHING — the claims before it stay issued; nothing is deleted', async () => {
|
|
649
702
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
@@ -713,6 +766,148 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
713
766
|
})
|
|
714
767
|
})
|
|
715
768
|
|
|
769
|
+
/**
|
|
770
|
+
* EC-762 (Codex r10 F51) — the first EXECUTABLE probe of F51's static trace. A record is
|
|
771
|
+
* identified by its kind AND its id (`record-functions.ts`, `findRecordDoc`), and an explicit id
|
|
772
|
+
* is unique only within its kind. One third-party handler writes `attachmentRequest/same` and
|
|
773
|
+
* `supportCase/same`, each claiming its OWN upload; removing one must not delete the other's.
|
|
774
|
+
*/
|
|
775
|
+
describe('EC-762 probe — an upload’s owner is the record’s kind AND id (Codex r10 F51)', () => {
|
|
776
|
+
const REMOVED_AT = '2026-10-26T12:00:00.000Z'
|
|
777
|
+
async function declareFor(t: ReturnType<typeof backend>, target: string): Promise<string> {
|
|
778
|
+
const intent = { kind: 'upload', target, field: 'cv', contentType: 'image/png', size: 500, sha256: await sha256Of(400) } as const
|
|
779
|
+
const answer = await signedPost(t, intent)
|
|
780
|
+
expect(answer.status).toBe(200)
|
|
781
|
+
return ((await answer.json()) as { uploadId: string }).uploadId
|
|
782
|
+
}
|
|
783
|
+
/** A genuine case of `target`'s kind under the explicit id `id`, claiming one upload of its own. Returns its file. */
|
|
784
|
+
async function genuineCase(t: ReturnType<typeof backend>, target: string, id: string): Promise<string> {
|
|
785
|
+
const uploadId = await declareFor(t, target)
|
|
786
|
+
const file = await storeFile(t, 400, 'image/png')
|
|
787
|
+
const answer = await signedPost(t, { kind: 'submission', target, data: { id }, uploads: [{ uploadId, storageId: file }] })
|
|
788
|
+
expect(answer.status).toBe(200)
|
|
789
|
+
expect(await answer.json()).toEqual({ ok: true, id })
|
|
790
|
+
return file
|
|
791
|
+
}
|
|
792
|
+
/** The addon's removal of one row: release what it claimed, then delete it. */
|
|
793
|
+
const removeCase = (t: ReturnType<typeof backend>, kind: typeof attachmentRequestKind, id: string) =>
|
|
794
|
+
t.run(async (ctx) => {
|
|
795
|
+
const released = await releaseIngestUploads(ctx, kind, id)
|
|
796
|
+
const doc = await findRecordDoc(ctx, kind, id)
|
|
797
|
+
if (doc) await ctx.db.delete(doc._id)
|
|
798
|
+
return released
|
|
799
|
+
})
|
|
800
|
+
const caseBindings = (t: ReturnType<typeof backend>) =>
|
|
801
|
+
t.run(async (ctx) =>
|
|
802
|
+
(await ctx.db.query('records').collect())
|
|
803
|
+
.filter((row) => row.kind === 'ingestBinding')
|
|
804
|
+
.map((row) => {
|
|
805
|
+
const data = row.data as { ownerKind?: string; owner?: string }
|
|
806
|
+
return { storageId: row.recordId, ownerKind: data.ownerKind, owner: data.owner }
|
|
807
|
+
}),
|
|
808
|
+
)
|
|
809
|
+
async function twoKindsOneId() {
|
|
810
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
811
|
+
const t = backend({ submission: casesHandle })
|
|
812
|
+
const a = await genuineCase(t, 'attachmentRequest', 'same')
|
|
813
|
+
const b = await genuineCase(t, 'supportCase', 'same')
|
|
814
|
+
return { t, a, b }
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** A binding row as an earlier release wrote it, straight into the table. */
|
|
818
|
+
const plantBinding = (t: ReturnType<typeof backend>, storageId: string, data: Record<string, string>, key1?: string) =>
|
|
819
|
+
t.run(async (ctx) => {
|
|
820
|
+
await ctx.db.insert('records', {
|
|
821
|
+
kind: 'ingestBinding',
|
|
822
|
+
recordId: storageId,
|
|
823
|
+
data: { uploadId: `upload_${storageId}`, target: 'attachmentRequest', boundAt: REMOVED_AT, ...data },
|
|
824
|
+
...(key1 !== undefined ? { key1 } : {}),
|
|
825
|
+
at: REMOVED_AT,
|
|
826
|
+
createdAt: REMOVED_AT,
|
|
827
|
+
updatedAt: REMOVED_AT,
|
|
828
|
+
})
|
|
829
|
+
})
|
|
830
|
+
async function asEditor(t: ReturnType<typeof backend>) {
|
|
831
|
+
await t.run(async (ctx) => {
|
|
832
|
+
await ctx.db.insert('members', { userId: 'user_editor', role: 'admin', createdAt: REMOVED_AT, updatedAt: REMOVED_AT })
|
|
833
|
+
})
|
|
834
|
+
return t.withIdentity({ subject: 'user_editor', issuer: 'https://convex.test' })
|
|
835
|
+
}
|
|
836
|
+
const deleteBlob = makeFunctionReference<'mutation', { storageKey: string }, unknown>('assets:deleteBlob')
|
|
837
|
+
|
|
838
|
+
it('a claim binds each file to its owner’s kind AND id', async () => {
|
|
839
|
+
const { t, a, b } = await twoKindsOneId()
|
|
840
|
+
expect(await caseBindings(t)).toEqual([
|
|
841
|
+
{ storageId: a, ownerKind: 'attachmentRequest', owner: 'same' }, // base: ownerKind undefined
|
|
842
|
+
{ storageId: b, ownerKind: 'supportCase', owner: 'same' },
|
|
843
|
+
])
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
it('Codex’s trace: removing attachmentRequest/same keeps supportCase/same’s bytes and binding', async () => {
|
|
847
|
+
const { t, a, b } = await twoKindsOneId()
|
|
848
|
+
// Measured on the base (7c2e9261), counterexample form: `{ deleted: [a, b], kept: [] }`, both files gone, no binding left.
|
|
849
|
+
expect(await removeCase(t, attachmentRequestKind, 'same')).toEqual({ deleted: [a], kept: [] })
|
|
850
|
+
expect([await stored(t, a), await stored(t, b)]).toEqual([false, true])
|
|
851
|
+
expect(await caseBindings(t)).toEqual([{ storageId: b, ownerKind: 'supportCase', owner: 'same' }])
|
|
852
|
+
// supportCase/same still holds its file; its own removal deletes it (below).
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
it('control: removing the owner itself (same kind, same id) deletes its file and binding — the instrument sees a deletion', async () => {
|
|
856
|
+
const { t, a, b } = await twoKindsOneId()
|
|
857
|
+
const released = await removeCase(t, supportCaseKind, 'same')
|
|
858
|
+
expect(released.deleted).toContain(b)
|
|
859
|
+
expect(await stored(t, b)).toBe(false)
|
|
860
|
+
expect((await caseBindings(t)).map((binding) => binding.storageId)).not.toContain(b)
|
|
861
|
+
// …and after both rows are removed nothing is left: no residue.
|
|
862
|
+
await removeCase(t, attachmentRequestKind, 'same')
|
|
863
|
+
expect([await stored(t, a), await stored(t, b)]).toEqual([false, false])
|
|
864
|
+
expect(await caseBindings(t)).toEqual([])
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
it('a KIND-LESS binding (written before bindings recorded the owner’s kind) is never deleted — residue, never loss; it still holds its bytes', async () => {
|
|
868
|
+
const { t, a } = await twoKindsOneId()
|
|
869
|
+
const kindless = await storeFile(t, 300, 'image/png')
|
|
870
|
+
await plantBinding(t, kindless, { owner: 'same' }, 'same') // the shape EC-750 wrote: `owner` in `key1`, no kind
|
|
871
|
+
expect(await removeCase(t, attachmentRequestKind, 'same')).toEqual({ deleted: [a], kept: [] }) // base: deleted the kind-less file too
|
|
872
|
+
expect(await stored(t, kindless)).toBe(true)
|
|
873
|
+
expect((await caseBindings(t)).filter((binding) => binding.storageId === kindless)).toEqual([
|
|
874
|
+
{ storageId: kindless, ownerKind: undefined, owner: 'same' },
|
|
875
|
+
])
|
|
876
|
+
const editor = await asEditor(t)
|
|
877
|
+
expect(await editor.mutation(deleteBlob, { storageKey: kindless })).toEqual({ deleted: false, heldBy: 'ingest' })
|
|
878
|
+
expect(await stored(t, kindless)).toBe(true)
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
it('control: an OWNER-LESS binding (written before EC-750) is never deleted', async () => {
|
|
882
|
+
const { t, a } = await twoKindsOneId()
|
|
883
|
+
const ownerless = await storeFile(t, 300, 'image/png')
|
|
884
|
+
await plantBinding(t, ownerless, {})
|
|
885
|
+
await removeCase(t, attachmentRequestKind, 'same')
|
|
886
|
+
await removeCase(t, supportCaseKind, 'same')
|
|
887
|
+
expect([await stored(t, a), await stored(t, ownerless)]).toEqual([false, true])
|
|
888
|
+
expect(await caseBindings(t)).toEqual([{ storageId: ownerless, ownerKind: undefined, owner: undefined }])
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
it('assets:deleteBlob still keeps bytes a binding holds — after attachmentRequest/same’s removal, supportCase/same’s binding protects its file', async () => {
|
|
892
|
+
const { t, b } = await twoKindsOneId()
|
|
893
|
+
await removeCase(t, attachmentRequestKind, 'same')
|
|
894
|
+
await t.run(async (ctx) => {
|
|
895
|
+
await ctx.db.insert('assets', {
|
|
896
|
+
assetId: 'asset_case',
|
|
897
|
+
filename: 'case.png',
|
|
898
|
+
contentType: 'image/png',
|
|
899
|
+
byteSize: 400,
|
|
900
|
+
storageKey: b,
|
|
901
|
+
createdAt: REMOVED_AT,
|
|
902
|
+
updatedAt: REMOVED_AT,
|
|
903
|
+
})
|
|
904
|
+
})
|
|
905
|
+
const editor = await asEditor(t)
|
|
906
|
+
expect(await editor.mutation(deleteBlob, { storageKey: b })).toEqual({ deleted: false, heldBy: 'ingest' }) // base: the bytes were already gone
|
|
907
|
+
expect(await stored(t, b)).toBe(true)
|
|
908
|
+
})
|
|
909
|
+
})
|
|
910
|
+
|
|
716
911
|
describe('the site’s route → the receiver, end to end (EC-617)', () => {
|
|
717
912
|
it('a visitor’s submission through createIngestRoute lands a record; a mismatched site secret is a 502', async () => {
|
|
718
913
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
package/convex/ingest.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from '@elytracms/core/ingest'
|
|
17
17
|
import type { IngestHandlerResult, IngestPayload, IngestUploadClaim } from '@elytracms/core/ingest'
|
|
18
18
|
import { defineRecordKind, resolveRecordQuery } from '@elytracms/core/persistence'
|
|
19
|
+
import type { AnyRecordKind } from '@elytracms/core/persistence'
|
|
19
20
|
import { createUuidIdFactory } from '@elytracms/core/determinism'
|
|
20
21
|
import type { MutationCtx, QueryCtx } from './functions'
|
|
21
22
|
import { findRecordDoc, listRecordWire, queryRecordDocs, saveRecord } from './record-functions'
|
|
@@ -44,8 +45,8 @@ import { findRecordDoc, listRecordWire, queryRecordDocs, saveRecord } from './re
|
|
|
44
45
|
* claims its stored files with {@link claimIngestUploads}, which checks that the deployment
|
|
45
46
|
* stored each for ITS intent (EC-671) — its bytes the ones the upload request declared by
|
|
46
47
|
* SHA-256 (EC-685) — and that it matches the declared size and type, and binds them all or
|
|
47
|
-
* none (EC-695) to the record that claims them (EC-750);
|
|
48
|
-
* for that record, is the only way such bytes are deleted.
|
|
48
|
+
* none (EC-695) to the record that claims them, by its kind and id (EC-750, EC-762);
|
|
49
|
+
* {@link releaseIngestUploads}, asked for that record, is the only way such bytes are deleted.
|
|
49
50
|
*
|
|
50
51
|
* The engine never imports an addon: the handler is an internal-mutation REFERENCE the
|
|
51
52
|
* consumer passes in its one `http.ts` line (`defineStudioHttp({ auth, ingest: {
|
|
@@ -197,9 +198,11 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
|
|
|
197
198
|
// which deletes only what a claim bound.
|
|
198
199
|
//
|
|
199
200
|
// EC-750 (Codex r9 F46) — bound to WHOM. A binding records the record that claimed the file
|
|
200
|
-
// (
|
|
201
|
-
//
|
|
202
|
-
//
|
|
201
|
+
// — EC-762 (Codex r10 F51): its kind AND its id (`ownerKind` + `owner`), the identity the records
|
|
202
|
+
// table declares — and release is asked for that owner, never for storage ids: removing a row
|
|
203
|
+
// deletes only the files that row claimed. Before EC-750, release took the removed row's `files`
|
|
204
|
+
// ids — a column a whole-record write can fill with another row's file — and deleted any bound
|
|
205
|
+
// one; before EC-762, it took the id alone, so a row of another kind sharing the id lost its files.
|
|
203
206
|
//
|
|
204
207
|
// EC-685 — causation. Eligibility (stored after the intent, unbound, no asset's) is not
|
|
205
208
|
// ownership: with intents A and B open, bytes stored through B's URL were eligible for A. So
|
|
@@ -243,23 +246,34 @@ export const ingestUploadRecordKind = defineRecordKind({
|
|
|
243
246
|
* claim's transaction; read by the claim (a file another claim bound is refused) and by
|
|
244
247
|
* {@link releaseIngestUploads}. Engine-internal (no public functions), like the intent.
|
|
245
248
|
*
|
|
246
|
-
* EC-750 (Codex r9 F46) — and WHOSE:
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
249
|
+
* EC-750 (Codex r9 F46) — and WHOSE: the record that claimed the file (a forms submission), so
|
|
250
|
+
* release asks "this record's files", never "is this id bound?". `target` is the intent's (the
|
|
251
|
+
* form every submission to it shares), so it cannot say whose a file is.
|
|
252
|
+
*
|
|
253
|
+
* EC-762 (Codex r10 F51) — a record is its kind AND its id (the records table's `kind` +
|
|
254
|
+
* `recordId`; an explicit id is unique only within its kind), so the owner is both:
|
|
255
|
+
* `ownerKind` (the owning kind's name) in the `key1` slot and `owner` (its id) in `key2`, read
|
|
256
|
+
* TOGETHER through the existing `by_kind_keys` index. Two rows of different kinds sharing an id
|
|
257
|
+
* never share a binding. Two string columns, never one object column (a key slot holds a
|
|
258
|
+
* string: an object would stringify to the same `[object Object]` for every owner) and never a
|
|
259
|
+
* composite string (a second encoding of an identity the table already stores as two columns).
|
|
260
|
+
*
|
|
261
|
+
* Bindings of earlier releases are never read by a release, so never deleted: one written before
|
|
262
|
+
* EC-750 has no owner and no slot, one written before EC-762 has its owner's id in `key1` and no
|
|
263
|
+
* `key2`. Each keeps protecting its bytes from `assets:deleteBlob` (residue, never loss —
|
|
264
|
+
* EC-671's no-migration precedent; no owner-only fallback, which would re-open F51).
|
|
253
265
|
*/
|
|
254
266
|
export const ingestBindingRecordKind = defineRecordKind({
|
|
255
267
|
kind: 'ingestBinding',
|
|
256
268
|
module: 'ingest',
|
|
257
|
-
keys: ['owner'],
|
|
269
|
+
keys: ['ownerKind', 'owner'],
|
|
258
270
|
time: 'boundAt',
|
|
259
271
|
fields: {
|
|
260
272
|
uploadId: z.string().min(1),
|
|
261
273
|
target: z.string().min(1),
|
|
262
|
-
/** EC-
|
|
274
|
+
/** EC-762: the KIND of the record that claimed the file (its declaration's `kind`). */
|
|
275
|
+
ownerKind: z.string().min(1),
|
|
276
|
+
/** EC-750: the id of the record that claimed the file — with `ownerKind`, what {@link releaseIngestUploads} is asked for. */
|
|
263
277
|
owner: z.string().min(1),
|
|
264
278
|
boundAt: z.string().min(1),
|
|
265
279
|
},
|
|
@@ -421,17 +435,20 @@ async function assetReferencesStorage(ctx: MutationCtx, storageId: string): Prom
|
|
|
421
435
|
* mutation that RETURNS commits its writes (a claim made before a later refusal stays bound with
|
|
422
436
|
* no row to hold it); one that THROWS commits nothing.
|
|
423
437
|
*
|
|
424
|
-
* EC-750
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
* put any id in its `files`) never
|
|
438
|
+
* EC-750 / EC-762: the owner is the record the handler is about to write — its kind's
|
|
439
|
+
* declaration (`ownerKind`) and its id (`ownerId`, minted BEFORE the claim). Every binding records
|
|
440
|
+
* both, and {@link releaseIngestUploads} deletes a file only for the owner that claimed it: a row
|
|
441
|
+
* naming another row's file (an editor's whole-record write can put any id in its `files`) never
|
|
442
|
+
* deletes it, nor does a row of another kind that happens to share the id. The declaration, not a
|
|
443
|
+
* string: a misspelt kind cannot compile into bindings no release ever finds.
|
|
428
444
|
*/
|
|
429
445
|
export async function claimIngestUploads(
|
|
430
446
|
ctx: MutationCtx,
|
|
431
447
|
claims: readonly IngestUploadClaim[],
|
|
432
448
|
target: string,
|
|
433
449
|
receivedAt: string,
|
|
434
|
-
|
|
450
|
+
ownerKind: AnyRecordKind,
|
|
451
|
+
ownerId: string,
|
|
435
452
|
): Promise<IngestUploadsClaimResult> {
|
|
436
453
|
const accepted: AcceptedClaim[] = []
|
|
437
454
|
for (const [index, claim] of claims.entries()) {
|
|
@@ -445,7 +462,7 @@ export async function claimIngestUploads(
|
|
|
445
462
|
ctx,
|
|
446
463
|
ingestBindingRecordKind,
|
|
447
464
|
claim.storageId,
|
|
448
|
-
{ uploadId: claim.uploadId, target, owner, boundAt: receivedAt },
|
|
465
|
+
{ uploadId: claim.uploadId, target, ownerKind: ownerKind.kind, owner: ownerId, boundAt: receivedAt },
|
|
449
466
|
stamps,
|
|
450
467
|
)
|
|
451
468
|
await saveRecord(ctx, ingestUploadRecordKind, claim.uploadId, { state: 'claimed', storageId: claim.storageId }, stamps)
|
|
@@ -537,13 +554,14 @@ async function judgeIngestUploadClaim(
|
|
|
537
554
|
}
|
|
538
555
|
|
|
539
556
|
/**
|
|
540
|
-
* Delete the ingest bytes ONE record claimed (EC-671, EC-750) — the ONE way ingest bytes
|
|
541
|
-
* deleted. It is asked for an OWNER
|
|
542
|
-
* for storage ids: it reads that owner's
|
|
543
|
-
* file with its binding — so it never
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
557
|
+
* Delete the ingest bytes ONE record claimed (EC-671, EC-750, EC-762) — the ONE way ingest bytes
|
|
558
|
+
* are deleted. It is asked for an OWNER — the kind's declaration and the id
|
|
559
|
+
* {@link claimIngestUploads} bound the files to — never for storage ids: it reads that owner's
|
|
560
|
+
* bindings by BOTH keys (`by_kind_keys`) and deletes each file with its binding — so it never
|
|
561
|
+
* sees, and never writes, another owner's binding, a row of another kind sharing the id
|
|
562
|
+
* included. A row's `files` column is the visitor's and the editor's word (a whole-record write
|
|
563
|
+
* can name any id, or clear the list), so release never reads it: a row naming another row's
|
|
564
|
+
* file deletes nothing, and a row whose list was cleared still deletes what it claimed.
|
|
547
565
|
*
|
|
548
566
|
* Of the owner's files it KEEPS, returned in `kept`:
|
|
549
567
|
*
|
|
@@ -553,24 +571,27 @@ async function judgeIngestUploadClaim(
|
|
|
553
571
|
* asset's own delete removes them later. Read in this transaction, so no asset can acquire
|
|
554
572
|
* the file between the check and the delete.
|
|
555
573
|
*
|
|
556
|
-
* Never touched: a binding of another owner
|
|
557
|
-
*
|
|
574
|
+
* Never touched: a binding of another owner; a binding written before EC-750 (no owner, no key
|
|
575
|
+
* slot); one written before EC-762 (its owner's id alone, in `key1`, no `key2` — read by id
|
|
576
|
+
* alone it would be F51 again, so there is no such fallback). Residue, never loss: each keeps
|
|
577
|
+
* protecting its bytes from `assets:deleteBlob`.
|
|
558
578
|
*
|
|
559
|
-
* Call it when removing a row that holds claimed files
|
|
560
|
-
* bound nothing ({@link claimIngestUploads}, EC-695). The mirror is
|
|
561
|
-
* ({@link ingestBindingHolds}): each
|
|
562
|
-
* transaction.
|
|
579
|
+
* Call it when removing a row that holds claimed files, with that row's kind and id — never for
|
|
580
|
+
* a refused submission, which bound nothing ({@link claimIngestUploads}, EC-695). The mirror is
|
|
581
|
+
* `assets:deleteBlob`, which keeps bytes a binding holds ({@link ingestBindingHolds}): each
|
|
582
|
+
* delete path re-checks the OTHER owner in its own transaction.
|
|
563
583
|
*/
|
|
564
584
|
export async function releaseIngestUploads(
|
|
565
585
|
ctx: MutationCtx,
|
|
566
|
-
|
|
586
|
+
ownerKind: AnyRecordKind,
|
|
587
|
+
ownerId: string,
|
|
567
588
|
): Promise<{ deleted: string[]; kept: string[] }> {
|
|
568
589
|
const deleted: string[] = []
|
|
569
590
|
const kept: string[] = []
|
|
570
591
|
const owned = await queryRecordDocs(
|
|
571
592
|
ctx,
|
|
572
593
|
ingestBindingRecordKind,
|
|
573
|
-
resolveRecordQuery(ingestBindingRecordKind, { where: { owner } }),
|
|
594
|
+
resolveRecordQuery(ingestBindingRecordKind, { where: { ownerKind: ownerKind.kind, owner: ownerId } }),
|
|
574
595
|
)
|
|
575
596
|
for (const binding of owned) {
|
|
576
597
|
const storageId = binding.recordId
|
|
@@ -150,6 +150,10 @@ export interface AppViewModule {
|
|
|
150
150
|
* Parse this view's trailing path segments into a deep-link, or `null` for an
|
|
151
151
|
* unroutable path (→ not-found). Omitted → a FLAT view ({@link flatPath}): only
|
|
152
152
|
* the bare path is valid, any trailing segment is not-found.
|
|
153
|
+
*
|
|
154
|
+
* **The segments arrive DECODED by the router** (`/p/view/a%25b` → `['a%b']`): each IS the
|
|
155
|
+
* value, and is never decoded again — a second decode throws on a value holding `%` (`50%`)
|
|
156
|
+
* and reads `a%41` as another value (`aA`). One decoder per address (EC-768).
|
|
153
157
|
*/
|
|
154
158
|
parsePath?: (segments: string[]) => AppViewDeepLink | null;
|
|
155
159
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"module.js","sourceRoot":"","sources":["../../../src/components/app-views/module.tsx"],"names":[],"mappings":"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","sourcesContent":["import type { ReactNode } from 'react'\nimport type { LucideIcon } from 'lucide-react'\nimport { availabilityOf } from '@elytracms/core/studio-core'\nimport type { Project, SectionAvailability, SectionId } from '@elytracms/core/studio-core'\nimport type { CollectionDef } from '@elytracms/core/cms-core'\nimport type { ProjectRole } from '@elytracms/core/persistence'\nimport type { StudioSearch } from '@elytracms/studio/router/search'\nimport type { Locale } from '@elytracms/core/cms-core'\nimport type { OperationsClient } from '@elytracms/core/operations'\nimport type { AuthUser } from '../../lib/auth'\nimport type { useOperationCache, useOperationQuery } from '../../lib/query'\nimport type { NavIssue } from '../nav-issues-registry'\n\n/**\n * The studio **view** contract + the **sidebar node** model — the internal types\n * the consumer-facing `nav.*` builders (EC-237/239, customization seam EC-236)\n * produce.\n *\n * A studio surface (the CMS, Routes, Assets view, or a net-new one) is a\n * self-contained {@link AppViewModule}: a stable id + display metadata + a React\n * `render` + an optional deep-link parser. A {@link SidebarNode} is one rail entry\n * — a view, a labelled group of views, or a separator — and a **view node carries\n * its view object DIRECTLY**, not a string id. This is the redesign's core: there\n * is no separate \"register a view, then reference it by `moduleId`\" indirection\n * and no shared id-keyspace, so the section rail IS the set of views, the router\n * registry is DERIVED from it, and the duplicate-id / both-highlighted class of\n * bug is gone by construction.\n *\n * The boundary principle: everything here is studio-only live code passed by\n * reference (no string keys). A string only crosses the serializable boundary for\n * field controls (`form.control`), where the field lives in delivery-shared schema.\n *\n * Reuse-not-copy (EC-239): a focused view like \"Catalog\" is the CMS surface\n * instantiated with config (`nav.cms({ collections })`), not a fork — the same\n * surface code backs many views, each its own addressable node.\n */\n\n/** The deep-link a view parses out of its trailing path segments (e.g. `{ collectionId, documentId }`). */\nexport type AppViewDeepLink = Record<string, string | null>\n\n/**\n * What an OPTIONAL nav-permission predicate sees (EC-240, vision AD-18): the\n * project + the signed-in user's resolved role (`null` while it loads, or for a\n * non-member). Kept minimal — policy that needs more should live server-side.\n */\nexport interface NavPermissionContext {\n project: Project\n role: ProjectRole | null\n}\n\n/**\n * An OPTIONAL, **default-open** per-nav-item visibility predicate (AD-18). Returns\n * `false` to HIDE the item for this principal; absent ⇒ visible. Permissions\n * RESTRICT — they never grant a baseline. This is the general mechanism (applies\n * to EVERY nav item, not a Users special-case); the concrete policy ships in the\n * starter project, never the engine. UI reflection only — the server chokepoint\n * stays the real boundary, and hidden views remain URL-addressable (EC-221).\n */\nexport type NavPermission = (ctx: NavPermissionContext) => boolean\n\n/**\n * Everything a view's surface needs to render (EC-239), and — since EC-616 — what an\n * addon's view reaches the studio through, instead of importing internals: the\n * operations client (the one chokepoint every read and write goes through), the query\n * hooks over the one cache (AD-17), the ambient content locale, and the signed-in user\n * with their role on this project.\n *\n * **The chrome contract** (ruling § 6b item 1): the SHELL draws the top bar, the\n * breadcrumb (its section crumb is the view's `label`; deeper crumbs are contributed with\n * `useSetBreadcrumb`), the rail, the issues badge and tray in the header (findings\n * contributed with `useNavIssues`, EC-690) and the theme. The VIEW owns the whole scrollable content area under it,\n * including its own title header — the shell adds no frame, title or padding.\n */\nexport interface AppViewContext {\n project: Project\n projectId: string\n /** The view's parsed deep-link; `{}` for a flat view with no nested resource. */\n deepLink: AppViewDeepLink\n /** The validated `?…` search (forced state, focused field/asset, …). */\n search: StudioSearch\n /** The studio's operations client — every read and write, authorized at the chokepoint. */\n operations(): Promise<OperationsClient>\n /** The query hooks over the studio's one operations-backed cache (AD-17). */\n query: AppViewQueryHooks\n /** The ambient content locale (`null` until the project's locale config is installed). */\n locale: Locale | null\n /** The signed-in user (`null` while signed out). */\n user: AuthUser | null\n /** The user's role on this project (`null` while it loads, or for a non-member). */\n role: ProjectRole | null\n}\n\n/** The cache hooks a view reads and writes through (`useOperationQuery` / `useOperationCache`). */\nexport interface AppViewQueryHooks {\n useOperationQuery: typeof useOperationQuery\n useOperationCache: typeof useOperationCache\n}\n\n/**\n * EC-616 — a view's ALWAYS-ON findings: a React hook the studio shell mounts for every\n * view that declares one (whether or not the view is open), publishing what it returns\n * through `useNavIssues` to the shell's issues tray (EC-690; it used to be the rail foot).\n * Use it for a problem the developer must see from any surface (an addon's broken\n * configuration); a finding that only matters while the view is open is published from\n * the view with `useNavIssues` directly.\n */\nexport type NavIssuesHook = (ctx: { project: Project; projectId: string }) => readonly NavIssue[]\n\n/**\n * EC-476/477 — how a view's id came to be, when the builder **derived** it instead of\n * taking an explicit `config.id`.\n *\n * Diagnostics only: nothing routes, renders or resolves on this. It exists because the\n * derived-vs-explicit distinction is otherwise UNRECOVERABLE downstream, and the two\n * must not behave the same. `nav.view({ id: 'cms' })` names a built-in on purpose —\n * that is the documented override feature (EC-236). `nav.view({ label: 'CMS' })` names\n * a *label*, and `slugify` happening to produce `cms` is an accident that costs an\n * entire built-in surface. `resolveSidebar` cannot tell those apart from the finished\n * view: every built-in's own English label slugifies onto its own id\n * (`settingsView().label` = \"Settings\" → `settings`), so inferring \"was this derived?\"\n * by re-slugifying the label misfires on the DEFAULT RAIL. Hence a carried marker.\n *\n * Present only on a derived id — an explicit `config.id` leaves it absent, which is\n * what makes \"the developer meant it\" checkable.\n */\nexport type DerivedViewId =\n /** Slugified from a custom view's LABEL (`nav.view({ label })`); the label is carried for the message. */\n | { from: 'label'; label: string }\n /**\n * Joined from a MULTI-collection CMS focus with the `-` separator\n * (`nav.cms({ collections: ['a','b'] })` → `cms:a-b`). The single-collection leaf and\n * the one-item `cms-group:` parent are deliberately NOT marked: their ids are\n * unambiguous by construction (EC-468), so there is nothing to diagnose.\n */\n | { from: 'collection-join'; collections: readonly string[] }\n\nexport interface AppViewModule {\n /** Stable id — the first path segment under the project + the registry key. */\n id: string\n /**\n * EC-476/477 — DIAGNOSTIC provenance for an id the builder DERIVED rather than took\n * from an explicit `config.id`. Absent → the id was declared. See {@link DerivedViewId}.\n */\n derivedId?: DerivedViewId\n label: string\n description: string\n icon: LucideIcon\n /** Render the surface; it mounts its own providers/gates. */\n render: (ctx: AppViewContext) => ReactNode\n /**\n * Parse this view's trailing path segments into a deep-link, or `null` for an\n * unroutable path (→ not-found). Omitted → a FLAT view ({@link flatPath}): only\n * the bare path is valid, any trailing segment is not-found.\n */\n parsePath?: (segments: string[]) => AppViewDeepLink | null\n /**\n * The availability section this view rides on (EC-221 gating): an `unavailable`\n * section hides the view from the rail. Omitted → always available (a custom\n * view defaults to visible).\n */\n availabilitySection?: SectionId\n /**\n * OPTIONAL, default-open per-nav-item visibility (EC-240, vision AD-18). Absent\n * → visible. Evaluated with the signed-in user's role so devs can configure\n * role-based sidebars for ANY nav item (not just Users). Distinct from\n * {@link availabilitySection} (project-config gating): this is per-principal.\n * See {@link NavPermission}. (Phase-0 contract; the rail wires it in EC-240c —\n * `resolveSidebar`/`SectionNav` thread the role through and honour a `false`.)\n */\n visible?: NavPermission\n /** A redirect-only / utility view the stock rail does not list (e.g. `builder`). */\n hidden?: boolean\n /** EC-616 — always-on findings for the shell's issues tray (see {@link NavIssuesHook}). */\n useIssues?: NavIssuesHook\n /**\n * EC-690 — the record kind a record rail lists (`nav.rail(kind)`, `nav.redirects`), so a\n * finding located at a record (`{ kind: 'record', recordKind, id }`) opens this view's row\n * whatever id the consumer gave the view. Set by `railView`; absent on every other view.\n */\n recordKind?: string\n}\n\n// --- The sidebar node model (what `nav.*` produces) ---\n\n/**\n * A rail entry that mounts a view. Carries the view object directly — no string id.\n *\n * EC-293: a view node may NEST one level. `children` is a static list of sub-views;\n * `expand` derives them at resolve time from the project's collections (CMS\n * auto-expand). Children render indented under the parent — indentation signals\n * parent→child, distinct from a flat labelled {@link SidebarGroupNode}. `children`\n * (explicit) wins over `expand` when both are set.\n */\nexport interface SidebarViewNode {\n kind: 'view'\n view: AppViewModule\n /** Static sub-views, rendered indented under this view (one level of nesting). */\n children?: SidebarViewNode[]\n /**\n * Resolve-time children from the project's collections (e.g. CMS expanding each\n * collection into a focused-lens child). Run by `resolveSidebar` with the\n * config-sourced collections; the result renders as {@link children}.\n */\n expand?: (collections: readonly CollectionDef[]) => SidebarViewNode[]\n}\n/**\n * A labelled group of view entries. Groups are presentation only — never routable,\n * and **flat**: their children render aligned with the top-level items (the heading\n * is the only grouping signal). Group children do not nest — use a parent-with-\n * children ({@link SidebarViewNode.expand}/`children`) for nesting (EC-293).\n */\nexport interface SidebarGroupNode {\n kind: 'group'\n label: string\n children: SidebarViewNode[]\n}\n/** A divider between rail sections. */\nexport interface SidebarSeparatorNode {\n kind: 'separator'\n}\nexport type SidebarNode = SidebarViewNode | SidebarGroupNode | SidebarSeparatorNode\n\n/** Wrap a view as a rail entry. */\nexport function viewNode(view: AppViewModule): SidebarViewNode {\n return { kind: 'view', view }\n}\n/** A labelled group of view entries (sub-views under a header). */\nexport function groupNode(label: string, children: SidebarViewNode[]): SidebarGroupNode {\n return { kind: 'group', label, children }\n}\n/** A rail divider. */\nexport function separatorNode(): SidebarSeparatorNode {\n return { kind: 'separator' }\n}\n\n// --- Deep-link parsers (shared by the built-in views + reused by factories) ---\n\n/** A flat view: only the bare path resolves; any trailing segment is not-found. */\nexport function flatPath(segments: string[]): AppViewDeepLink | null {\n return segments.length === 0 ? {} : null\n}\n\n/** CMS arity: `[]` index · `[c]` collection · `[c, d]` document; deeper is not-found. */\nexport function cmsDeepLink(segments: string[]): AppViewDeepLink | null {\n if (segments.length === 0) return { collectionId: null, documentId: null }\n if (segments.length === 1) return { collectionId: segments[0]!, documentId: null }\n if (segments.length === 2) return { collectionId: segments[0]!, documentId: segments[1]! }\n return null\n}\n\n/** Components arity: `[]` index · `[id]` detail; deeper is not-found. */\nexport function componentsDeepLink(segments: string[]): AppViewDeepLink | null {\n if (segments.length === 0) return { componentId: null }\n if (segments.length === 1) return { componentId: segments[0]! }\n return null\n}\n\n/** Users arity (EC-240): `[]` list · `[userId]` member detail; deeper is not-found. */\nexport function usersDeepLink(segments: string[]): AppViewDeepLink | null {\n if (segments.length === 0) return { userId: null }\n if (segments.length === 1) return { userId: segments[0]! }\n return null\n}\n\n/**\n * A record rail's arity (EC-616): `[]` list · `[id]` one record's detail, under the kind's\n * `idKey`; deeper is not-found.\n */\nexport function recordDeepLink(idKey: string): (segments: string[]) => AppViewDeepLink | null {\n return (segments) => {\n if (segments.length === 0) return { [idKey]: null }\n if (segments.length === 1) return { [idKey]: segments[0]! }\n return null\n }\n}\n\n/** Redirects arity (EC-303): `[]` list · `[redirectId]` detail; deeper is not-found — the redirect kind's {@link recordDeepLink}. */\nexport const redirectsDeepLink = recordDeepLink('redirectId')\n\n/** Assets arity (EC-319): `[]` list · `[assetId]` detail; deeper is not-found. */\nexport function assetsDeepLink(segments: string[]): AppViewDeepLink | null {\n if (segments.length === 0) return { assetId: null }\n if (segments.length === 1) return { assetId: segments[0]! }\n return null\n}\n\n// --- Availability (EC-221) ---\n\n/**\n * A view's availability for a project (EC-221): the section it rides on, or always\n * `available` when it declares none (a custom view). Drives both the rail (hide\n * `unavailable`) and the bare-project landing target.\n */\nexport function moduleAvailability(project: Project, module: AppViewModule): SectionAvailability {\n return module.availabilitySection\n ? availabilityOf(project.settings.sectionAvailability, module.availabilitySection)\n : 'available'\n}\n\n/**\n * The view a project should open to from its bare URL: the first listed\n * (non-hidden), non-`unavailable` view in rail order. Callers pass the resolved\n * registry's view list (rail order); `undefined` only if every view is\n * hidden/unavailable (the resolver guarantees Settings, so in practice never).\n */\nexport function firstAvailableView(\n views: readonly AppViewModule[],\n project: Project,\n): AppViewModule | undefined {\n return views.find(\n (view) => !view.hidden && moduleAvailability(project, view) !== 'unavailable',\n )\n}\n"]}
|
|
1
|
+
{"version":3,"file":"module.js","sourceRoot":"","sources":["../../../src/components/app-views/module.tsx"],"names":[],"mappings":"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","sourcesContent":["import type { ReactNode } from 'react'\nimport type { LucideIcon } from 'lucide-react'\nimport { availabilityOf } from '@elytracms/core/studio-core'\nimport type { Project, SectionAvailability, SectionId } from '@elytracms/core/studio-core'\nimport type { CollectionDef } from '@elytracms/core/cms-core'\nimport type { ProjectRole } from '@elytracms/core/persistence'\nimport type { StudioSearch } from '@elytracms/studio/router/search'\nimport type { Locale } from '@elytracms/core/cms-core'\nimport type { OperationsClient } from '@elytracms/core/operations'\nimport type { AuthUser } from '../../lib/auth'\nimport type { useOperationCache, useOperationQuery } from '../../lib/query'\nimport type { NavIssue } from '../nav-issues-registry'\n\n/**\n * The studio **view** contract + the **sidebar node** model — the internal types\n * the consumer-facing `nav.*` builders (EC-237/239, customization seam EC-236)\n * produce.\n *\n * A studio surface (the CMS, Routes, Assets view, or a net-new one) is a\n * self-contained {@link AppViewModule}: a stable id + display metadata + a React\n * `render` + an optional deep-link parser. A {@link SidebarNode} is one rail entry\n * — a view, a labelled group of views, or a separator — and a **view node carries\n * its view object DIRECTLY**, not a string id. This is the redesign's core: there\n * is no separate \"register a view, then reference it by `moduleId`\" indirection\n * and no shared id-keyspace, so the section rail IS the set of views, the router\n * registry is DERIVED from it, and the duplicate-id / both-highlighted class of\n * bug is gone by construction.\n *\n * The boundary principle: everything here is studio-only live code passed by\n * reference (no string keys). A string only crosses the serializable boundary for\n * field controls (`form.control`), where the field lives in delivery-shared schema.\n *\n * Reuse-not-copy (EC-239): a focused view like \"Catalog\" is the CMS surface\n * instantiated with config (`nav.cms({ collections })`), not a fork — the same\n * surface code backs many views, each its own addressable node.\n */\n\n/** The deep-link a view parses out of its trailing path segments (e.g. `{ collectionId, documentId }`). */\nexport type AppViewDeepLink = Record<string, string | null>\n\n/**\n * What an OPTIONAL nav-permission predicate sees (EC-240, vision AD-18): the\n * project + the signed-in user's resolved role (`null` while it loads, or for a\n * non-member). Kept minimal — policy that needs more should live server-side.\n */\nexport interface NavPermissionContext {\n project: Project\n role: ProjectRole | null\n}\n\n/**\n * An OPTIONAL, **default-open** per-nav-item visibility predicate (AD-18). Returns\n * `false` to HIDE the item for this principal; absent ⇒ visible. Permissions\n * RESTRICT — they never grant a baseline. This is the general mechanism (applies\n * to EVERY nav item, not a Users special-case); the concrete policy ships in the\n * starter project, never the engine. UI reflection only — the server chokepoint\n * stays the real boundary, and hidden views remain URL-addressable (EC-221).\n */\nexport type NavPermission = (ctx: NavPermissionContext) => boolean\n\n/**\n * Everything a view's surface needs to render (EC-239), and — since EC-616 — what an\n * addon's view reaches the studio through, instead of importing internals: the\n * operations client (the one chokepoint every read and write goes through), the query\n * hooks over the one cache (AD-17), the ambient content locale, and the signed-in user\n * with their role on this project.\n *\n * **The chrome contract** (ruling § 6b item 1): the SHELL draws the top bar, the\n * breadcrumb (its section crumb is the view's `label`; deeper crumbs are contributed with\n * `useSetBreadcrumb`), the rail, the issues badge and tray in the header (findings\n * contributed with `useNavIssues`, EC-690) and the theme. The VIEW owns the whole scrollable content area under it,\n * including its own title header — the shell adds no frame, title or padding.\n */\nexport interface AppViewContext {\n project: Project\n projectId: string\n /** The view's parsed deep-link; `{}` for a flat view with no nested resource. */\n deepLink: AppViewDeepLink\n /** The validated `?…` search (forced state, focused field/asset, …). */\n search: StudioSearch\n /** The studio's operations client — every read and write, authorized at the chokepoint. */\n operations(): Promise<OperationsClient>\n /** The query hooks over the studio's one operations-backed cache (AD-17). */\n query: AppViewQueryHooks\n /** The ambient content locale (`null` until the project's locale config is installed). */\n locale: Locale | null\n /** The signed-in user (`null` while signed out). */\n user: AuthUser | null\n /** The user's role on this project (`null` while it loads, or for a non-member). */\n role: ProjectRole | null\n}\n\n/** The cache hooks a view reads and writes through (`useOperationQuery` / `useOperationCache`). */\nexport interface AppViewQueryHooks {\n useOperationQuery: typeof useOperationQuery\n useOperationCache: typeof useOperationCache\n}\n\n/**\n * EC-616 — a view's ALWAYS-ON findings: a React hook the studio shell mounts for every\n * view that declares one (whether or not the view is open), publishing what it returns\n * through `useNavIssues` to the shell's issues tray (EC-690; it used to be the rail foot).\n * Use it for a problem the developer must see from any surface (an addon's broken\n * configuration); a finding that only matters while the view is open is published from\n * the view with `useNavIssues` directly.\n */\nexport type NavIssuesHook = (ctx: { project: Project; projectId: string }) => readonly NavIssue[]\n\n/**\n * EC-476/477 — how a view's id came to be, when the builder **derived** it instead of\n * taking an explicit `config.id`.\n *\n * Diagnostics only: nothing routes, renders or resolves on this. It exists because the\n * derived-vs-explicit distinction is otherwise UNRECOVERABLE downstream, and the two\n * must not behave the same. `nav.view({ id: 'cms' })` names a built-in on purpose —\n * that is the documented override feature (EC-236). `nav.view({ label: 'CMS' })` names\n * a *label*, and `slugify` happening to produce `cms` is an accident that costs an\n * entire built-in surface. `resolveSidebar` cannot tell those apart from the finished\n * view: every built-in's own English label slugifies onto its own id\n * (`settingsView().label` = \"Settings\" → `settings`), so inferring \"was this derived?\"\n * by re-slugifying the label misfires on the DEFAULT RAIL. Hence a carried marker.\n *\n * Present only on a derived id — an explicit `config.id` leaves it absent, which is\n * what makes \"the developer meant it\" checkable.\n */\nexport type DerivedViewId =\n /** Slugified from a custom view's LABEL (`nav.view({ label })`); the label is carried for the message. */\n | { from: 'label'; label: string }\n /**\n * Joined from a MULTI-collection CMS focus with the `-` separator\n * (`nav.cms({ collections: ['a','b'] })` → `cms:a-b`). The single-collection leaf and\n * the one-item `cms-group:` parent are deliberately NOT marked: their ids are\n * unambiguous by construction (EC-468), so there is nothing to diagnose.\n */\n | { from: 'collection-join'; collections: readonly string[] }\n\nexport interface AppViewModule {\n /** Stable id — the first path segment under the project + the registry key. */\n id: string\n /**\n * EC-476/477 — DIAGNOSTIC provenance for an id the builder DERIVED rather than took\n * from an explicit `config.id`. Absent → the id was declared. See {@link DerivedViewId}.\n */\n derivedId?: DerivedViewId\n label: string\n description: string\n icon: LucideIcon\n /** Render the surface; it mounts its own providers/gates. */\n render: (ctx: AppViewContext) => ReactNode\n /**\n * Parse this view's trailing path segments into a deep-link, or `null` for an\n * unroutable path (→ not-found). Omitted → a FLAT view ({@link flatPath}): only\n * the bare path is valid, any trailing segment is not-found.\n *\n * **The segments arrive DECODED by the router** (`/p/view/a%25b` → `['a%b']`): each IS the\n * value, and is never decoded again — a second decode throws on a value holding `%` (`50%`)\n * and reads `a%41` as another value (`aA`). One decoder per address (EC-768).\n */\n parsePath?: (segments: string[]) => AppViewDeepLink | null\n /**\n * The availability section this view rides on (EC-221 gating): an `unavailable`\n * section hides the view from the rail. Omitted → always available (a custom\n * view defaults to visible).\n */\n availabilitySection?: SectionId\n /**\n * OPTIONAL, default-open per-nav-item visibility (EC-240, vision AD-18). Absent\n * → visible. Evaluated with the signed-in user's role so devs can configure\n * role-based sidebars for ANY nav item (not just Users). Distinct from\n * {@link availabilitySection} (project-config gating): this is per-principal.\n * See {@link NavPermission}. (Phase-0 contract; the rail wires it in EC-240c —\n * `resolveSidebar`/`SectionNav` thread the role through and honour a `false`.)\n */\n visible?: NavPermission\n /** A redirect-only / utility view the stock rail does not list (e.g. `builder`). */\n hidden?: boolean\n /** EC-616 — always-on findings for the shell's issues tray (see {@link NavIssuesHook}). */\n useIssues?: NavIssuesHook\n /**\n * EC-690 — the record kind a record rail lists (`nav.rail(kind)`, `nav.redirects`), so a\n * finding located at a record (`{ kind: 'record', recordKind, id }`) opens this view's row\n * whatever id the consumer gave the view. Set by `railView`; absent on every other view.\n */\n recordKind?: string\n}\n\n// --- The sidebar node model (what `nav.*` produces) ---\n\n/**\n * A rail entry that mounts a view. Carries the view object directly — no string id.\n *\n * EC-293: a view node may NEST one level. `children` is a static list of sub-views;\n * `expand` derives them at resolve time from the project's collections (CMS\n * auto-expand). Children render indented under the parent — indentation signals\n * parent→child, distinct from a flat labelled {@link SidebarGroupNode}. `children`\n * (explicit) wins over `expand` when both are set.\n */\nexport interface SidebarViewNode {\n kind: 'view'\n view: AppViewModule\n /** Static sub-views, rendered indented under this view (one level of nesting). */\n children?: SidebarViewNode[]\n /**\n * Resolve-time children from the project's collections (e.g. CMS expanding each\n * collection into a focused-lens child). Run by `resolveSidebar` with the\n * config-sourced collections; the result renders as {@link children}.\n */\n expand?: (collections: readonly CollectionDef[]) => SidebarViewNode[]\n}\n/**\n * A labelled group of view entries. Groups are presentation only — never routable,\n * and **flat**: their children render aligned with the top-level items (the heading\n * is the only grouping signal). Group children do not nest — use a parent-with-\n * children ({@link SidebarViewNode.expand}/`children`) for nesting (EC-293).\n */\nexport interface SidebarGroupNode {\n kind: 'group'\n label: string\n children: SidebarViewNode[]\n}\n/** A divider between rail sections. */\nexport interface SidebarSeparatorNode {\n kind: 'separator'\n}\nexport type SidebarNode = SidebarViewNode | SidebarGroupNode | SidebarSeparatorNode\n\n/** Wrap a view as a rail entry. */\nexport function viewNode(view: AppViewModule): SidebarViewNode {\n return { kind: 'view', view }\n}\n/** A labelled group of view entries (sub-views under a header). */\nexport function groupNode(label: string, children: SidebarViewNode[]): SidebarGroupNode {\n return { kind: 'group', label, children }\n}\n/** A rail divider. */\nexport function separatorNode(): SidebarSeparatorNode {\n return { kind: 'separator' }\n}\n\n// --- Deep-link parsers (shared by the built-in views + reused by factories) ---\n\n/** A flat view: only the bare path resolves; any trailing segment is not-found. */\nexport function flatPath(segments: string[]): AppViewDeepLink | null {\n return segments.length === 0 ? {} : null\n}\n\n/** CMS arity: `[]` index · `[c]` collection · `[c, d]` document; deeper is not-found. */\nexport function cmsDeepLink(segments: string[]): AppViewDeepLink | null {\n if (segments.length === 0) return { collectionId: null, documentId: null }\n if (segments.length === 1) return { collectionId: segments[0]!, documentId: null }\n if (segments.length === 2) return { collectionId: segments[0]!, documentId: segments[1]! }\n return null\n}\n\n/** Components arity: `[]` index · `[id]` detail; deeper is not-found. */\nexport function componentsDeepLink(segments: string[]): AppViewDeepLink | null {\n if (segments.length === 0) return { componentId: null }\n if (segments.length === 1) return { componentId: segments[0]! }\n return null\n}\n\n/** Users arity (EC-240): `[]` list · `[userId]` member detail; deeper is not-found. */\nexport function usersDeepLink(segments: string[]): AppViewDeepLink | null {\n if (segments.length === 0) return { userId: null }\n if (segments.length === 1) return { userId: segments[0]! }\n return null\n}\n\n/**\n * A record rail's arity (EC-616): `[]` list · `[id]` one record's detail, under the kind's\n * `idKey`; deeper is not-found.\n */\nexport function recordDeepLink(idKey: string): (segments: string[]) => AppViewDeepLink | null {\n return (segments) => {\n if (segments.length === 0) return { [idKey]: null }\n if (segments.length === 1) return { [idKey]: segments[0]! }\n return null\n }\n}\n\n/** Redirects arity (EC-303): `[]` list · `[redirectId]` detail; deeper is not-found — the redirect kind's {@link recordDeepLink}. */\nexport const redirectsDeepLink = recordDeepLink('redirectId')\n\n/** Assets arity (EC-319): `[]` list · `[assetId]` detail; deeper is not-found. */\nexport function assetsDeepLink(segments: string[]): AppViewDeepLink | null {\n if (segments.length === 0) return { assetId: null }\n if (segments.length === 1) return { assetId: segments[0]! }\n return null\n}\n\n// --- Availability (EC-221) ---\n\n/**\n * A view's availability for a project (EC-221): the section it rides on, or always\n * `available` when it declares none (a custom view). Drives both the rail (hide\n * `unavailable`) and the bare-project landing target.\n */\nexport function moduleAvailability(project: Project, module: AppViewModule): SectionAvailability {\n return module.availabilitySection\n ? availabilityOf(project.settings.sectionAvailability, module.availabilitySection)\n : 'available'\n}\n\n/**\n * The view a project should open to from its bare URL: the first listed\n * (non-hidden), non-`unavailable` view in rail order. Callers pass the resolved\n * registry's view list (rail order); `undefined` only if every view is\n * hidden/unavailable (the resolver guarantees Settings, so in practice never).\n */\nexport function firstAvailableView(\n views: readonly AppViewModule[],\n project: Project,\n): AppViewModule | undefined {\n return views.find(\n (view) => !view.hidden && moduleAvailability(project, view) !== 'unavailable',\n )\n}\n"]}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from 'react';
|
|
3
3
|
import { AlertTriangle, X } from 'lucide-react';
|
|
4
|
+
import { elytraGuidePath } from '@elytracms/core/studio-core';
|
|
4
5
|
import { translate } from '../../lib/i18n/index.js';
|
|
5
6
|
import { isDevRuntime } from '../../lib/persistence/index.js';
|
|
6
7
|
import { Row, Text } from '@elytracms/studio/ui';
|
|
@@ -30,6 +31,8 @@ const NOTICE_KEYS = {
|
|
|
30
31
|
'no-theme-tokens': 'builder.canvasTheme.noThemeTokensNotice',
|
|
31
32
|
'no-stylesheet': 'builder.canvasTheme.noStylesheetNotice',
|
|
32
33
|
};
|
|
34
|
+
/** EC-765: the notices point at the guide's theming chapter where the project has it installed. */
|
|
35
|
+
const THEMING_CHAPTER = elytraGuidePath('13-theming.md');
|
|
33
36
|
export function CanvasThemeNotice({ status }) {
|
|
34
37
|
// Local state only forces the re-render on dismiss; the truth is module-level.
|
|
35
38
|
const [, setDismissedAt] = useState(0);
|
|
@@ -38,7 +41,7 @@ export function CanvasThemeNotice({ status }) {
|
|
|
38
41
|
const messageKey = NOTICE_KEYS[status.kind];
|
|
39
42
|
if (!messageKey || dismissedKinds.has(status.kind))
|
|
40
43
|
return null;
|
|
41
|
-
return (_jsx("div", { "data-testid": "canvas-theme-notice", "data-notice-kind": status.kind, className: "absolute top-4 left-1/2 z-20 w-max max-w-[min(460px,90%)] -translate-x-1/2", children: _jsx("div", { className: "rounded-lg border border-border bg-popover p-2 text-popover-foreground shadow-lg", children: _jsxs(Row, { gap: "2", align: "start", children: [_jsx(AlertTriangle, { className: "mt-0.5 size-3.5 shrink-0 text-warning", "aria-hidden": true }), _jsx(Text, { size: "xs", className: "min-w-0 flex-1", children: translate(messageKey) }), _jsx("button", { type: "button", "aria-label": translate('common.dismiss'), onClick: () => {
|
|
44
|
+
return (_jsx("div", { "data-testid": "canvas-theme-notice", "data-notice-kind": status.kind, className: "absolute top-4 left-1/2 z-20 w-max max-w-[min(460px,90%)] -translate-x-1/2", children: _jsx("div", { className: "rounded-lg border border-border bg-popover p-2 text-popover-foreground shadow-lg", children: _jsxs(Row, { gap: "2", align: "start", children: [_jsx(AlertTriangle, { className: "mt-0.5 size-3.5 shrink-0 text-warning", "aria-hidden": true }), _jsx(Text, { size: "xs", className: "min-w-0 flex-1", children: translate(messageKey, { guide: THEMING_CHAPTER }) }), _jsx("button", { type: "button", "aria-label": translate('common.dismiss'), onClick: () => {
|
|
42
45
|
dismissedKinds.add(status.kind);
|
|
43
46
|
setDismissedAt(Date.now());
|
|
44
47
|
}, className: "shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground", children: _jsx(X, { className: "size-3.5" }) })] }) }) }));
|