@elytracms/studio 0.0.27 → 0.0.29

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 (138) hide show
  1. package/convex/assets.ts +19 -10
  2. package/convex/authority.ts +60 -0
  3. package/convex/cliTokens.ts +4 -4
  4. package/convex/cms.ts +12 -12
  5. package/convex/content.ts +10 -3
  6. package/convex/delivery.ts +22 -4
  7. package/convex/guard.ts +28 -11
  8. package/convex/members.ts +40 -7
  9. package/convex/redirects.ts +67 -0
  10. package/convex/references.ts +11 -11
  11. package/convex/schema.ts +30 -1
  12. package/convex/sync.ts +17 -10
  13. package/convex/users.ts +63 -0
  14. package/dist/components/app-views/default-modules.d.ts +48 -14
  15. package/dist/components/app-views/default-modules.js +64 -21
  16. package/dist/components/app-views/default-modules.js.map +1 -1
  17. package/dist/components/app-views/module.d.ts +32 -0
  18. package/dist/components/app-views/module.js +16 -0
  19. package/dist/components/app-views/module.js.map +1 -1
  20. package/dist/components/app-views/sidebar.d.ts +15 -1
  21. package/dist/components/app-views/sidebar.js +10 -0
  22. package/dist/components/app-views/sidebar.js.map +1 -1
  23. package/dist/components/cms/asset-picker.js +4 -4
  24. package/dist/components/cms/asset-picker.js.map +1 -1
  25. package/dist/components/cms/cell.js +16 -13
  26. package/dist/components/cms/cell.js.map +1 -1
  27. package/dist/components/cms/cms-store.d.ts +11 -26
  28. package/dist/components/cms/cms-store.js +25 -160
  29. package/dist/components/cms/cms-store.js.map +1 -1
  30. package/dist/components/cms/collection-table.d.ts +10 -0
  31. package/dist/components/cms/collection-table.js +72 -72
  32. package/dist/components/cms/collection-table.js.map +1 -1
  33. package/dist/components/cms/detail-editor.d.ts +14 -0
  34. package/dist/components/cms/detail-editor.js +224 -72
  35. package/dist/components/cms/detail-editor.js.map +1 -1
  36. package/dist/components/cms/document-editor-context.d.ts +26 -0
  37. package/dist/components/cms/document-editor-context.js.map +1 -1
  38. package/dist/components/cms/editable-cell.js +12 -7
  39. package/dist/components/cms/editable-cell.js.map +1 -1
  40. package/dist/components/cms/field-control.d.ts +2 -0
  41. package/dist/components/cms/field-control.js +40 -30
  42. package/dist/components/cms/field-control.js.map +1 -1
  43. package/dist/components/cms/filter-builder.d.ts +18 -0
  44. package/dist/components/cms/filter-builder.js +47 -6
  45. package/dist/components/cms/filter-builder.js.map +1 -1
  46. package/dist/components/cms/import-export.js +5 -3
  47. package/dist/components/cms/import-export.js.map +1 -1
  48. package/dist/components/cms/reference-confirm.js +8 -9
  49. package/dist/components/cms/reference-confirm.js.map +1 -1
  50. package/dist/components/cms/relation-picker.js +13 -10
  51. package/dist/components/cms/relation-picker.js.map +1 -1
  52. package/dist/components/cms/use-cell-edit.js +13 -7
  53. package/dist/components/cms/use-cell-edit.js.map +1 -1
  54. package/dist/components/cms/use-document-versions.d.ts +16 -0
  55. package/dist/components/cms/use-document-versions.js +31 -0
  56. package/dist/components/cms/use-document-versions.js.map +1 -0
  57. package/dist/components/cms/version-compare-text.d.ts +30 -0
  58. package/dist/components/cms/version-compare-text.js +113 -0
  59. package/dist/components/cms/version-compare-text.js.map +1 -0
  60. package/dist/components/cms/version-compare.d.ts +12 -0
  61. package/dist/components/cms/version-compare.js +271 -0
  62. package/dist/components/cms/version-compare.js.map +1 -0
  63. package/dist/components/cms/versions-pane.d.ts +3 -0
  64. package/dist/components/cms/versions-pane.js +47 -38
  65. package/dist/components/cms/versions-pane.js.map +1 -1
  66. package/dist/components/cms/view-bar.d.ts +21 -6
  67. package/dist/components/cms/view-bar.js +42 -57
  68. package/dist/components/cms/view-bar.js.map +1 -1
  69. package/dist/components/collection-view/cms-collection-view-source.d.ts +15 -0
  70. package/dist/components/collection-view/cms-collection-view-source.js +158 -0
  71. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -0
  72. package/dist/components/collection-view/collection-view-context.d.ts +10 -0
  73. package/dist/components/collection-view/collection-view-context.js +26 -0
  74. package/dist/components/collection-view/collection-view-context.js.map +1 -0
  75. package/dist/components/current-user-menu.js +8 -1
  76. package/dist/components/current-user-menu.js.map +1 -1
  77. package/dist/components/redirects/redirects-collection-view-source.d.ts +24 -0
  78. package/dist/components/redirects/redirects-collection-view-source.js +137 -0
  79. package/dist/components/redirects/redirects-collection-view-source.js.map +1 -0
  80. package/dist/components/redirects/redirects-module.d.ts +21 -0
  81. package/dist/components/redirects/redirects-module.js +43 -0
  82. package/dist/components/redirects/redirects-module.js.map +1 -0
  83. package/dist/components/redirects/redirects-schema.d.ts +10 -0
  84. package/dist/components/redirects/redirects-schema.js +42 -0
  85. package/dist/components/redirects/redirects-schema.js.map +1 -0
  86. package/dist/components/redirects/redirects-store.d.ts +46 -0
  87. package/dist/components/redirects/redirects-store.js +127 -0
  88. package/dist/components/redirects/redirects-store.js.map +1 -0
  89. package/dist/components/references/references-panel.js +2 -2
  90. package/dist/components/references/references-panel.js.map +1 -1
  91. package/dist/components/section-nav.js +14 -5
  92. package/dist/components/section-nav.js.map +1 -1
  93. package/dist/components/users/user-search.d.ts +8 -0
  94. package/dist/components/users/user-search.js +13 -0
  95. package/dist/components/users/user-search.js.map +1 -0
  96. package/dist/components/users/users-collection-view-source.d.ts +28 -0
  97. package/dist/components/users/users-collection-view-source.js +157 -0
  98. package/dist/components/users/users-collection-view-source.js.map +1 -0
  99. package/dist/components/users/users-module.d.ts +23 -0
  100. package/dist/components/users/users-module.js +61 -0
  101. package/dist/components/users/users-module.js.map +1 -0
  102. package/dist/components/users/users-schema.d.ts +19 -0
  103. package/dist/components/users/users-schema.js +38 -0
  104. package/dist/components/users/users-schema.js.map +1 -0
  105. package/dist/components/users/users-store.d.ts +40 -0
  106. package/dist/components/users/users-store.js +87 -0
  107. package/dist/components/users/users-store.js.map +1 -0
  108. package/dist/lib/cms-views/index.d.ts +4 -5
  109. package/dist/lib/cms-views/index.js +4 -5
  110. package/dist/lib/cms-views/index.js.map +1 -1
  111. package/dist/lib/cms-views/view-model.d.ts +48 -1
  112. package/dist/lib/cms-views/view-model.js +77 -1
  113. package/dist/lib/cms-views/view-model.js.map +1 -1
  114. package/dist/lib/collection-view/collection-view-source.d.ts +251 -0
  115. package/dist/lib/collection-view/collection-view-source.js +10 -0
  116. package/dist/lib/collection-view/collection-view-source.js.map +1 -0
  117. package/dist/lib/operations/session-hydration.d.ts +7 -0
  118. package/dist/lib/operations/session-hydration.js +7 -1
  119. package/dist/lib/operations/session-hydration.js.map +1 -1
  120. package/dist/lib/persistence/convex-adapter.d.ts +5 -1
  121. package/dist/lib/persistence/convex-adapter.js +148 -1
  122. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  123. package/dist/lib/workspace/index.d.ts +1 -8
  124. package/dist/lib/workspace/index.js +3 -9
  125. package/dist/lib/workspace/index.js.map +1 -1
  126. package/dist/router/match.d.ts +4 -0
  127. package/dist/router/match.js +8 -0
  128. package/dist/router/match.js.map +1 -1
  129. package/dist/ui/index.d.ts +2 -0
  130. package/dist/ui/index.js +2 -0
  131. package/dist/ui/index.js.map +1 -1
  132. package/dist/ui/popover.d.ts +13 -0
  133. package/dist/ui/popover.js +17 -0
  134. package/dist/ui/popover.js.map +1 -0
  135. package/dist/ui/row-item.d.ts +16 -0
  136. package/dist/ui/row-item.js +18 -0
  137. package/dist/ui/row-item.js.map +1 -0
  138. package/package.json +3 -2
package/convex/assets.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { v } from 'convex/values'
2
2
  import type { GenericId } from 'convex/values'
3
3
  import { mutation, query } from './functions'
4
- import { requireRole, requireUserId } from './guard'
4
+ import { requireCapability } from './guard'
5
5
 
6
6
  /**
7
7
  * Asset metadata persistence (EC-044) + byte storage (EC-151). Bytes live in
@@ -32,7 +32,7 @@ export const upsert = mutation({
32
32
  updatedAt: v.string(),
33
33
  },
34
34
  handler: async (ctx, args) => {
35
- await requireRole(ctx, 'editor') // EC-150
35
+ await requireCapability(ctx, 'content.write') // EC-150
36
36
  const existing = await ctx.db
37
37
  .query('assets')
38
38
  .withIndex('by_asset', (q) => q.eq('assetId', args.assetId))
@@ -49,7 +49,7 @@ export const upsert = mutation({
49
49
  export const get = query({
50
50
  args: { assetId: v.string() },
51
51
  handler: async (ctx, args) => {
52
- await requireRole(ctx, 'viewer') // EC-150
52
+ await requireCapability(ctx, 'content.read') // EC-150
53
53
  return ctx.db
54
54
  .query('assets')
55
55
  .withIndex('by_asset', (q) => q.eq('assetId', args.assetId))
@@ -60,7 +60,7 @@ export const get = query({
60
60
  export const list = query({
61
61
  args: {},
62
62
  handler: async (ctx) => {
63
- await requireRole(ctx, 'viewer') // EC-150
63
+ await requireCapability(ctx, 'content.read') // EC-150
64
64
  return ctx.db.query('assets').withIndex('by_asset').collect()
65
65
  },
66
66
  })
@@ -68,7 +68,7 @@ export const list = query({
68
68
  export const remove = mutation({
69
69
  args: { assetId: v.string() },
70
70
  handler: async (ctx, args) => {
71
- await requireRole(ctx, 'editor') // EC-150
71
+ await requireCapability(ctx, 'content.write') // EC-150
72
72
  const existing = await ctx.db
73
73
  .query('assets')
74
74
  .withIndex('by_asset', (q) => q.eq('assetId', args.assetId))
@@ -83,9 +83,11 @@ export const remove = mutation({
83
83
  export const generateUploadUrl = mutation({
84
84
  args: {},
85
85
  handler: async (ctx) => {
86
- // EC-150: any authenticated user may mint an upload URL; the metadata write
87
- // (`assets:upsert`) is what enforces the deployment role.
88
- await requireUserId(ctx)
86
+ // EC-150 / EC-304: minting an upload URL lets the caller push bytes into
87
+ // deployment storage (orphaned blobs even without a metadata write), so gate
88
+ // it at the same `editor` role as `assets:upsert`/`remove`. `requireUserId`
89
+ // admits an authenticated NON-member (open Password sign-up) — not enough.
90
+ await requireCapability(ctx, 'content.write')
89
91
  return ctx.storage.generateUploadUrl()
90
92
  },
91
93
  })
@@ -94,7 +96,10 @@ export const generateUploadUrl = mutation({
94
96
  export const getServeUrl = query({
95
97
  args: { storageKey: v.string() },
96
98
  handler: async (ctx, { storageKey }) => {
97
- await requireUserId(ctx) // EC-150: storage keys are unguessable; auth is the gate.
99
+ // EC-150 / EC-304: members only, matching the `assets:get`/`list` read gate.
100
+ // Storage keys are NOT unguessable — the public delivery snapshot ships them
101
+ // for every published asset — so authentication alone is not the gate.
102
+ await requireCapability(ctx, 'content.read')
98
103
  return ctx.storage.getUrl(asStorageId(storageKey))
99
104
  },
100
105
  })
@@ -103,7 +108,11 @@ export const getServeUrl = query({
103
108
  export const deleteBlob = mutation({
104
109
  args: { storageKey: v.string() },
105
110
  handler: async (ctx, { storageKey }) => {
106
- await requireUserId(ctx) // EC-150
111
+ // EC-150 / EC-304: destructive — gate at `editor`, matching `assets:remove`.
112
+ // Under the old `requireUserId` an authenticated non-member could delete the
113
+ // bytes of any published asset (storageKeys are harvestable from the public
114
+ // `delivery:snapshot`), knocking every image off the live site.
115
+ await requireCapability(ctx, 'content.write')
107
116
  await ctx.storage.delete(asStorageId(storageKey))
108
117
  },
109
118
  })
@@ -0,0 +1,60 @@
1
+ import { checkAuthorityFloor } from '@elytracms/core/operations'
2
+ import { defaultRolePolicy, roleGrantsCapability } from '@elytracms/core/persistence'
3
+ import type { ProjectRole } from '@elytracms/core/persistence'
4
+ import { memberRole, type GuardCtx } from './guard'
5
+
6
+ /**
7
+ * The engine integrity FLOOR at the Convex trust boundary (EC-240 / AD-18 tenet
8
+ * 6). Member/role management is capability-gated by
9
+ * `requireCapability('project.administer')`, but that alone would still let an
10
+ * administrator lock the project out. This asserts the two non-overridable
11
+ * invariants on authority-mutating handlers (`members:set`, `members:remove` —
12
+ * and therefore `users.save`/`users.remove`, which delegate to them):
13
+ *
14
+ * - **(a) assign only roles you hold** — future-proofs the boundary for when
15
+ * policy opens member-management to non-admins; today an administrator can
16
+ * grant any role, so this never fires under the default policy.
17
+ * - **(b) the last admin can't be removed or demoted** — the hole this closes:
18
+ * before EC-240 the handlers did a blind upsert/delete, so an admin could
19
+ * remove the sole admin and lock everyone out (the empty-table bootstrap in
20
+ * `auth.ts` only self-heals a FULLY empty members table).
21
+ *
22
+ * The decision logic is the ONE shared `checkAuthorityFloor` primitive that the
23
+ * engine resolver also calls — this file only supplies the primitives it reads
24
+ * from `ctx.db`, so client and server can never drift. Denials throw
25
+ * `unauthorized: …`, matching the guard convention.
26
+ */
27
+ export async function assertAuthorityFloor(
28
+ ctx: GuardCtx,
29
+ params: {
30
+ /** The caller (already `requireCapability('project.administer')`-checked). */
31
+ readonly callerId: string
32
+ /** The member whose role/membership this op changes. */
33
+ readonly targetUserId: string
34
+ /** Roles this op grants the target (empty for a pure removal). */
35
+ readonly rolesGranted: readonly ProjectRole[]
36
+ /** True when the op removes the membership entirely. */
37
+ readonly removesMembership: boolean
38
+ },
39
+ ): Promise<void> {
40
+ const callerRole = await memberRole(ctx, params.callerId)
41
+ const targetRole = await memberRole(ctx, params.targetUserId)
42
+ const members = await ctx.db.query('members').collect()
43
+ // "admin" generalizes to "grants project.administer" (EC-240c) — the same
44
+ // policy the engine resolver uses, so client and server can never drift.
45
+ const isAdministrator = (role: string) =>
46
+ roleGrantsCapability(role, 'project.administer', defaultRolePolicy)
47
+ const adminCount = members.filter((m) => isAdministrator(m.role)).length
48
+
49
+ const decision = checkAuthorityFloor(
50
+ {
51
+ callerRoles: callerRole ? [callerRole] : [],
52
+ rolesGranted: params.rolesGranted,
53
+ targetIsAdmin: targetRole !== null && isAdministrator(targetRole),
54
+ removesMembership: params.removesMembership,
55
+ adminCount,
56
+ },
57
+ defaultRolePolicy,
58
+ )
59
+ if (!decision.allow) throw new Error(`unauthorized: ${decision.reason}`)
60
+ }
@@ -1,6 +1,6 @@
1
1
  import { v } from 'convex/values'
2
2
  import { mutation, query } from './functions'
3
- import { requireRole } from './guard'
3
+ import { requireCapability } from './guard'
4
4
 
5
5
  /**
6
6
  * CLI token persistence (EC-150). Only SHA-256 hashes are stored — hashing and
@@ -17,7 +17,7 @@ export const create = mutation({
17
17
  createdAt: v.string(),
18
18
  },
19
19
  handler: async (ctx, args) => {
20
- await requireRole(ctx, 'admin')
20
+ await requireCapability(ctx, 'project.administer')
21
21
  const id = await ctx.db.insert('cliTokens', args)
22
22
  return ctx.db.get(id)
23
23
  },
@@ -26,7 +26,7 @@ export const create = mutation({
26
26
  export const list = query({
27
27
  args: {},
28
28
  handler: async (ctx) => {
29
- await requireRole(ctx, 'admin')
29
+ await requireCapability(ctx, 'project.administer')
30
30
  const rows = await ctx.db.query('cliTokens').collect()
31
31
  return rows.sort((a, b) => (a.tokenId < b.tokenId ? -1 : a.tokenId > b.tokenId ? 1 : 0))
32
32
  },
@@ -35,7 +35,7 @@ export const list = query({
35
35
  export const revoke = mutation({
36
36
  args: { tokenId: v.string(), revokedAt: v.string() },
37
37
  handler: async (ctx, { tokenId, revokedAt }) => {
38
- await requireRole(ctx, 'admin')
38
+ await requireCapability(ctx, 'project.administer')
39
39
  const existing = await ctx.db
40
40
  .query('cliTokens')
41
41
  .withIndex('by_token', (q) => q.eq('tokenId', tokenId))
package/convex/cms.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { v } from 'convex/values'
2
2
  import { mutation, query } from './functions'
3
- import { requireRole } from './guard'
3
+ import { requireCapability } from './guard'
4
4
 
5
5
  /**
6
6
  * CMS document persistence (EC-043). The backend stores **documents only**
@@ -20,7 +20,7 @@ export const upsertDocument = mutation({
20
20
  updatedAt: v.string(),
21
21
  },
22
22
  handler: async (ctx, args) => {
23
- await requireRole(ctx, 'editor') // EC-150
23
+ await requireCapability(ctx, 'content.write') // EC-150
24
24
  const existing = await ctx.db
25
25
  .query('cmsDocuments')
26
26
  .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
@@ -39,7 +39,7 @@ export const upsertDocument = mutation({
39
39
  export const getDocument = query({
40
40
  args: { collection: v.string(), docId: v.string() },
41
41
  handler: async (ctx, args) => {
42
- await requireRole(ctx, 'viewer') // EC-150
42
+ await requireCapability(ctx, 'content.read') // EC-150
43
43
  return ctx.db
44
44
  .query('cmsDocuments')
45
45
  .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
@@ -50,7 +50,7 @@ export const getDocument = query({
50
50
  export const listDocuments = query({
51
51
  args: { collection: v.optional(v.string()) },
52
52
  handler: async (ctx, args) => {
53
- await requireRole(ctx, 'viewer') // EC-150
53
+ await requireCapability(ctx, 'content.read') // EC-150
54
54
  if (args.collection) {
55
55
  const collection = args.collection
56
56
  return ctx.db
@@ -65,7 +65,7 @@ export const listDocuments = query({
65
65
  export const deleteDocument = mutation({
66
66
  args: { collection: v.string(), docId: v.string() },
67
67
  handler: async (ctx, args) => {
68
- await requireRole(ctx, 'editor') // EC-150
68
+ await requireCapability(ctx, 'content.write') // EC-150
69
69
  const existing = await ctx.db
70
70
  .query('cmsDocuments')
71
71
  .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
@@ -97,7 +97,7 @@ export const saveDocumentVersion = mutation({
97
97
  createdAt: v.string(),
98
98
  },
99
99
  handler: async (ctx, args) => {
100
- await requireRole(ctx, 'editor') // EC-150
100
+ await requireCapability(ctx, 'content.write') // EC-150
101
101
  // EC-302: versions are appended monotonically, so the latest-created row holds
102
102
  // the highest version. Read just that one (index-ordered, newest first) instead
103
103
  // of collecting the whole — unbounded — history to compute `max+1`.
@@ -115,7 +115,7 @@ export const saveDocumentVersion = mutation({
115
115
  export const listDocumentVersions = query({
116
116
  args: { collection: v.string(), docId: v.string() },
117
117
  handler: async (ctx, args) => {
118
- await requireRole(ctx, 'viewer') // EC-150
118
+ await requireCapability(ctx, 'content.read') // EC-150
119
119
  const rows = await ctx.db
120
120
  .query('documentVersions')
121
121
  .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
@@ -128,7 +128,7 @@ export const listDocumentVersions = query({
128
128
  export const getDocumentVersion = query({
129
129
  args: { collection: v.string(), docId: v.string(), version: v.number() },
130
130
  handler: async (ctx, args) => {
131
- await requireRole(ctx, 'viewer') // EC-150
131
+ await requireCapability(ctx, 'content.read') // EC-150
132
132
  // EC-307: address the row directly via `by_doc_version` instead of collecting
133
133
  // the whole — unbounded — per-document history and finding in JS. `version` is
134
134
  // unique per (collection, docId), so `.unique()` both fetches and asserts that.
@@ -151,7 +151,7 @@ export const getDocumentVersion = query({
151
151
  export const listPublishedDocuments = query({
152
152
  args: { collection: v.optional(v.string()) },
153
153
  handler: async (ctx, args) => {
154
- await requireRole(ctx, 'viewer') // EC-150
154
+ await requireCapability(ctx, 'content.read') // EC-150
155
155
  const collection = args.collection
156
156
  const docs = collection
157
157
  ? await ctx.db
@@ -179,7 +179,7 @@ export const listPublishedDocuments = query({
179
179
  export const listAllDocumentVersions = query({
180
180
  args: {},
181
181
  handler: async (ctx) => {
182
- await requireRole(ctx, 'viewer') // EC-150
182
+ await requireCapability(ctx, 'content.read') // EC-150
183
183
  return ctx.db.query('documentVersions').collect()
184
184
  },
185
185
  })
@@ -192,7 +192,7 @@ export const publishDocumentVersion = mutation({
192
192
  updatedAt: v.string(),
193
193
  },
194
194
  handler: async (ctx, args) => {
195
- await requireRole(ctx, 'editor') // EC-150
195
+ await requireCapability(ctx, 'content.write') // EC-150
196
196
  const doc = await ctx.db
197
197
  .query('cmsDocuments')
198
198
  .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
@@ -209,7 +209,7 @@ export const publishDocumentVersion = mutation({
209
209
  export const unpublishDocument = mutation({
210
210
  args: { collection: v.string(), docId: v.string(), updatedAt: v.string() },
211
211
  handler: async (ctx, args) => {
212
- await requireRole(ctx, 'editor') // EC-150
212
+ await requireCapability(ctx, 'content.write') // EC-150
213
213
  const doc = await ctx.db
214
214
  .query('cmsDocuments')
215
215
  .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
package/convex/content.ts CHANGED
@@ -90,8 +90,8 @@ export interface CreateContentOptions {
90
90
  collections?: readonly CollectionDef[]
91
91
  /** Route records served by `resolvePage` (EC-019). */
92
92
  routes?: readonly RouteRecord[]
93
- /** Redirect records served by `resolvePage` (EC-019/020). */
94
- redirects?: readonly RedirectRecord[]
93
+ // Redirects are no longer an option (EC-303): they are editorial DATA, read from
94
+ // the `redirects` table inside `createContent`, not passed by the caller.
95
95
  }
96
96
 
97
97
  export interface ContentKit {
@@ -130,9 +130,16 @@ export async function createContent(
130
130
  ): Promise<ContentKit> {
131
131
  const { projectId, perspective } = options
132
132
  const routes = options.routes ?? []
133
- const redirects = options.redirects ?? []
134
133
  const loadIssues: ContentKitIssue[] = []
135
134
 
135
+ // Redirects are editorial DATA now (EC-303), read from the `redirects` table —
136
+ // only ENABLED entries resolve. (Config redirects are retired; static infra
137
+ // redirects stay native.) Projected to the routing `RedirectRecord` shape.
138
+ const redirectRows = await ctx.db.query('redirects').withIndex('by_redirect').collect()
139
+ const redirects: RedirectRecord[] = redirectRows
140
+ .filter((row) => row.enabled)
141
+ .map((row) => ({ id: row.redirectId, from: row.from, to: row.to, permanent: row.permanent }))
142
+
136
143
  // --- Prefetch (the only async part; everything below is synchronous) -----
137
144
 
138
145
  // Config-first identity (EC-182/EC-183, AD-11): locales AND collections are
@@ -2,7 +2,7 @@ import { v } from 'convex/values'
2
2
  import { dataSourceSchema } from '@elytracms/core/data-binding'
3
3
  import { query } from './functions'
4
4
  import type { QueryCtx } from './functions'
5
- import { requireUserId } from './guard'
5
+ import { requireCapability } from './guard'
6
6
 
7
7
  // EC-207: routes/redirects are config-sourced (authored in elytra.config.ts and
8
8
  // resolved by studio-core), NOT backend data. The delivery snapshot no longer
@@ -20,7 +20,7 @@ import { requireUserId } from './guard'
20
20
  * ## Why `snapshot` is PUBLIC (deliberate, EC-150 exception)
21
21
  *
22
22
  * Every other project-scoped function in this deployment is guarded by
23
- * `requireRole`. This one is not, on purpose: it serves **published content
23
+ * `requireCapability`. This one is not, on purpose: it serves **published content
24
24
  * only** — the exact data the production website renders to every anonymous
25
25
  * visitor. Publishing IS the act of making it public. The invariants that
26
26
  * keep this safe are enforced server-side in this module:
@@ -62,6 +62,14 @@ interface DeliverySnapshot {
62
62
  documents: unknown[]
63
63
  /** Asset metadata in the delivery `AssetRecord` shape (with serve URLs). */
64
64
  assets: unknown[]
65
+ /**
66
+ * Enabled editorial redirects (EC-303) in the routing `RedirectRecord` shape,
67
+ * served by `resolvePage`. Redirects are DATA now (not config), so they ship in
68
+ * the snapshot and change DYNAMICALLY (no redeploy) — the host fetches the
69
+ * snapshot per request. Disabled redirects never leave the deployment. Static
70
+ * infra redirects stay native (`next.config`/`vercel`), evaluated before these.
71
+ */
72
+ redirects: unknown[]
65
73
  }
66
74
 
67
75
  /** One document per persisted source — must match the studio's sources-store. */
@@ -198,12 +206,21 @@ export async function loadSnapshot(
198
206
  documentRows.map((row) => row.document as unknown),
199
207
  )
200
208
 
209
+ // Editorial redirects (EC-303): only ENABLED entries serve — a disabled redirect
210
+ // is stored but never live, in either perspective. Projected to the routing
211
+ // `RedirectRecord` shape the router/`resolvePage` consume.
212
+ const redirectRows = await ctx.db.query('redirects').withIndex('by_redirect').collect()
213
+ const redirects = redirectRows
214
+ .filter((row) => row.enabled)
215
+ .map((row) => ({ id: row.redirectId, from: row.from, to: row.to, permanent: row.permanent }))
216
+
201
217
  return {
202
218
  projectId,
203
219
  perspective,
204
220
  sources,
205
221
  documents,
206
222
  assets,
223
+ redirects,
207
224
  }
208
225
  }
209
226
 
@@ -243,14 +260,15 @@ export const draftSnapshot = query({
243
260
  * trigger's `scheduleRevalidate`, `triggers.ts`), and the webhook only fires when
244
261
  * `CANVAS_REVALIDATE_ENDPOINT` + `CANVAS_REVALIDATE_SECRET` are set
245
262
  * (`webhooks.ts` `deliver`). A future `elytra doctor` (EC-259) can read the same
246
- * query. Gated to authenticated users — config state is not public.
263
+ * query. Gated to deployment members (EC-304) — config state is not public, and
264
+ * an authenticated non-member (open Password sign-up) is not a member.
247
265
  */
248
266
  export const deliveryConfig = query({
249
267
  args: {},
250
268
  handler: async (
251
269
  ctx,
252
270
  ): Promise<{ projectScope: boolean; revalidateEndpoint: boolean; revalidateSecret: boolean }> => {
253
- await requireUserId(ctx)
271
+ await requireCapability(ctx, 'content.read')
254
272
  const env = process.env as Record<string, string | undefined>
255
273
  const isSet = (key: string): boolean => {
256
274
  const value = env[key]
package/convex/guard.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { getAuthUserId } from '@convex-dev/auth/server'
2
+ import { defaultRolePolicy, roleGrantsCapability } from '@elytracms/core/persistence'
3
+ import type { OperationCapabilityId, ProjectRole, RolePolicy } from '@elytracms/core/persistence'
2
4
  import type { QueryCtx } from './functions'
3
5
 
4
6
  /**
@@ -11,8 +13,14 @@ import type { QueryCtx } from './functions'
11
13
  * deployment-scoped — a `members` row grants a user a role on this deployment.
12
14
  * No `projectId` is threaded.
13
15
  *
14
- * Role ranks mirror `@elytracms/core/persistence`'s `roleAtLeast`:
15
- * viewer (0) < editor (1) < admin (2).
16
+ * The role model + permission policy are the ONE canonical source
17
+ * (`@elytracms/core/persistence` — the `RolePolicy` + `roleGrantsCapability`),
18
+ * imported here rather than re-declared. Handlers gate on CAPABILITIES
19
+ * (`content.read` / `content.write` / `project.administer`), not literal roles
20
+ * (EC-240c), so a project's custom role set enforces server-side by the same
21
+ * policy the operations layer uses. The Convex trust boundary reads the shipped
22
+ * {@link defaultRolePolicy}; a project overriding roles injects its policy (the
23
+ * documented follow-up seam).
16
24
  *
17
25
  * Failures throw plain `Error('unauthorized: …')` — Convex transports the
18
26
  * message; the app's adapters normalize thrown backend errors onto the
@@ -20,9 +28,8 @@ import type { QueryCtx } from './functions'
20
28
  * structured `OperationError`s (never crashes).
21
29
  */
22
30
 
23
- export type ProjectRole = 'admin' | 'editor' | 'viewer'
24
-
25
- const ROLE_RANK: Record<ProjectRole, number> = { viewer: 0, editor: 1, admin: 2 }
31
+ // Re-exported so peer Convex modules (e.g. `sync.ts`) keep one import site.
32
+ export type { ProjectRole }
26
33
 
27
34
  /**
28
35
  * The ctx slice the guards need: identity + a readable `members` table.
@@ -48,7 +55,7 @@ export async function requireUserId(ctx: GuardCtx): Promise<string> {
48
55
  * instead of throwing. A thrown handler is logged by Convex as an
49
56
  * `Uncaught Error` even though the client catches it — noise that an autonomous
50
57
  * agent (Milestone J) reads as a real failure. Writes and protected reads keep
51
- * using {@link requireUserId} / {@link requireRole} so genuine unauthorized
58
+ * using {@link requireUserId} / {@link requireCapability} so genuine unauthorized
52
59
  * access stays loud.
53
60
  */
54
61
  export async function getOptionalUserId(ctx: GuardCtx): Promise<string | null> {
@@ -65,18 +72,28 @@ export async function memberRole(ctx: GuardCtx, userId: string): Promise<Project
65
72
  }
66
73
 
67
74
  /**
68
- * Require an authenticated caller with at least `role` on this deployment.
69
- * Returns the user id so handlers can attribute writes.
75
+ * Require an authenticated member whose role grants `capability` on this
76
+ * deployment (EC-240c). Returns the user id so handlers can attribute writes.
77
+ *
78
+ * This is the ONE role-enforcement primitive at the trust boundary — every
79
+ * handler gates on a capability rather than a literal role, so a project's
80
+ * custom role set is honoured by the shared policy. `policy` defaults to the
81
+ * shipped {@link defaultRolePolicy}; the override seam (a project injecting its
82
+ * own policy) is the documented follow-up.
70
83
  */
71
- export async function requireRole(ctx: GuardCtx, role: ProjectRole): Promise<string> {
84
+ export async function requireCapability(
85
+ ctx: GuardCtx,
86
+ capability: OperationCapabilityId,
87
+ policy: RolePolicy = defaultRolePolicy,
88
+ ): Promise<string> {
72
89
  const userId = await requireUserId(ctx)
73
90
  const actual = await memberRole(ctx, userId)
74
91
  if (actual === null) {
75
92
  throw new Error('unauthorized: not a member of this deployment')
76
93
  }
77
- if (ROLE_RANK[actual] < ROLE_RANK[role]) {
94
+ if (!roleGrantsCapability(actual, capability, policy)) {
78
95
  throw new Error(
79
- `unauthorized: this deployment requires the "${role}" role (caller is "${actual}")`,
96
+ `unauthorized: this operation requires the "${capability}" capability (caller role "${actual}" does not grant it)`,
80
97
  )
81
98
  }
82
99
  return userId
package/convex/members.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { v } from 'convex/values'
2
2
  import { mutation, query } from './functions'
3
- import { getOptionalUserId, requireRole, requireUserId } from './guard'
3
+ import { getOptionalUserId, requireCapability, requireUserId } from './guard'
4
+ import { assertAuthorityFloor } from './authority'
4
5
 
5
6
  /**
6
7
  * Deployment membership persistence (EC-150, single-tenant per EC-179). The
@@ -15,7 +16,7 @@ const roleValidator = v.union(v.literal('admin'), v.literal('editor'), v.literal
15
16
  export const list = query({
16
17
  args: {},
17
18
  handler: async (ctx) => {
18
- await requireRole(ctx, 'viewer')
19
+ await requireCapability(ctx, 'content.read')
19
20
  const rows = await ctx.db.query('members').collect()
20
21
  return rows.sort((a, b) => (a.userId < b.userId ? -1 : a.userId > b.userId ? 1 : 0))
21
22
  },
@@ -27,7 +28,7 @@ export const get = query({
27
28
  // Members may look up memberships of this deployment; everyone may look up
28
29
  // their *own* membership (the studio needs it to render the role badge).
29
30
  const caller = await requireUserId(ctx)
30
- if (caller !== userId) await requireRole(ctx, 'viewer')
31
+ if (caller !== userId) await requireCapability(ctx, 'content.read')
31
32
  return ctx.db
32
33
  .query('members')
33
34
  .withIndex('by_user', (q) => q.eq('userId', userId))
@@ -62,20 +63,45 @@ export const set = mutation({
62
63
  args: {
63
64
  userId: v.string(),
64
65
  role: roleValidator,
66
+ /**
67
+ * Studio-user custom fields (EC-240, 240a). Optional so the member-management
68
+ * path (`ConvexMembersRepository.setMember`) omits it; the users rail
69
+ * (`ConvexUserRepository.saveUser`) supplies it. When omitted on an update,
70
+ * the existing bag is preserved (a role change must not wipe custom fields).
71
+ */
72
+ meta: v.optional(v.any()),
65
73
  createdAt: v.string(),
66
74
  updatedAt: v.string(),
67
75
  },
68
76
  handler: async (ctx, args) => {
69
- await requireRole(ctx, 'admin')
77
+ const callerId = await requireCapability(ctx, 'project.administer')
78
+ // Integrity floor (EC-240): can't demote the last admin / assign above self.
79
+ await assertAuthorityFloor(ctx, {
80
+ callerId,
81
+ targetUserId: args.userId,
82
+ rolesGranted: [args.role],
83
+ removesMembership: false,
84
+ })
70
85
  const existing = await ctx.db
71
86
  .query('members')
72
87
  .withIndex('by_user', (q) => q.eq('userId', args.userId))
73
88
  .first()
74
89
  if (existing) {
75
- await ctx.db.patch(existing._id, { role: args.role, updatedAt: args.updatedAt })
90
+ await ctx.db.patch(existing._id, {
91
+ role: args.role,
92
+ updatedAt: args.updatedAt,
93
+ // Only touch `meta` when supplied — a bare role change preserves it.
94
+ ...(args.meta !== undefined ? { meta: args.meta } : {}),
95
+ })
76
96
  return ctx.db.get(existing._id)
77
97
  }
78
- const id = await ctx.db.insert('members', args)
98
+ const id = await ctx.db.insert('members', {
99
+ userId: args.userId,
100
+ role: args.role,
101
+ ...(args.meta !== undefined ? { meta: args.meta } : {}),
102
+ createdAt: args.createdAt,
103
+ updatedAt: args.updatedAt,
104
+ })
79
105
  return ctx.db.get(id)
80
106
  },
81
107
  })
@@ -83,7 +109,14 @@ export const set = mutation({
83
109
  export const remove = mutation({
84
110
  args: { userId: v.string() },
85
111
  handler: async (ctx, { userId }) => {
86
- await requireRole(ctx, 'admin')
112
+ const callerId = await requireCapability(ctx, 'project.administer')
113
+ // Integrity floor (EC-240): the last admin cannot be removed.
114
+ await assertAuthorityFloor(ctx, {
115
+ callerId,
116
+ targetUserId: userId,
117
+ rolesGranted: [],
118
+ removesMembership: true,
119
+ })
87
120
  const existing = await ctx.db
88
121
  .query('members')
89
122
  .withIndex('by_user', (q) => q.eq('userId', userId))
@@ -0,0 +1,67 @@
1
+ import { v } from 'convex/values'
2
+ import { mutation, query } from './functions'
3
+ import { requireCapability } from './guard'
4
+
5
+ /**
6
+ * Editorial redirects persistence (EC-303). Redirects are data an editor owns —
7
+ * saved+enabled entries go live dynamically (no deploy) via the delivery
8
+ * snapshot. Reachable only through the app's `ConvexRedirectRepository` adapter.
9
+ * Content-gated (a viewer reads, an editor writes), NOT admin-only — redirects
10
+ * are content, not administration.
11
+ */
12
+
13
+ export const save = mutation({
14
+ args: {
15
+ redirectId: v.string(),
16
+ from: v.string(),
17
+ to: v.string(),
18
+ permanent: v.boolean(),
19
+ enabled: v.boolean(),
20
+ createdAt: v.string(),
21
+ updatedAt: v.string(),
22
+ },
23
+ handler: async (ctx, args) => {
24
+ await requireCapability(ctx, 'content.write')
25
+ const existing = await ctx.db
26
+ .query('redirects')
27
+ .withIndex('by_redirect', (q) => q.eq('redirectId', args.redirectId))
28
+ .first()
29
+ if (existing) {
30
+ await ctx.db.patch(existing._id, { ...args, createdAt: existing.createdAt })
31
+ return ctx.db.get(existing._id)
32
+ }
33
+ const id = await ctx.db.insert('redirects', args)
34
+ return ctx.db.get(id)
35
+ },
36
+ })
37
+
38
+ export const get = query({
39
+ args: { redirectId: v.string() },
40
+ handler: async (ctx, args) => {
41
+ await requireCapability(ctx, 'content.read')
42
+ return ctx.db
43
+ .query('redirects')
44
+ .withIndex('by_redirect', (q) => q.eq('redirectId', args.redirectId))
45
+ .first()
46
+ },
47
+ })
48
+
49
+ export const list = query({
50
+ args: {},
51
+ handler: async (ctx) => {
52
+ await requireCapability(ctx, 'content.read')
53
+ return ctx.db.query('redirects').withIndex('by_redirect').collect()
54
+ },
55
+ })
56
+
57
+ export const remove = mutation({
58
+ args: { redirectId: v.string() },
59
+ handler: async (ctx, args) => {
60
+ await requireCapability(ctx, 'content.write')
61
+ const existing = await ctx.db
62
+ .query('redirects')
63
+ .withIndex('by_redirect', (q) => q.eq('redirectId', args.redirectId))
64
+ .first()
65
+ if (existing) await ctx.db.delete(existing._id)
66
+ },
67
+ })