@elytracms/studio 0.0.80 → 0.0.82
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/contract-parity.test.ts +1 -1
- package/convex/functions.ts +13 -3
- package/convex/http-routes.test.ts +143 -0
- package/convex/http.ts +19 -9
- package/convex/redirect-revalidate.test.ts +130 -0
- package/convex/revalidate.ts +9 -7
- package/convex/schema.ts +0 -19
- package/convex/triggers.ts +33 -3
- package/convex/users.ts +1 -1
- package/dist/components/ai/ai-chat-panel.js +26 -4
- package/dist/components/ai/ai-chat-panel.js.map +1 -1
- package/dist/components/ai/proposal-card.d.ts +27 -0
- package/dist/components/ai/proposal-card.js +31 -3
- package/dist/components/ai/proposal-card.js.map +1 -1
- package/dist/components/ai/proposal-issues.d.ts +55 -0
- package/dist/components/ai/proposal-issues.js +69 -0
- package/dist/components/ai/proposal-issues.js.map +1 -0
- package/dist/components/ai/translate-document-dialog.js +38 -3
- package/dist/components/ai/translate-document-dialog.js.map +1 -1
- package/dist/components/app-views/default-modules.d.ts +141 -44
- package/dist/components/app-views/default-modules.js +229 -65
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/app-views/module.d.ts +38 -0
- package/dist/components/app-views/module.js.map +1 -1
- package/dist/components/app-views/sidebar.d.ts +5 -2
- package/dist/components/app-views/sidebar.js +16 -5
- package/dist/components/app-views/sidebar.js.map +1 -1
- package/dist/components/app-views/studio-config.d.ts +28 -1
- package/dist/components/app-views/studio-config.js +1 -1
- package/dist/components/app-views/studio-config.js.map +1 -1
- package/dist/components/builder/canvas-render-context.js +10 -2
- package/dist/components/builder/canvas-render-context.js.map +1 -1
- package/dist/components/builder/clipboard-actions.js +5 -3
- package/dist/components/builder/clipboard-actions.js.map +1 -1
- package/dist/components/builder/reuse-context.js +7 -1
- package/dist/components/builder/reuse-context.js.map +1 -1
- package/dist/components/builder/reuse.d.ts +94 -16
- package/dist/components/builder/reuse.js +201 -19
- package/dist/components/builder/reuse.js.map +1 -1
- package/dist/components/cms/bulk-actions.js +10 -5
- package/dist/components/cms/bulk-actions.js.map +1 -1
- package/dist/components/cms/cell-edit.d.ts +18 -3
- package/dist/components/cms/cell-edit.js +29 -7
- package/dist/components/cms/cell-edit.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +54 -14
- package/dist/components/cms/cms-store.js +142 -45
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/collection-table.js +2 -2
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/detail-editor.js +15 -14
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/document-editor-context.d.ts +12 -1
- package/dist/components/cms/document-editor-context.js.map +1 -1
- package/dist/components/cms/field-control.js +22 -8
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/field-renderer.js +8 -1
- package/dist/components/cms/field-renderer.js.map +1 -1
- package/dist/components/cms/gate-validation.d.ts +36 -0
- package/dist/components/cms/gate-validation.js +17 -0
- package/dist/components/cms/gate-validation.js.map +1 -0
- package/dist/components/cms/import-export.js +2 -1
- package/dist/components/cms/import-export.js.map +1 -1
- package/dist/components/cms/inline-field-errors.d.ts +8 -0
- package/dist/components/cms/inline-field-errors.js +52 -0
- package/dist/components/cms/inline-field-errors.js.map +1 -0
- package/dist/components/cms/issues-pane.d.ts +4 -2
- package/dist/components/cms/issues-pane.js +13 -7
- package/dist/components/cms/issues-pane.js.map +1 -1
- package/dist/components/cms/slug-collision-report.d.ts +41 -0
- package/dist/components/cms/slug-collision-report.js +52 -0
- package/dist/components/cms/slug-collision-report.js.map +1 -0
- package/dist/components/components/transfer-dialog.js +10 -12
- package/dist/components/components/transfer-dialog.js.map +1 -1
- package/dist/components/deployment-setup-gate.d.ts +24 -0
- package/dist/components/deployment-setup-gate.js +29 -4
- package/dist/components/deployment-setup-gate.js.map +1 -1
- package/dist/components/issue-severity.d.ts +73 -0
- package/dist/components/issue-severity.js +56 -0
- package/dist/components/issue-severity.js.map +1 -0
- package/dist/components/nav-issues.d.ts +68 -0
- package/dist/components/nav-issues.js +78 -0
- package/dist/components/nav-issues.js.map +1 -0
- package/dist/components/operation-error.js +3 -3
- package/dist/components/operation-error.js.map +1 -1
- package/dist/components/project-list.d.ts +9 -3
- package/dist/components/project-list.js +11 -5
- package/dist/components/project-list.js.map +1 -1
- package/dist/components/project-switcher.d.ts +3 -1
- package/dist/components/project-switcher.js +4 -2
- package/dist/components/project-switcher.js.map +1 -1
- package/dist/components/redirects/redirects-store.d.ts +21 -0
- package/dist/components/redirects/redirects-store.js +17 -4
- package/dist/components/redirects/redirects-store.js.map +1 -1
- package/dist/components/section-nav.js +75 -10
- package/dist/components/section-nav.js.map +1 -1
- package/dist/customization.d.ts +7 -0
- package/dist/customization.js.map +1 -1
- package/dist/lib/ai/apply-content.js +6 -7
- package/dist/lib/ai/apply-content.js.map +1 -1
- package/dist/lib/ai/chat-context.d.ts +32 -26
- package/dist/lib/ai/chat-context.js +81 -9
- package/dist/lib/ai/chat-context.js.map +1 -1
- package/dist/lib/ai/constraints.d.ts +42 -0
- package/dist/lib/ai/constraints.js +259 -0
- package/dist/lib/ai/constraints.js.map +1 -0
- package/dist/lib/ai/index.d.ts +2 -0
- package/dist/lib/ai/index.js +2 -0
- package/dist/lib/ai/index.js.map +1 -1
- package/dist/lib/ai/proposal-validation.d.ts +26 -0
- package/dist/lib/ai/proposal-validation.js +17 -0
- package/dist/lib/ai/proposal-validation.js.map +1 -0
- package/dist/lib/ai/shape.js +60 -13
- package/dist/lib/ai/shape.js.map +1 -1
- package/dist/lib/ai/tools.d.ts +8 -4
- package/dist/lib/ai/tools.js +43 -14
- package/dist/lib/ai/tools.js.map +1 -1
- package/dist/lib/ai/translate-document.d.ts +35 -1
- package/dist/lib/ai/translate-document.js +16 -1
- package/dist/lib/ai/translate-document.js.map +1 -1
- package/dist/lib/ai/types.d.ts +11 -6
- package/dist/lib/ai/types.js.map +1 -1
- package/dist/lib/ai/update-fields.d.ts +8 -6
- package/dist/lib/ai/update-fields.js +9 -22
- package/dist/lib/ai/update-fields.js.map +1 -1
- package/dist/lib/clipboard/paste-issues.d.ts +5 -0
- package/dist/lib/clipboard/paste-issues.js +16 -6
- package/dist/lib/clipboard/paste-issues.js.map +1 -1
- package/dist/lib/cms/document-gate.d.ts +37 -0
- package/dist/lib/cms/document-gate.js +40 -0
- package/dist/lib/cms/document-gate.js.map +1 -0
- package/dist/lib/cms-views/view-model.d.ts +5 -0
- package/dist/lib/cms-views/view-model.js +10 -4
- package/dist/lib/cms-views/view-model.js.map +1 -1
- package/dist/lib/data/collection-content.d.ts +6 -1
- package/dist/lib/data/collection-content.js +51 -15
- package/dist/lib/data/collection-content.js.map +1 -1
- package/dist/lib/i18n/i18n.d.ts +51 -16
- package/dist/lib/i18n/i18n.js +119 -43
- package/dist/lib/i18n/i18n.js.map +1 -1
- package/dist/lib/i18n/index.d.ts +8 -1
- package/dist/lib/i18n/index.js +1 -1
- package/dist/lib/i18n/index.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +200 -0
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +162 -0
- package/dist/lib/i18n/messages.en.js +226 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/i18n/types.d.ts +15 -9
- package/dist/lib/i18n/types.js.map +1 -1
- package/dist/lib/locale/binding-fallback.d.ts +8 -1
- package/dist/lib/locale/binding-fallback.js +8 -1
- package/dist/lib/locale/binding-fallback.js.map +1 -1
- package/dist/lib/locale/locale-store.d.ts +41 -9
- package/dist/lib/locale/locale-store.js +34 -15
- package/dist/lib/locale/locale-store.js.map +1 -1
- package/dist/lib/locale/locale-switcher.d.ts +3 -1
- package/dist/lib/locale/locale-switcher.js +4 -2
- package/dist/lib/locale/locale-switcher.js.map +1 -1
- package/dist/lib/locale/use-studio-locale.d.ts +9 -4
- package/dist/lib/locale/use-studio-locale.js.map +1 -1
- package/dist/lib/operations/studio-operations.js +19 -2
- package/dist/lib/operations/studio-operations.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.d.ts +1 -3
- package/dist/lib/persistence/convex-adapter.js +0 -55
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persistence/deployment-readiness.js +10 -0
- package/dist/lib/persistence/deployment-readiness.js.map +1 -1
- package/dist/lib/persona/labels.d.ts +85 -9
- package/dist/lib/persona/labels.js +198 -58
- package/dist/lib/persona/labels.js.map +1 -1
- package/dist/lib/routes/route-issues.d.ts +9 -0
- package/dist/lib/routes/route-issues.js +117 -0
- package/dist/lib/routes/route-issues.js.map +1 -0
- package/dist/lib/webhooks/dispatcher.d.ts +5 -2
- package/dist/lib/webhooks/dispatcher.js.map +1 -1
- package/dist/lib/webhooks/tags.js +150 -17
- package/dist/lib/webhooks/tags.js.map +1 -1
- package/dist/lib/workspace/config-issues.d.ts +56 -0
- package/dist/lib/workspace/config-issues.js +72 -0
- package/dist/lib/workspace/config-issues.js.map +1 -0
- package/dist/lib/workspace/index.d.ts +24 -8
- package/dist/lib/workspace/index.js +28 -18
- package/dist/lib/workspace/index.js.map +1 -1
- package/dist/lib/workspace/switcher-source.d.ts +20 -3
- package/dist/lib/workspace/switcher-source.js.map +1 -1
- package/dist/lib/workspace/workspace-source.js +27 -0
- package/dist/lib/workspace/workspace-source.js.map +1 -1
- package/dist/previews/manifest-format.d.ts +13 -6
- package/dist/previews/manifest-format.js +9 -2
- package/dist/previews/manifest-format.js.map +1 -1
- package/dist/router/root.d.ts +36 -1
- package/dist/router/root.js +54 -7
- package/dist/router/root.js.map +1 -1
- package/package.json +2 -2
- package/convex/cliTokens.ts +0 -63
- package/convex/sync.ts +0 -297
|
@@ -225,7 +225,7 @@ describe('guard capability gates through the real functions (EC-150/EC-240c, Con
|
|
|
225
225
|
await expect(
|
|
226
226
|
viewer.cms.upsertDocument(PROJECT_ID, { collection: 'post', id: 'x', values: {} }),
|
|
227
227
|
).rejects.toThrow(/unauthorized/) // content.write denied
|
|
228
|
-
await expect(viewer.
|
|
228
|
+
await expect(viewer.users.listUsers(PROJECT_ID)).rejects.toThrow(/unauthorized/) // project.administer denied
|
|
229
229
|
|
|
230
230
|
// An authenticated NON-member is rejected even on reads …
|
|
231
231
|
await expect(forSubject('user_stranger').cms.listDocuments(PROJECT_ID)).rejects.toThrow(
|
package/convex/functions.ts
CHANGED
|
@@ -52,8 +52,18 @@ export const internalMutation = customMutation(
|
|
|
52
52
|
internalMutationGeneric as MutationBuilder<DataModel, 'internal'>,
|
|
53
53
|
customCtx(triggers.wrapDB),
|
|
54
54
|
)
|
|
55
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Server-only reads — not callable by clients. The mirror of `internalMutation`,
|
|
57
|
+
* kept as the symmetric half of the codegen-replacement builder set and as the
|
|
58
|
+
* builder a consumer reaches for when adding its own internal query.
|
|
59
|
+
* EC-481: the engine itself no longer declares one (its only user was the retired
|
|
60
|
+
* registry-sync snapshot).
|
|
61
|
+
*/
|
|
56
62
|
export const internalQuery = internalQueryGeneric as QueryBuilder<DataModel, 'internal'>
|
|
57
|
-
/**
|
|
58
|
-
* DataModel-bound: an action reaches data only
|
|
63
|
+
/**
|
|
64
|
+
* HTTP action builder (EC-252). Not DataModel-bound: an action reaches data only
|
|
65
|
+
* through `ctx.runQuery`/`runMutation`.
|
|
66
|
+
* EC-481: the engine declares no HTTP action of its own any more (`http.ts` only
|
|
67
|
+
* mounts the consumer's auth routes); this stays for consumer-defined actions.
|
|
68
|
+
*/
|
|
59
69
|
export const httpAction = httpActionGeneric
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { httpRouter } from 'convex/server'
|
|
3
|
+
import type { HttpRouter, PublicHttpAction } from 'convex/server'
|
|
4
|
+
import { defineStudioHttp } from './http'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* EC-481 — the studio's HTTP route table, pinned.
|
|
8
|
+
*
|
|
9
|
+
* `defineStudioHttp` used to add `GET /api/projects/*` (the EC-157 registry-sync
|
|
10
|
+
* endpoint). It was Bearer-authenticated, reachable on every deployment, and — after
|
|
11
|
+
* EC-470 deleted the `builder` CLI — had no client at all. Removing it is only half
|
|
12
|
+
* the job: the failure mode this file exists to prevent is a future edit quietly
|
|
13
|
+
* mounting a route again, or re-claiming that very broad prefix. Neither would break
|
|
14
|
+
* anything, which is exactly why nothing would catch it.
|
|
15
|
+
*
|
|
16
|
+
* The `auth` seam is injected (`{ addHttpRoutes }`), so these assertions isolate what
|
|
17
|
+
* the ENGINE contributes from what Convex Auth contributes — the real question. Every
|
|
18
|
+
* detector below is paired with a positive control (`AGENTS.md` §1.5): a recorder that
|
|
19
|
+
* observes nothing is indistinguishable from a router that registers nothing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** An `auth` double that registers nothing — isolates the engine's own contribution. */
|
|
23
|
+
const silentAuth = { addHttpRoutes: () => {} }
|
|
24
|
+
|
|
25
|
+
/** A stand-in handler; the router only stores it. */
|
|
26
|
+
const handler = (() => {}) as unknown as PublicHttpAction
|
|
27
|
+
|
|
28
|
+
/** The route table as stable `"<METHOD> <path>"` strings, sorted. */
|
|
29
|
+
function routeTable(http: HttpRouter): string[] {
|
|
30
|
+
return http
|
|
31
|
+
.getRoutes()
|
|
32
|
+
.map(([path, method]) => `${method} ${path}`)
|
|
33
|
+
.sort()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('defineStudioHttp — the engine registers no HTTP routes of its own (EC-481)', () => {
|
|
37
|
+
it('contributes an EMPTY route table beyond what the injected auth registers', () => {
|
|
38
|
+
expect(routeTable(defineStudioHttp({ auth: silentAuth }))).toEqual([])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('control: the recorder DOES see routes, so the empty result above is real', () => {
|
|
42
|
+
const auth = {
|
|
43
|
+
addHttpRoutes: (http: HttpRouter) => {
|
|
44
|
+
http.route({ path: '/.well-known/jwks.json', method: 'GET', handler })
|
|
45
|
+
http.route({ pathPrefix: '/api/auth/callback/', method: 'POST', handler })
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
expect(routeTable(defineStudioHttp({ auth }))).toEqual([
|
|
49
|
+
'GET /.well-known/jwks.json',
|
|
50
|
+
'POST /api/auth/callback/*',
|
|
51
|
+
])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('passes the auth routes through untouched — nothing is added, dropped, or reordered', () => {
|
|
55
|
+
const registered: string[] = []
|
|
56
|
+
const auth = {
|
|
57
|
+
addHttpRoutes: (http: HttpRouter) => {
|
|
58
|
+
for (const path of ['/.well-known/openid-configuration', '/.well-known/jwks.json']) {
|
|
59
|
+
http.route({ path, method: 'GET', handler })
|
|
60
|
+
registered.push(`GET ${path}`)
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
expect(routeTable(defineStudioHttp({ auth }))).toEqual([...registered].sort())
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('the /api/projects/ prefix is released (EC-481)', () => {
|
|
69
|
+
it('routes NOTHING under /api/projects/ — the request falls through to Convex 404', () => {
|
|
70
|
+
const http = defineStudioHttp({ auth: silentAuth })
|
|
71
|
+
// `lookup` is exactly what Convex's `runRequest` consults; `null` is the branch
|
|
72
|
+
// that answers `No HttpAction routed for <path>`.
|
|
73
|
+
expect(http.lookup('/api/projects/demo/registry-sync/snapshot', 'GET')).toBeNull()
|
|
74
|
+
expect(http.lookup('/api/projects/', 'GET')).toBeNull()
|
|
75
|
+
expect(http.lookup('/api/projects/anything-at-all', 'GET')).toBeNull()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('control: the same lookup DOES resolve when something claims the prefix', () => {
|
|
79
|
+
const auth = {
|
|
80
|
+
addHttpRoutes: (http: HttpRouter) => {
|
|
81
|
+
http.route({ pathPrefix: '/api/projects/', method: 'GET', handler })
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
const http = defineStudioHttp({ auth })
|
|
85
|
+
expect(http.lookup('/api/projects/demo/registry-sync/snapshot', 'GET')).not.toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('no route the engine mounts may claim a prefix that swallows /api/projects/', () => {
|
|
89
|
+
const claims = routeTable(defineStudioHttp({ auth: silentAuth })).filter((route) =>
|
|
90
|
+
route.includes('/api/projects'),
|
|
91
|
+
)
|
|
92
|
+
expect(claims).toEqual([])
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('auth routes are unaffected by the removal (EC-481)', () => {
|
|
97
|
+
it('leaves the /api/auth/* prefixes matching exactly as before', () => {
|
|
98
|
+
// The removed route was `/api/projects/`, which is neither a prefix of nor
|
|
99
|
+
// prefixed by any auth path — so Convex's longest-prefix-wins ordering cannot
|
|
100
|
+
// have changed for them. Asserted rather than argued.
|
|
101
|
+
const auth = {
|
|
102
|
+
addHttpRoutes: (http: HttpRouter) => {
|
|
103
|
+
http.route({ pathPrefix: '/api/auth/signin/', method: 'GET', handler })
|
|
104
|
+
http.route({ pathPrefix: '/api/auth/callback/', method: 'GET', handler })
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
const http = defineStudioHttp({ auth })
|
|
108
|
+
expect(http.lookup('/api/auth/signin/github', 'GET')?.[2]).toBe('/api/auth/signin/*')
|
|
109
|
+
expect(http.lookup('/api/auth/callback/github', 'GET')?.[2]).toBe('/api/auth/callback/*')
|
|
110
|
+
// …and the released prefix still resolves to nothing alongside them.
|
|
111
|
+
expect(http.lookup('/api/projects/demo', 'GET')).toBeNull()
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('the registry-sync and CLI-token modules are gone, not merely unrouted (EC-481/EC-485)', () => {
|
|
116
|
+
// `import.meta.glob` is the module-enumeration pattern this directory already uses
|
|
117
|
+
// (`contract-parity.test.ts`). A literal `import('./sync')` cannot be used here: TS
|
|
118
|
+
// fails to *typecheck* a specifier that no longer resolves, so the assertion could
|
|
119
|
+
// never compile — the glob asks the same question without naming a missing module.
|
|
120
|
+
const convexModules = Object.keys(import.meta.glob('./*.ts'))
|
|
121
|
+
|
|
122
|
+
it('ships no `sync` module — a route can only come back from a handler that exists', () => {
|
|
123
|
+
expect(convexModules).not.toContain('./sync.ts')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('ships no `cliTokens` module — the whole CLI-token path was retired (EC-485)', () => {
|
|
127
|
+
// The registry-sync endpoint was its only verifier (EC-481); with that gone the
|
|
128
|
+
// issuance path was inert and `cliTokens:byHash` was a public, unauthenticated
|
|
129
|
+
// query with an expired justification. Nothing addresses these functions any
|
|
130
|
+
// more — `ConvexCliTokenRepository` went with them, in the same change, because
|
|
131
|
+
// it named them by STRING and a half-rip would typecheck and fail at runtime.
|
|
132
|
+
expect(convexModules).not.toContain('./cliTokens.ts')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('control: the glob DOES see this directory, so the absences above are real', () => {
|
|
136
|
+
expect(convexModules).toContain('./http.ts')
|
|
137
|
+
expect(convexModules).toContain('./users.ts')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('control: `httpRouter()` itself is unchanged — an empty router really is empty', () => {
|
|
141
|
+
expect(routeTable(httpRouter())).toEqual([])
|
|
142
|
+
})
|
|
143
|
+
})
|
package/convex/http.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { httpRouter } from 'convex/server'
|
|
2
2
|
import type { HttpRouter } from 'convex/server'
|
|
3
|
-
import { registrySync } from './sync'
|
|
4
3
|
|
|
5
4
|
/** The slice of the Convex Auth object the studio's HTTP routes need. */
|
|
6
5
|
interface AuthHttpRoutes {
|
|
@@ -8,23 +7,34 @@ interface AuthHttpRoutes {
|
|
|
8
7
|
}
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
|
-
* EC-252 — the studio's HTTP routes as a factory.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* EC-252 — the studio's HTTP routes as a factory. Pure engine infra ships in the
|
|
11
|
+
* package; only the consumer's `auth` (provider choice) is injected. The consumer's
|
|
12
|
+
* `convex/http.ts` is a two-liner:
|
|
14
13
|
*
|
|
15
14
|
* ```ts
|
|
16
15
|
* import { auth } from './auth'
|
|
17
16
|
* export default defineStudioHttp({ auth })
|
|
18
17
|
* ```
|
|
19
18
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
19
|
+
* ## The engine registers NO HTTP routes of its own (EC-481)
|
|
20
|
+
*
|
|
21
|
+
* Everything on this router comes from Convex Auth's `addHttpRoutes` — the JWT
|
|
22
|
+
* discovery endpoints (`/.well-known/*`, EC-150) and, only when a consumer supplies
|
|
23
|
+
* an OAuth provider, `/api/auth/signin/*` + `/api/auth/callback/*`.
|
|
24
|
+
*
|
|
25
|
+
* The engine used to add one more: `GET /api/projects/*`, the EC-157 Bearer-token
|
|
26
|
+
* registry-sync endpoint backing `builder typegen`/`builder check`. EC-470 deleted
|
|
27
|
+
* that CLI and with it the endpoint's only client; the handler had in any case
|
|
28
|
+
* answered with config-owned placeholders since EC-287 retired the project graph
|
|
29
|
+
* (`project: null`, `collections: []`). EC-481 removed the route and its handler
|
|
30
|
+
* together rather than leave a Bearer-authenticated surface serving stub data on
|
|
31
|
+
* every deployment. `http-routes.test.ts` pins that shut.
|
|
32
|
+
*
|
|
33
|
+
* A future engine-owned HTTP surface is fine — but it needs a real client and a
|
|
34
|
+
* narrower prefix than `/api/projects/`.
|
|
24
35
|
*/
|
|
25
36
|
export function defineStudioHttp({ auth }: { auth: AuthHttpRoutes }): HttpRouter {
|
|
26
37
|
const http = httpRouter()
|
|
27
38
|
auth.addHttpRoutes(http)
|
|
28
|
-
http.route({ pathPrefix: '/api/projects/', method: 'GET', handler: registrySync })
|
|
29
39
|
return http
|
|
30
40
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { convexTest } from 'convex-test'
|
|
3
|
+
import { makeFunctionReference } from 'convex/server'
|
|
4
|
+
import schema from './schema'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* EC-510 — the Convex half of "a redirect change must reach the live site".
|
|
8
|
+
*
|
|
9
|
+
* The browser dispatcher (`src/lib/webhooks`) is one emitter; the EC-286 write
|
|
10
|
+
* chokepoint is the other, and it is the one that catches writers the studio
|
|
11
|
+
* does not own. Before this ticket a redirect write reached the host by
|
|
12
|
+
* NEITHER, while a document publish reached it by both.
|
|
13
|
+
*
|
|
14
|
+
* This runs the REAL `redirects.save`/`redirects.remove` mutations through the
|
|
15
|
+
* REAL `functions.ts` trigger wrapper on `convex-test`'s in-memory backend, and
|
|
16
|
+
* asserts on the `_scheduled_functions` system table — the observable
|
|
17
|
+
* `scheduleRevalidate` actually produces (a mutation cannot `fetch`, so it hands
|
|
18
|
+
* the signed POST to the `webhooks:deliver` action).
|
|
19
|
+
*
|
|
20
|
+
* The env var is the gate `scheduleRevalidate` reads: an unconfigured
|
|
21
|
+
* deployment must stay a silent no-op, because publishing may never depend on a
|
|
22
|
+
* reachable host. Both sides are pinned here so neither can regress into the
|
|
23
|
+
* other.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const modules = {
|
|
27
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
28
|
+
'./_generated/api.ts': async () => ({}),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// EC-223: the studio's convex functions address each other by STRING ref, never
|
|
32
|
+
// a per-deployment `_generated/api` — the same addressing the tests use.
|
|
33
|
+
const saveRedirect = makeFunctionReference<'mutation'>('redirects:save')
|
|
34
|
+
const removeRedirect = makeFunctionReference<'mutation'>('redirects:remove')
|
|
35
|
+
|
|
36
|
+
const SESSION_USER_ID = 'user_admin'
|
|
37
|
+
const SCOPE = 'ec510-scope'
|
|
38
|
+
|
|
39
|
+
async function freshBackend() {
|
|
40
|
+
const t = convexTest(schema, modules)
|
|
41
|
+
const now = new Date().toISOString()
|
|
42
|
+
await t.run(async (ctx) => {
|
|
43
|
+
await ctx.db.insert('members', {
|
|
44
|
+
userId: SESSION_USER_ID,
|
|
45
|
+
role: 'admin',
|
|
46
|
+
createdAt: now,
|
|
47
|
+
updatedAt: now,
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
return t
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The scheduled `webhooks:deliver` calls, in order, as `{event, tags}`. */
|
|
54
|
+
async function scheduledRevalidations(
|
|
55
|
+
t: Awaited<ReturnType<typeof freshBackend>>,
|
|
56
|
+
): Promise<{ event: unknown; tags: unknown }[]> {
|
|
57
|
+
return t.run(async (ctx) => {
|
|
58
|
+
const rows = await ctx.db.system.query('_scheduled_functions').collect()
|
|
59
|
+
return rows
|
|
60
|
+
.filter((row) => row.name.startsWith('webhooks'))
|
|
61
|
+
.map((row) => {
|
|
62
|
+
const [args] = row.args as [{ event?: unknown; tags?: unknown }]
|
|
63
|
+
return { event: args?.event, tags: args?.tags }
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function withScope<T>(scope: string | undefined, run: () => Promise<T>): Promise<T> {
|
|
69
|
+
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process
|
|
70
|
+
?.env
|
|
71
|
+
const previous = env?.['CANVAS_PROJECT_SCOPE']
|
|
72
|
+
if (env) {
|
|
73
|
+
if (scope === undefined) delete env['CANVAS_PROJECT_SCOPE']
|
|
74
|
+
else env['CANVAS_PROJECT_SCOPE'] = scope
|
|
75
|
+
}
|
|
76
|
+
return run().finally(() => {
|
|
77
|
+
if (!env) return
|
|
78
|
+
if (previous === undefined) delete env['CANVAS_PROJECT_SCOPE']
|
|
79
|
+
else env['CANVAS_PROJECT_SCOPE'] = previous
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const redirectArgs = {
|
|
84
|
+
redirectId: 'redirect_ec510_1',
|
|
85
|
+
from: '/old-page',
|
|
86
|
+
to: '/blog/hello',
|
|
87
|
+
permanent: true,
|
|
88
|
+
enabled: true,
|
|
89
|
+
createdAt: '2026-08-01T00:00:00.000Z',
|
|
90
|
+
updatedAt: '2026-08-01T00:00:00.000Z',
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('EC-510 — the write chokepoint revalidates on every redirect write', () => {
|
|
94
|
+
it('schedules a project sweep on create, retarget, disable, and delete', async () => {
|
|
95
|
+
await withScope(SCOPE, async () => {
|
|
96
|
+
const t = await freshBackend()
|
|
97
|
+
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
98
|
+
|
|
99
|
+
await asAdmin.mutation(saveRedirect, redirectArgs)
|
|
100
|
+
await asAdmin.mutation(saveRedirect, { ...redirectArgs, to: '/blog/second' })
|
|
101
|
+
// Pausing a redirect stops it serving (`delivery.ts` keeps only `enabled`
|
|
102
|
+
// rows), so it is as much a routing change as a delete.
|
|
103
|
+
await asAdmin.mutation(saveRedirect, { ...redirectArgs, enabled: false })
|
|
104
|
+
await asAdmin.mutation(removeRedirect, { redirectId: redirectArgs.redirectId })
|
|
105
|
+
|
|
106
|
+
expect(await scheduledRevalidations(t)).toEqual([
|
|
107
|
+
{ event: 'redirects.save', tags: [`p:${SCOPE}:project`] },
|
|
108
|
+
{ event: 'redirects.save', tags: [`p:${SCOPE}:project`] },
|
|
109
|
+
{ event: 'redirects.save', tags: [`p:${SCOPE}:project`] },
|
|
110
|
+
{ event: 'redirects.remove', tags: [`p:${SCOPE}:project`] },
|
|
111
|
+
])
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('stays a silent no-op on a deployment with no CANVAS_PROJECT_SCOPE', async () => {
|
|
116
|
+
await withScope(undefined, async () => {
|
|
117
|
+
const t = await freshBackend()
|
|
118
|
+
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
119
|
+
await asAdmin.mutation(saveRedirect, redirectArgs)
|
|
120
|
+
// The known-positive control is the test above: the same call on the same
|
|
121
|
+
// harness DOES schedule when the scope is configured, so this empty result
|
|
122
|
+
// is the gate working rather than the instrument being blind.
|
|
123
|
+
expect(await scheduledRevalidations(t)).toEqual([])
|
|
124
|
+
// The write itself still happened — silence is at the webhook, not the save.
|
|
125
|
+
expect(
|
|
126
|
+
await t.run(async (ctx) => (await ctx.db.query('redirects').collect()).length),
|
|
127
|
+
).toBe(1)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
})
|
package/convex/revalidate.ts
CHANGED
|
@@ -19,15 +19,17 @@ const webhooksDeliver = makeFunctionReference<'action'>('webhooks:deliver')
|
|
|
19
19
|
* var. An unconfigured deployment (local dev, no env) is a silent no-op —
|
|
20
20
|
* publishing must never depend on a reachable host.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* Called from the EC-286 write-chokepoint triggers only (`triggers.ts`), so
|
|
23
|
+
* every writer of a live-wire table is covered by construction: `cmsDocuments`
|
|
24
|
+
* when the published pin moves, and `redirects` on any write (EC-510 — a
|
|
25
|
+
* saved+enabled redirect IS the live state, with no draft/published split).
|
|
26
26
|
*
|
|
27
27
|
* Doc/collection-scoped tags (matching the client dispatcher's
|
|
28
|
-
* `webhookTagsForEvent`) would be more surgical, but need the
|
|
29
|
-
* not available in the Convex env here. The sweep is the
|
|
30
|
-
* threading scoped tags is a precision follow-up.
|
|
28
|
+
* `webhookTagsForEvent`) would be more surgical for DOCUMENTS, but need the
|
|
29
|
+
* project locales — not available in the Convex env here. The sweep is the
|
|
30
|
+
* honest, reliable v1; threading scoped tags is a precision follow-up. For
|
|
31
|
+
* redirects the sweep is not a compromise but the correct answer — see
|
|
32
|
+
* `ROUTING_OPERATIONS` in `src/lib/webhooks/tags.ts`.
|
|
31
33
|
*/
|
|
32
34
|
export async function scheduleRevalidate(ctx: MutationCtx, event: string): Promise<void> {
|
|
33
35
|
const env =
|
package/convex/schema.ts
CHANGED
|
@@ -109,25 +109,6 @@ export default defineSchema({
|
|
|
109
109
|
at: v.string(),
|
|
110
110
|
}).index('by_time', ['at']),
|
|
111
111
|
|
|
112
|
-
/**
|
|
113
|
-
* Revocable CLI sync tokens (EC-150, consumed by the EC-147 push CLI).
|
|
114
|
-
* Only the SHA-256 hash of a token is ever stored; revoked tokens keep
|
|
115
|
-
* their row (with `revokedAt`) for auditability.
|
|
116
|
-
*
|
|
117
|
-
* Single-tenant (EC-182/EC-183): a deployment hosts one project, so a token
|
|
118
|
-
* carries no per-project scope — `projectId` is gone (it was `optional`
|
|
119
|
-
* through EC-182; this drops the field now that both deployments are migrated).
|
|
120
|
-
*/
|
|
121
|
-
cliTokens: defineTable({
|
|
122
|
-
tokenId: v.string(),
|
|
123
|
-
label: v.string(),
|
|
124
|
-
tokenHash: v.string(),
|
|
125
|
-
createdAt: v.string(),
|
|
126
|
-
revokedAt: v.optional(v.string()),
|
|
127
|
-
})
|
|
128
|
-
.index('by_token', ['tokenId'])
|
|
129
|
-
.index('by_hash', ['tokenHash']),
|
|
130
|
-
|
|
131
112
|
/**
|
|
132
113
|
* CMS documents (EC-043). The stored `document` is the continuous working
|
|
133
114
|
* **draft**; `publishedVersion` pins which `documentVersions` row is live
|
package/convex/triggers.ts
CHANGED
|
@@ -21,9 +21,11 @@ import { scheduleRevalidate } from './revalidate'
|
|
|
21
21
|
* via `customMutation(…, customCtx(triggers.wrapDB))`. `referenceEntries` has no
|
|
22
22
|
* trigger, so the index writes these derivations perform never recurse.
|
|
23
23
|
*
|
|
24
|
-
* EC-287 retired the DB-resident project graph and its publish path, so
|
|
25
|
-
*
|
|
26
|
-
* gone at the source
|
|
24
|
+
* EC-287 retired the DB-resident project graph and its publish path, so
|
|
25
|
+
* `cmsDocuments` is the SINGLE document trigger — one publish path, the
|
|
26
|
+
* dual-path footgun gone at the source. EC-510 added `redirects`, the other
|
|
27
|
+
* table whose rows go straight onto the live wire; both are registered here so
|
|
28
|
+
* "which writes reach the host" has one place to read.
|
|
27
29
|
*/
|
|
28
30
|
export const triggers = new Triggers<DataModel>()
|
|
29
31
|
|
|
@@ -62,3 +64,31 @@ triggers.register('cmsDocuments', async (ctx, change) => {
|
|
|
62
64
|
await scheduleRevalidate(ctx, after === undefined ? 'documents.unpublish' : 'documents.publish')
|
|
63
65
|
}
|
|
64
66
|
})
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Editorial redirects (EC-303 → EC-510). Every *enabled* redirect row is
|
|
70
|
+
* projected into the delivery snapshot the router and `resolvePage` consume, in
|
|
71
|
+
* BOTH perspectives (`delivery.ts`), so any write here changes published output
|
|
72
|
+
* — and unlike a document there is no draft/published split to gate on: the
|
|
73
|
+
* write IS the live state.
|
|
74
|
+
*
|
|
75
|
+
* Why this exists at all (EC-510): the studio's browser dispatcher
|
|
76
|
+
* (`src/lib/webhooks`) never carried the redirect operations either, so before
|
|
77
|
+
* this a redirect change reached the host by **no** path while a document
|
|
78
|
+
* publish reached it by two — measured 2026-08-01, five redirect writes on a
|
|
79
|
+
* connected studio delivered zero webhooks, and a production host kept serving
|
|
80
|
+
* the routing it had cached before the redirect existed. This trigger is the
|
|
81
|
+
* half that catches every writer by construction (EC-286): a CLI import, a
|
|
82
|
+
* future automation API, or any new mutation that forgets to call a helper.
|
|
83
|
+
*
|
|
84
|
+
* The tag is `scheduleRevalidate`'s whole-project sweep, which is also the right
|
|
85
|
+
* granularity here rather than merely the available one — `ROUTING_OPERATIONS`
|
|
86
|
+
* in `src/lib/webhooks/tags.ts` records why a targeted `route:<from>:<locale>`
|
|
87
|
+
* tag cannot be correct. Every write fires, including a save that changed
|
|
88
|
+
* nothing: the trigger cannot cheaply prove a no-op, and a redirect save is an
|
|
89
|
+
* explicit editorial action, not a keystroke (EC-501's cost argument does not
|
|
90
|
+
* transfer).
|
|
91
|
+
*/
|
|
92
|
+
triggers.register('redirects', async (ctx, change) => {
|
|
93
|
+
await scheduleRevalidate(ctx, change.operation === 'delete' ? 'redirects.remove' : 'redirects.save')
|
|
94
|
+
})
|
package/convex/users.ts
CHANGED
|
@@ -42,7 +42,7 @@ export const me = query({
|
|
|
42
42
|
* onto `@elytracms/core/persistence` `UserRecord`s.
|
|
43
43
|
*
|
|
44
44
|
* Admin-only, server-side: the list exposes every member's email + role — an
|
|
45
|
-
* administration concern (like `
|
|
45
|
+
* administration concern (like `invites:list`), not a content read. UI gating
|
|
46
46
|
* alone is never trusted.
|
|
47
47
|
*/
|
|
48
48
|
|
|
@@ -9,10 +9,9 @@ import { translate } from '@elytracms/studio/lib/i18n';
|
|
|
9
9
|
import { STUDIO_PROJECT_ROUTE, studioPath, useNavigate } from '@elytracms/studio/router';
|
|
10
10
|
import { resolveDeploymentAi } from '@elytracms/studio/lib/workspace';
|
|
11
11
|
import { getStudioOperations } from '@elytracms/studio/lib/operations';
|
|
12
|
-
import { DEFAULT_MAX_STEPS, buildChatContext, chatReviewReducer, clearConversation, createChatCandidateStore, createChatToolExecutor, initialChatReviewState, renderCurrentDocumentSection, resolveAiConfig, resolveChatTarget, resolveKnowledge, withStepCap, } from '@elytracms/studio/lib/ai';
|
|
12
|
+
import { DEFAULT_MAX_STEPS, buildChatContext, chatReviewReducer, clearConversation, createChatCandidateStore, createChatToolExecutor, initialChatReviewState, introducedErrors, renderCurrentDocumentSection, resolveAiConfig, resolveChatTarget, resolveKnowledge, withStepCap, } from '@elytracms/studio/lib/ai';
|
|
13
13
|
import { getAiCurrentDocument } from '@elytracms/studio/lib/ai-current-document';
|
|
14
14
|
import { useCms } from '../cms/cms-store.js';
|
|
15
|
-
import { validateComposition, validateRichTextEmbeds } from '../cms/cms-composition-validators.js';
|
|
16
15
|
import { AssistantTurn, ChatErrorNote, Conversation, ConversationEmpty, PromptInput, ToolStatus, UserTurn, WorkingIndicator, describeToolPart, messageText, segmentMessageParts, } from './chat-ui.js';
|
|
17
16
|
import { ProposalCard } from './proposal-card.js';
|
|
18
17
|
import { setAiPanelOpen } from '@elytracms/studio/lib/ai-panel';
|
|
@@ -202,8 +201,11 @@ export function AiChatPanel() {
|
|
|
202
201
|
currentDocument: () => getAiCurrentDocument(),
|
|
203
202
|
locale: activeLocale,
|
|
204
203
|
...(cms.localeConfig ? { localeConfig: cms.localeConfig } : {}),
|
|
205
|
-
|
|
206
|
-
|
|
204
|
+
// EC-473 established that the assistant must validate with the EDITOR's
|
|
205
|
+
// rules, not a subset. EC-497 hands over THE bag itself — one value, built
|
|
206
|
+
// once by `gateValidateOptions` — so the assistant's gate and the editor's
|
|
207
|
+
// gate cannot drift by someone forgetting a key at a call site.
|
|
208
|
+
validateOptions: cms.validateOptions,
|
|
207
209
|
...(maxRepairAttempts !== undefined ? { maxRepairAttempts } : {}),
|
|
208
210
|
});
|
|
209
211
|
}, [
|
|
@@ -211,6 +213,7 @@ export function AiChatPanel() {
|
|
|
211
213
|
registry,
|
|
212
214
|
cms.collections,
|
|
213
215
|
cms.localeConfig,
|
|
216
|
+
cms.validateOptions,
|
|
214
217
|
readers,
|
|
215
218
|
mintDocumentId,
|
|
216
219
|
activeLocale,
|
|
@@ -288,6 +291,25 @@ export function AiChatPanel() {
|
|
|
288
291
|
values: proposal.patch.values,
|
|
289
292
|
...(proposal.patch.localized ? { localized: proposal.patch.localized } : {}),
|
|
290
293
|
};
|
|
294
|
+
// EC-473 — re-validate BEFORE the write. The proposal was validated when it was
|
|
295
|
+
// built, but the editor may have changed the document since (or applied an
|
|
296
|
+
// earlier proposal on top of it), so the patch is re-judged against what is in
|
|
297
|
+
// the session NOW. Same gate as every other AI write path: only errors the
|
|
298
|
+
// apply would INTRODUCE block it — pre-existing brokenness on the document is
|
|
299
|
+
// not the assistant's doing and must not make an unrelated change unappliable.
|
|
300
|
+
const introduced = introducedErrors(existing ? cms.validate(existing) : [], cms.validate(doc));
|
|
301
|
+
if (introduced.length > 0) {
|
|
302
|
+
dispatch({
|
|
303
|
+
type: 'fail',
|
|
304
|
+
message: translate('ai.error.applyInvalid', {
|
|
305
|
+
issues: introduced
|
|
306
|
+
.slice(0, 3)
|
|
307
|
+
.map((issue) => issue.message)
|
|
308
|
+
.join('; '),
|
|
309
|
+
}),
|
|
310
|
+
});
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
291
313
|
const { operations } = await getStudioOperations();
|
|
292
314
|
const upserted = await operations.documents.upsert({ projectId, document: doc });
|
|
293
315
|
if (!upserted.ok) {
|