@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.
Files changed (196) hide show
  1. package/convex/contract-parity.test.ts +1 -1
  2. package/convex/functions.ts +13 -3
  3. package/convex/http-routes.test.ts +143 -0
  4. package/convex/http.ts +19 -9
  5. package/convex/redirect-revalidate.test.ts +130 -0
  6. package/convex/revalidate.ts +9 -7
  7. package/convex/schema.ts +0 -19
  8. package/convex/triggers.ts +33 -3
  9. package/convex/users.ts +1 -1
  10. package/dist/components/ai/ai-chat-panel.js +26 -4
  11. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  12. package/dist/components/ai/proposal-card.d.ts +27 -0
  13. package/dist/components/ai/proposal-card.js +31 -3
  14. package/dist/components/ai/proposal-card.js.map +1 -1
  15. package/dist/components/ai/proposal-issues.d.ts +55 -0
  16. package/dist/components/ai/proposal-issues.js +69 -0
  17. package/dist/components/ai/proposal-issues.js.map +1 -0
  18. package/dist/components/ai/translate-document-dialog.js +38 -3
  19. package/dist/components/ai/translate-document-dialog.js.map +1 -1
  20. package/dist/components/app-views/default-modules.d.ts +141 -44
  21. package/dist/components/app-views/default-modules.js +229 -65
  22. package/dist/components/app-views/default-modules.js.map +1 -1
  23. package/dist/components/app-views/module.d.ts +38 -0
  24. package/dist/components/app-views/module.js.map +1 -1
  25. package/dist/components/app-views/sidebar.d.ts +5 -2
  26. package/dist/components/app-views/sidebar.js +16 -5
  27. package/dist/components/app-views/sidebar.js.map +1 -1
  28. package/dist/components/app-views/studio-config.d.ts +28 -1
  29. package/dist/components/app-views/studio-config.js +1 -1
  30. package/dist/components/app-views/studio-config.js.map +1 -1
  31. package/dist/components/builder/canvas-render-context.js +10 -2
  32. package/dist/components/builder/canvas-render-context.js.map +1 -1
  33. package/dist/components/builder/clipboard-actions.js +5 -3
  34. package/dist/components/builder/clipboard-actions.js.map +1 -1
  35. package/dist/components/builder/reuse-context.js +7 -1
  36. package/dist/components/builder/reuse-context.js.map +1 -1
  37. package/dist/components/builder/reuse.d.ts +94 -16
  38. package/dist/components/builder/reuse.js +201 -19
  39. package/dist/components/builder/reuse.js.map +1 -1
  40. package/dist/components/cms/bulk-actions.js +10 -5
  41. package/dist/components/cms/bulk-actions.js.map +1 -1
  42. package/dist/components/cms/cell-edit.d.ts +18 -3
  43. package/dist/components/cms/cell-edit.js +29 -7
  44. package/dist/components/cms/cell-edit.js.map +1 -1
  45. package/dist/components/cms/cms-store.d.ts +54 -14
  46. package/dist/components/cms/cms-store.js +142 -45
  47. package/dist/components/cms/cms-store.js.map +1 -1
  48. package/dist/components/cms/collection-table.js +2 -2
  49. package/dist/components/cms/collection-table.js.map +1 -1
  50. package/dist/components/cms/detail-editor.js +15 -14
  51. package/dist/components/cms/detail-editor.js.map +1 -1
  52. package/dist/components/cms/document-editor-context.d.ts +12 -1
  53. package/dist/components/cms/document-editor-context.js.map +1 -1
  54. package/dist/components/cms/field-control.js +22 -8
  55. package/dist/components/cms/field-control.js.map +1 -1
  56. package/dist/components/cms/field-renderer.js +8 -1
  57. package/dist/components/cms/field-renderer.js.map +1 -1
  58. package/dist/components/cms/gate-validation.d.ts +36 -0
  59. package/dist/components/cms/gate-validation.js +17 -0
  60. package/dist/components/cms/gate-validation.js.map +1 -0
  61. package/dist/components/cms/import-export.js +2 -1
  62. package/dist/components/cms/import-export.js.map +1 -1
  63. package/dist/components/cms/inline-field-errors.d.ts +8 -0
  64. package/dist/components/cms/inline-field-errors.js +52 -0
  65. package/dist/components/cms/inline-field-errors.js.map +1 -0
  66. package/dist/components/cms/issues-pane.d.ts +4 -2
  67. package/dist/components/cms/issues-pane.js +13 -7
  68. package/dist/components/cms/issues-pane.js.map +1 -1
  69. package/dist/components/cms/slug-collision-report.d.ts +41 -0
  70. package/dist/components/cms/slug-collision-report.js +52 -0
  71. package/dist/components/cms/slug-collision-report.js.map +1 -0
  72. package/dist/components/components/transfer-dialog.js +10 -12
  73. package/dist/components/components/transfer-dialog.js.map +1 -1
  74. package/dist/components/deployment-setup-gate.d.ts +24 -0
  75. package/dist/components/deployment-setup-gate.js +29 -4
  76. package/dist/components/deployment-setup-gate.js.map +1 -1
  77. package/dist/components/issue-severity.d.ts +73 -0
  78. package/dist/components/issue-severity.js +56 -0
  79. package/dist/components/issue-severity.js.map +1 -0
  80. package/dist/components/nav-issues.d.ts +68 -0
  81. package/dist/components/nav-issues.js +78 -0
  82. package/dist/components/nav-issues.js.map +1 -0
  83. package/dist/components/operation-error.js +3 -3
  84. package/dist/components/operation-error.js.map +1 -1
  85. package/dist/components/project-list.d.ts +9 -3
  86. package/dist/components/project-list.js +11 -5
  87. package/dist/components/project-list.js.map +1 -1
  88. package/dist/components/project-switcher.d.ts +3 -1
  89. package/dist/components/project-switcher.js +4 -2
  90. package/dist/components/project-switcher.js.map +1 -1
  91. package/dist/components/redirects/redirects-store.d.ts +21 -0
  92. package/dist/components/redirects/redirects-store.js +17 -4
  93. package/dist/components/redirects/redirects-store.js.map +1 -1
  94. package/dist/components/section-nav.js +75 -10
  95. package/dist/components/section-nav.js.map +1 -1
  96. package/dist/customization.d.ts +7 -0
  97. package/dist/customization.js.map +1 -1
  98. package/dist/lib/ai/apply-content.js +6 -7
  99. package/dist/lib/ai/apply-content.js.map +1 -1
  100. package/dist/lib/ai/chat-context.d.ts +32 -26
  101. package/dist/lib/ai/chat-context.js +81 -9
  102. package/dist/lib/ai/chat-context.js.map +1 -1
  103. package/dist/lib/ai/constraints.d.ts +42 -0
  104. package/dist/lib/ai/constraints.js +259 -0
  105. package/dist/lib/ai/constraints.js.map +1 -0
  106. package/dist/lib/ai/index.d.ts +2 -0
  107. package/dist/lib/ai/index.js +2 -0
  108. package/dist/lib/ai/index.js.map +1 -1
  109. package/dist/lib/ai/proposal-validation.d.ts +26 -0
  110. package/dist/lib/ai/proposal-validation.js +17 -0
  111. package/dist/lib/ai/proposal-validation.js.map +1 -0
  112. package/dist/lib/ai/shape.js +60 -13
  113. package/dist/lib/ai/shape.js.map +1 -1
  114. package/dist/lib/ai/tools.d.ts +8 -4
  115. package/dist/lib/ai/tools.js +43 -14
  116. package/dist/lib/ai/tools.js.map +1 -1
  117. package/dist/lib/ai/translate-document.d.ts +35 -1
  118. package/dist/lib/ai/translate-document.js +16 -1
  119. package/dist/lib/ai/translate-document.js.map +1 -1
  120. package/dist/lib/ai/types.d.ts +11 -6
  121. package/dist/lib/ai/types.js.map +1 -1
  122. package/dist/lib/ai/update-fields.d.ts +8 -6
  123. package/dist/lib/ai/update-fields.js +9 -22
  124. package/dist/lib/ai/update-fields.js.map +1 -1
  125. package/dist/lib/clipboard/paste-issues.d.ts +5 -0
  126. package/dist/lib/clipboard/paste-issues.js +16 -6
  127. package/dist/lib/clipboard/paste-issues.js.map +1 -1
  128. package/dist/lib/cms/document-gate.d.ts +37 -0
  129. package/dist/lib/cms/document-gate.js +40 -0
  130. package/dist/lib/cms/document-gate.js.map +1 -0
  131. package/dist/lib/cms-views/view-model.d.ts +5 -0
  132. package/dist/lib/cms-views/view-model.js +10 -4
  133. package/dist/lib/cms-views/view-model.js.map +1 -1
  134. package/dist/lib/data/collection-content.d.ts +6 -1
  135. package/dist/lib/data/collection-content.js +51 -15
  136. package/dist/lib/data/collection-content.js.map +1 -1
  137. package/dist/lib/i18n/i18n.d.ts +51 -16
  138. package/dist/lib/i18n/i18n.js +119 -43
  139. package/dist/lib/i18n/i18n.js.map +1 -1
  140. package/dist/lib/i18n/index.d.ts +8 -1
  141. package/dist/lib/i18n/index.js +1 -1
  142. package/dist/lib/i18n/index.js.map +1 -1
  143. package/dist/lib/i18n/messages.de.js +200 -0
  144. package/dist/lib/i18n/messages.de.js.map +1 -1
  145. package/dist/lib/i18n/messages.en.d.ts +162 -0
  146. package/dist/lib/i18n/messages.en.js +226 -0
  147. package/dist/lib/i18n/messages.en.js.map +1 -1
  148. package/dist/lib/i18n/types.d.ts +15 -9
  149. package/dist/lib/i18n/types.js.map +1 -1
  150. package/dist/lib/locale/binding-fallback.d.ts +8 -1
  151. package/dist/lib/locale/binding-fallback.js +8 -1
  152. package/dist/lib/locale/binding-fallback.js.map +1 -1
  153. package/dist/lib/locale/locale-store.d.ts +41 -9
  154. package/dist/lib/locale/locale-store.js +34 -15
  155. package/dist/lib/locale/locale-store.js.map +1 -1
  156. package/dist/lib/locale/locale-switcher.d.ts +3 -1
  157. package/dist/lib/locale/locale-switcher.js +4 -2
  158. package/dist/lib/locale/locale-switcher.js.map +1 -1
  159. package/dist/lib/locale/use-studio-locale.d.ts +9 -4
  160. package/dist/lib/locale/use-studio-locale.js.map +1 -1
  161. package/dist/lib/operations/studio-operations.js +19 -2
  162. package/dist/lib/operations/studio-operations.js.map +1 -1
  163. package/dist/lib/persistence/convex-adapter.d.ts +1 -3
  164. package/dist/lib/persistence/convex-adapter.js +0 -55
  165. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  166. package/dist/lib/persistence/deployment-readiness.js +10 -0
  167. package/dist/lib/persistence/deployment-readiness.js.map +1 -1
  168. package/dist/lib/persona/labels.d.ts +85 -9
  169. package/dist/lib/persona/labels.js +198 -58
  170. package/dist/lib/persona/labels.js.map +1 -1
  171. package/dist/lib/routes/route-issues.d.ts +9 -0
  172. package/dist/lib/routes/route-issues.js +117 -0
  173. package/dist/lib/routes/route-issues.js.map +1 -0
  174. package/dist/lib/webhooks/dispatcher.d.ts +5 -2
  175. package/dist/lib/webhooks/dispatcher.js.map +1 -1
  176. package/dist/lib/webhooks/tags.js +150 -17
  177. package/dist/lib/webhooks/tags.js.map +1 -1
  178. package/dist/lib/workspace/config-issues.d.ts +56 -0
  179. package/dist/lib/workspace/config-issues.js +72 -0
  180. package/dist/lib/workspace/config-issues.js.map +1 -0
  181. package/dist/lib/workspace/index.d.ts +24 -8
  182. package/dist/lib/workspace/index.js +28 -18
  183. package/dist/lib/workspace/index.js.map +1 -1
  184. package/dist/lib/workspace/switcher-source.d.ts +20 -3
  185. package/dist/lib/workspace/switcher-source.js.map +1 -1
  186. package/dist/lib/workspace/workspace-source.js +27 -0
  187. package/dist/lib/workspace/workspace-source.js.map +1 -1
  188. package/dist/previews/manifest-format.d.ts +13 -6
  189. package/dist/previews/manifest-format.js +9 -2
  190. package/dist/previews/manifest-format.js.map +1 -1
  191. package/dist/router/root.d.ts +36 -1
  192. package/dist/router/root.js +54 -7
  193. package/dist/router/root.js.map +1 -1
  194. package/package.json +2 -2
  195. package/convex/cliTokens.ts +0 -63
  196. 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.cliTokens.listTokens()).rejects.toThrow(/unauthorized/) // project.administer denied
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(
@@ -52,8 +52,18 @@ export const internalMutation = customMutation(
52
52
  internalMutationGeneric as MutationBuilder<DataModel, 'internal'>,
53
53
  customCtx(triggers.wrapDB),
54
54
  )
55
- /** Server-only reads (e.g. the EC-157 HTTP sync endpoints) — not callable by clients. */
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
- /** HTTP action builder (EC-252) — used by the package's `http.ts`/`sync.ts`. Not
58
- * DataModel-bound: an action reaches data only through `ctx.runQuery`/`runMutation`. */
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. `http`/`sync` are pure engine
12
- * infra, so they ship in the package; only the consumer's `auth` (provider choice)
13
- * is injected. The consumer's `convex/http.ts` is a two-liner:
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
- * Registers Convex Auth's JWT discovery endpoints (`/.well-known/*`, EC-150) plus the
21
- * EC-157 Bearer-token registry-sync endpoint. Convex routes by exact path or prefix
22
- * only, so the dynamic `/api/projects/:projectId/registry-sync/snapshot` segment is
23
- * parsed inside the action (`sync.ts`).
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
+ })
@@ -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
- * Shared by BOTH graph publish (`publishing.ts`) and CMS **document** publish
23
- * (`cms.ts`). A content-only site never touches the graph, so without the
24
- * document-publish call instant-publish silently never fires for it (the
25
- * footgun this consolidates away).
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 project locales —
29
- * not available in the Convex env here. The sweep is the honest, reliable v1;
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
@@ -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 this is
25
- * now the SINGLE `cmsDocuments` trigger — one publish path, the dual-path footgun
26
- * gone at the source (the EC-146 revalidation gap is now impossible by construction).
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 `cliTokens:list`), not a content read. UI gating
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
- validateRichTextEmbeds,
206
- validateComposition,
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) {