@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.
- package/convex/assets.ts +19 -10
- package/convex/authority.ts +60 -0
- package/convex/cliTokens.ts +4 -4
- package/convex/cms.ts +12 -12
- package/convex/content.ts +10 -3
- package/convex/delivery.ts +22 -4
- package/convex/guard.ts +28 -11
- package/convex/members.ts +40 -7
- package/convex/redirects.ts +67 -0
- package/convex/references.ts +11 -11
- package/convex/schema.ts +30 -1
- package/convex/sync.ts +17 -10
- package/convex/users.ts +63 -0
- package/dist/components/app-views/default-modules.d.ts +48 -14
- package/dist/components/app-views/default-modules.js +64 -21
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/app-views/module.d.ts +32 -0
- package/dist/components/app-views/module.js +16 -0
- package/dist/components/app-views/module.js.map +1 -1
- package/dist/components/app-views/sidebar.d.ts +15 -1
- package/dist/components/app-views/sidebar.js +10 -0
- package/dist/components/app-views/sidebar.js.map +1 -1
- package/dist/components/cms/asset-picker.js +4 -4
- package/dist/components/cms/asset-picker.js.map +1 -1
- package/dist/components/cms/cell.js +16 -13
- package/dist/components/cms/cell.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +11 -26
- package/dist/components/cms/cms-store.js +25 -160
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/collection-table.d.ts +10 -0
- package/dist/components/cms/collection-table.js +72 -72
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/detail-editor.d.ts +14 -0
- package/dist/components/cms/detail-editor.js +224 -72
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/document-editor-context.d.ts +26 -0
- package/dist/components/cms/document-editor-context.js.map +1 -1
- package/dist/components/cms/editable-cell.js +12 -7
- package/dist/components/cms/editable-cell.js.map +1 -1
- package/dist/components/cms/field-control.d.ts +2 -0
- package/dist/components/cms/field-control.js +40 -30
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/filter-builder.d.ts +18 -0
- package/dist/components/cms/filter-builder.js +47 -6
- package/dist/components/cms/filter-builder.js.map +1 -1
- package/dist/components/cms/import-export.js +5 -3
- package/dist/components/cms/import-export.js.map +1 -1
- package/dist/components/cms/reference-confirm.js +8 -9
- package/dist/components/cms/reference-confirm.js.map +1 -1
- package/dist/components/cms/relation-picker.js +13 -10
- package/dist/components/cms/relation-picker.js.map +1 -1
- package/dist/components/cms/use-cell-edit.js +13 -7
- package/dist/components/cms/use-cell-edit.js.map +1 -1
- package/dist/components/cms/use-document-versions.d.ts +16 -0
- package/dist/components/cms/use-document-versions.js +31 -0
- package/dist/components/cms/use-document-versions.js.map +1 -0
- package/dist/components/cms/version-compare-text.d.ts +30 -0
- package/dist/components/cms/version-compare-text.js +113 -0
- package/dist/components/cms/version-compare-text.js.map +1 -0
- package/dist/components/cms/version-compare.d.ts +12 -0
- package/dist/components/cms/version-compare.js +271 -0
- package/dist/components/cms/version-compare.js.map +1 -0
- package/dist/components/cms/versions-pane.d.ts +3 -0
- package/dist/components/cms/versions-pane.js +47 -38
- package/dist/components/cms/versions-pane.js.map +1 -1
- package/dist/components/cms/view-bar.d.ts +21 -6
- package/dist/components/cms/view-bar.js +42 -57
- package/dist/components/cms/view-bar.js.map +1 -1
- package/dist/components/collection-view/cms-collection-view-source.d.ts +15 -0
- package/dist/components/collection-view/cms-collection-view-source.js +158 -0
- package/dist/components/collection-view/cms-collection-view-source.js.map +1 -0
- package/dist/components/collection-view/collection-view-context.d.ts +10 -0
- package/dist/components/collection-view/collection-view-context.js +26 -0
- package/dist/components/collection-view/collection-view-context.js.map +1 -0
- package/dist/components/current-user-menu.js +8 -1
- package/dist/components/current-user-menu.js.map +1 -1
- package/dist/components/redirects/redirects-collection-view-source.d.ts +24 -0
- package/dist/components/redirects/redirects-collection-view-source.js +137 -0
- package/dist/components/redirects/redirects-collection-view-source.js.map +1 -0
- package/dist/components/redirects/redirects-module.d.ts +21 -0
- package/dist/components/redirects/redirects-module.js +43 -0
- package/dist/components/redirects/redirects-module.js.map +1 -0
- package/dist/components/redirects/redirects-schema.d.ts +10 -0
- package/dist/components/redirects/redirects-schema.js +42 -0
- package/dist/components/redirects/redirects-schema.js.map +1 -0
- package/dist/components/redirects/redirects-store.d.ts +46 -0
- package/dist/components/redirects/redirects-store.js +127 -0
- package/dist/components/redirects/redirects-store.js.map +1 -0
- package/dist/components/references/references-panel.js +2 -2
- package/dist/components/references/references-panel.js.map +1 -1
- package/dist/components/section-nav.js +14 -5
- package/dist/components/section-nav.js.map +1 -1
- package/dist/components/users/user-search.d.ts +8 -0
- package/dist/components/users/user-search.js +13 -0
- package/dist/components/users/user-search.js.map +1 -0
- package/dist/components/users/users-collection-view-source.d.ts +28 -0
- package/dist/components/users/users-collection-view-source.js +157 -0
- package/dist/components/users/users-collection-view-source.js.map +1 -0
- package/dist/components/users/users-module.d.ts +23 -0
- package/dist/components/users/users-module.js +61 -0
- package/dist/components/users/users-module.js.map +1 -0
- package/dist/components/users/users-schema.d.ts +19 -0
- package/dist/components/users/users-schema.js +38 -0
- package/dist/components/users/users-schema.js.map +1 -0
- package/dist/components/users/users-store.d.ts +40 -0
- package/dist/components/users/users-store.js +87 -0
- package/dist/components/users/users-store.js.map +1 -0
- package/dist/lib/cms-views/index.d.ts +4 -5
- package/dist/lib/cms-views/index.js +4 -5
- package/dist/lib/cms-views/index.js.map +1 -1
- package/dist/lib/cms-views/view-model.d.ts +48 -1
- package/dist/lib/cms-views/view-model.js +77 -1
- package/dist/lib/cms-views/view-model.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +251 -0
- package/dist/lib/collection-view/collection-view-source.js +10 -0
- package/dist/lib/collection-view/collection-view-source.js.map +1 -0
- package/dist/lib/operations/session-hydration.d.ts +7 -0
- package/dist/lib/operations/session-hydration.js +7 -1
- package/dist/lib/operations/session-hydration.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.d.ts +5 -1
- package/dist/lib/persistence/convex-adapter.js +148 -1
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/workspace/index.d.ts +1 -8
- package/dist/lib/workspace/index.js +3 -9
- package/dist/lib/workspace/index.js.map +1 -1
- package/dist/router/match.d.ts +4 -0
- package/dist/router/match.js +8 -0
- package/dist/router/match.js.map +1 -1
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +2 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/popover.d.ts +13 -0
- package/dist/ui/popover.js +17 -0
- package/dist/ui/popover.js.map +1 -0
- package/dist/ui/row-item.d.ts +16 -0
- package/dist/ui/row-item.js +18 -0
- package/dist/ui/row-item.js.map +1 -0
- 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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
87
|
-
// (
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/convex/cliTokens.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { v } from 'convex/values'
|
|
2
2
|
import { mutation, query } from './functions'
|
|
3
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
94
|
-
redirects
|
|
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
|
package/convex/delivery.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
* `
|
|
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
|
|
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
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
94
|
+
if (!roleGrantsCapability(actual, capability, policy)) {
|
|
78
95
|
throw new Error(
|
|
79
|
-
`unauthorized: this
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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, {
|
|
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',
|
|
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
|
|
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
|
+
})
|