@sonicjs-cms/core 2.18.1 → 3.0.0-beta.10
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/README.md +52 -52
- package/dist/admin-documents-form.template-DDSH6ROU.js +6 -0
- package/dist/{admin-layout-catalyst.template-UMTIN66R.js.map → admin-documents-form.template-DDSH6ROU.js.map} +1 -1
- package/dist/admin-documents-form.template-LSZKGA5J.cjs +19 -0
- package/dist/{admin-layout-catalyst.template-HFD37TY5.cjs.map → admin-documents-form.template-LSZKGA5J.cjs.map} +1 -1
- package/dist/{filter-bar.template-DlVYMk-T.d.cts → admin-layout-catalyst.template-DrwDUfsE.d.cts} +25 -1
- package/dist/{filter-bar.template-DlVYMk-T.d.ts → admin-layout-catalyst.template-DrwDUfsE.d.ts} +25 -1
- package/dist/admin-layout-catalyst.template-KDHKVLXR.cjs +21 -0
- package/dist/admin-layout-catalyst.template-KDHKVLXR.cjs.map +1 -0
- package/dist/admin-layout-catalyst.template-YQ4EMF2J.js +7 -0
- package/dist/admin-layout-catalyst.template-YQ4EMF2J.js.map +1 -0
- package/dist/app-Bo0X1OWX.d.ts +1268 -0
- package/dist/app-Do66yCcV.d.cts +1268 -0
- package/dist/cache-DDARE4QE.js +4 -0
- package/dist/cache-DDARE4QE.js.map +1 -0
- package/dist/cache-LVYS4BPL.cjs +33 -0
- package/dist/cache-LVYS4BPL.cjs.map +1 -0
- package/dist/chunk-2CB4KY7I.cjs +771 -0
- package/dist/chunk-2CB4KY7I.cjs.map +1 -0
- package/dist/{chunk-ABB34XUS.cjs → chunk-3KYKEXV7.cjs} +667 -19
- package/dist/chunk-3KYKEXV7.cjs.map +1 -0
- package/dist/chunk-4BTBSXMR.cjs +912 -0
- package/dist/chunk-4BTBSXMR.cjs.map +1 -0
- package/dist/{chunk-55RDMDOP.js → chunk-5V62WT6M.js} +181 -57
- package/dist/chunk-5V62WT6M.js.map +1 -0
- package/dist/{chunk-XXDFQERJ.js → chunk-6OC6MF3C.js} +7192 -9806
- package/dist/chunk-6OC6MF3C.js.map +1 -0
- package/dist/chunk-AI663NBO.js +821 -0
- package/dist/chunk-AI663NBO.js.map +1 -0
- package/dist/chunk-ALDRXTUO.js +273 -0
- package/dist/chunk-ALDRXTUO.js.map +1 -0
- package/dist/{chunk-TFNTM3OA.js → chunk-ATUPB6MN.js} +645 -15
- package/dist/chunk-ATUPB6MN.js.map +1 -0
- package/dist/chunk-BLMTL57B.js +767 -0
- package/dist/chunk-BLMTL57B.js.map +1 -0
- package/dist/{chunk-4ZSNJDLS.cjs → chunk-CRGUD4KC.cjs} +9 -9
- package/dist/chunk-CRGUD4KC.cjs.map +1 -0
- package/dist/chunk-F67UK75A.cjs +158 -0
- package/dist/chunk-F67UK75A.cjs.map +1 -0
- package/dist/chunk-GCDZZNIN.js +192 -0
- package/dist/chunk-GCDZZNIN.js.map +1 -0
- package/dist/chunk-HIKBY7MS.cjs +70 -0
- package/dist/chunk-HIKBY7MS.cjs.map +1 -0
- package/dist/chunk-IDCZBF35.js +1186 -0
- package/dist/chunk-IDCZBF35.js.map +1 -0
- package/dist/chunk-IESEVHXL.js +66 -0
- package/dist/chunk-IESEVHXL.js.map +1 -0
- package/dist/chunk-IGADDMXH.js +387 -0
- package/dist/chunk-IGADDMXH.js.map +1 -0
- package/dist/chunk-IHTXB7AT.cjs +276 -0
- package/dist/chunk-IHTXB7AT.cjs.map +1 -0
- package/dist/chunk-IVPRUGTY.js +242 -0
- package/dist/chunk-IVPRUGTY.js.map +1 -0
- package/dist/{chunk-SQ6FNXU2.cjs → chunk-IXUHXTHW.cjs} +2 -151
- package/dist/chunk-IXUHXTHW.cjs.map +1 -0
- package/dist/chunk-J6JTWD2A.cjs +100 -0
- package/dist/chunk-J6JTWD2A.cjs.map +1 -0
- package/dist/chunk-JEQ7FLOD.cjs +199 -0
- package/dist/chunk-JEQ7FLOD.cjs.map +1 -0
- package/dist/{chunk-ON5ZMSU4.js → chunk-JQISFW6U.js} +3 -3
- package/dist/chunk-JQISFW6U.js.map +1 -0
- package/dist/chunk-K25XHMM3.js +566 -0
- package/dist/chunk-K25XHMM3.js.map +1 -0
- package/dist/{chunk-UYJ6TJHX.cjs → chunk-K623Q6WD.cjs} +181 -56
- package/dist/chunk-K623Q6WD.cjs.map +1 -0
- package/dist/chunk-MUNO67TT.cjs +1219 -0
- package/dist/chunk-MUNO67TT.cjs.map +1 -0
- package/dist/chunk-N32OWET6.cjs +327 -0
- package/dist/chunk-N32OWET6.cjs.map +1 -0
- package/dist/chunk-NUKJ54GA.cjs +245 -0
- package/dist/chunk-NUKJ54GA.cjs.map +1 -0
- package/dist/{chunk-XWIA3HVX.js → chunk-OBA2RYZN.js} +6 -1249
- package/dist/chunk-OBA2RYZN.js.map +1 -0
- package/dist/chunk-PMGOBS6X.cjs +408 -0
- package/dist/chunk-PMGOBS6X.cjs.map +1 -0
- package/dist/{chunk-OHYBNCVL.cjs → chunk-PXNTCCPE.cjs} +10 -1256
- package/dist/chunk-PXNTCCPE.cjs.map +1 -0
- package/dist/chunk-PYVFXCSD.js +1828 -0
- package/dist/chunk-PYVFXCSD.js.map +1 -0
- package/dist/{chunk-MGFRZO24.js → chunk-QZGABF2M.js} +3 -149
- package/dist/chunk-QZGABF2M.js.map +1 -0
- package/dist/{chunk-T3Q5V33G.cjs → chunk-R4ILO3W6.cjs} +876 -829
- package/dist/chunk-R4ILO3W6.cjs.map +1 -0
- package/dist/chunk-RMRJGMDE.js +323 -0
- package/dist/chunk-RMRJGMDE.js.map +1 -0
- package/dist/chunk-RNZFGN4R.js +88 -0
- package/dist/chunk-RNZFGN4R.js.map +1 -0
- package/dist/chunk-RQ6N3FTV.js +900 -0
- package/dist/chunk-RQ6N3FTV.js.map +1 -0
- package/dist/{chunk-SXXTQETM.cjs → chunk-TO6EY4P7.cjs} +8722 -11323
- package/dist/chunk-TO6EY4P7.cjs.map +1 -0
- package/dist/chunk-V464XBYS.js +154 -0
- package/dist/chunk-V464XBYS.js.map +1 -0
- package/dist/chunk-YA3TJ65D.cjs +575 -0
- package/dist/chunk-YA3TJ65D.cjs.map +1 -0
- package/dist/chunk-YP7GW2G5.cjs +866 -0
- package/dist/chunk-YP7GW2G5.cjs.map +1 -0
- package/dist/{collection-config-B4PG-AaF.d.cts → collection-config-JgHOpFCG.d.cts} +30 -2
- package/dist/{collection-config-B4PG-AaF.d.ts → collection-config-JgHOpFCG.d.ts} +30 -2
- package/dist/config-HFXANXCC.js +6 -0
- package/dist/config-HFXANXCC.js.map +1 -0
- package/dist/config-ON6FNMYX.cjs +19 -0
- package/dist/config-ON6FNMYX.cjs.map +1 -0
- package/dist/define-plugin-BzNHc1ZI.d.ts +1321 -0
- package/dist/define-plugin-IWDKYaVm.d.cts +1321 -0
- package/dist/document-projection-TDWRJX3Z.cjs +13 -0
- package/dist/document-projection-TDWRJX3Z.cjs.map +1 -0
- package/dist/document-projection-YYMC6I4U.js +4 -0
- package/dist/document-projection-YYMC6I4U.js.map +1 -0
- package/dist/index.cjs +13737 -4327
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +331 -493
- package/dist/index.d.ts +331 -493
- package/dist/index.js +13456 -4068
- package/dist/index.js.map +1 -1
- package/dist/middleware.cjs +38 -32
- package/dist/middleware.d.cts +69 -7
- package/dist/middleware.d.ts +69 -7
- package/dist/middleware.js +9 -3
- package/dist/migrations-2XHQEGOQ.cjs +13 -0
- package/dist/{migrations-IYNTWDC6.cjs.map → migrations-2XHQEGOQ.cjs.map} +1 -1
- package/dist/migrations-PE3CDVSM.js +4 -0
- package/dist/{migrations-R337UD46.js.map → migrations-PE3CDVSM.js.map} +1 -1
- package/dist/{plugin-bootstrap-DfVerYV4.d.cts → plugin-bootstrap-B8ThJU21.d.cts} +4315 -1661
- package/dist/{plugin-bootstrap-P_ciLp_C.d.ts → plugin-bootstrap-qu8hJgUt.d.ts} +4315 -1661
- package/dist/plugins.cjs +171 -12
- package/dist/plugins.d.cts +36 -2
- package/dist/plugins.d.ts +36 -2
- package/dist/plugins.js +5 -2
- package/dist/rbac-O73MFKDA.js +5 -0
- package/dist/rbac-O73MFKDA.js.map +1 -0
- package/dist/rbac-VONLJJKB.cjs +14 -0
- package/dist/rbac-VONLJJKB.cjs.map +1 -0
- package/dist/routes.cjs +42 -46
- package/dist/routes.d.cts +56 -146
- package/dist/routes.d.ts +56 -146
- package/dist/routes.js +18 -10
- package/dist/services.cjs +43 -76
- package/dist/services.d.cts +93 -55
- package/dist/services.d.ts +93 -55
- package/dist/services.js +6 -3
- package/dist/{telemetry-B9vIV4wh.d.cts → telemetry-Cku1ax74.d.cts} +1 -1
- package/dist/{telemetry-B9vIV4wh.d.ts → telemetry-Cku1ax74.d.ts} +1 -1
- package/dist/templates.cjs +17 -29
- package/dist/templates.d.cts +2 -89
- package/dist/templates.d.ts +2 -89
- package/dist/templates.js +3 -3
- package/dist/types-Dea1eNxU.d.cts +286 -0
- package/dist/types-Dea1eNxU.d.ts +286 -0
- package/dist/types.d.cts +2 -2
- package/dist/types.d.ts +2 -2
- package/dist/utils.cjs +21 -20
- package/dist/utils.d.cts +2 -2
- package/dist/utils.d.ts +2 -2
- package/dist/utils.js +3 -2
- package/migrations/0001_core.sql +184 -0
- package/migrations/0002_documents.sql +163 -0
- package/package.json +12 -7
- package/dist/admin-layout-catalyst.template-HFD37TY5.cjs +0 -17
- package/dist/admin-layout-catalyst.template-UMTIN66R.js +0 -7
- package/dist/app-C9esKLmh.d.cts +0 -112
- package/dist/app-C9esKLmh.d.ts +0 -112
- package/dist/chunk-4R3NOOL3.js +0 -2217
- package/dist/chunk-4R3NOOL3.js.map +0 -1
- package/dist/chunk-4ZSNJDLS.cjs.map +0 -1
- package/dist/chunk-55RDMDOP.js.map +0 -1
- package/dist/chunk-635JAMSE.cjs +0 -653
- package/dist/chunk-635JAMSE.cjs.map +0 -1
- package/dist/chunk-ABB34XUS.cjs.map +0 -1
- package/dist/chunk-C54YUA23.cjs +0 -2219
- package/dist/chunk-C54YUA23.cjs.map +0 -1
- package/dist/chunk-DSUJ5YQH.cjs +0 -722
- package/dist/chunk-DSUJ5YQH.cjs.map +0 -1
- package/dist/chunk-EW5NOBVU.js +0 -1783
- package/dist/chunk-EW5NOBVU.js.map +0 -1
- package/dist/chunk-EXNEW5US.js +0 -648
- package/dist/chunk-EXNEW5US.js.map +0 -1
- package/dist/chunk-I2H5NGJQ.js +0 -692
- package/dist/chunk-I2H5NGJQ.js.map +0 -1
- package/dist/chunk-MGFRZO24.js.map +0 -1
- package/dist/chunk-OHYBNCVL.cjs.map +0 -1
- package/dist/chunk-ON5ZMSU4.js.map +0 -1
- package/dist/chunk-QFWHAFEO.js +0 -1843
- package/dist/chunk-QFWHAFEO.js.map +0 -1
- package/dist/chunk-SQ6FNXU2.cjs.map +0 -1
- package/dist/chunk-SXXTQETM.cjs.map +0 -1
- package/dist/chunk-T3Q5V33G.cjs.map +0 -1
- package/dist/chunk-TFNTM3OA.js.map +0 -1
- package/dist/chunk-UYJ6TJHX.cjs.map +0 -1
- package/dist/chunk-WAEQXGCX.cjs +0 -1898
- package/dist/chunk-WAEQXGCX.cjs.map +0 -1
- package/dist/chunk-XWIA3HVX.js.map +0 -1
- package/dist/chunk-XXDFQERJ.js.map +0 -1
- package/dist/migrations-IYNTWDC6.cjs +0 -13
- package/dist/migrations-R337UD46.js +0 -4
- package/dist/plugin-manager-BoM3Q7o7.d.cts +0 -328
- package/dist/plugin-manager-Efx9RyDX.d.ts +0 -328
- package/migrations/001_initial_schema.sql +0 -170
- package/migrations/002_faq_plugin.sql +0 -86
- package/migrations/003_stage5_enhancements.sql +0 -121
- package/migrations/004_stage6_user_management.sql +0 -183
- package/migrations/005_stage7_workflow_automation.sql +0 -294
- package/migrations/006_plugin_system.sql +0 -155
- package/migrations/007_demo_login_plugin.sql +0 -23
- package/migrations/008_fix_slug_validation.sql +0 -22
- package/migrations/009_system_logging.sql +0 -57
- package/migrations/011_config_managed_collections.sql +0 -15
- package/migrations/012_testimonials_plugin.sql +0 -80
- package/migrations/013_code_examples_plugin.sql +0 -177
- package/migrations/014_fix_plugin_registry.sql +0 -88
- package/migrations/015_add_remaining_plugins.sql +0 -89
- package/migrations/016_remove_duplicate_cache_plugin.sql +0 -17
- package/migrations/017_auth_configurable_fields.sql +0 -49
- package/migrations/018_settings_table.sql +0 -23
- package/migrations/019_remove_blog_posts_collection.sql +0 -15
- package/migrations/020_add_email_plugin.sql +0 -22
- package/migrations/021_add_magic_link_auth_plugin.sql +0 -42
- package/migrations/022_add_tinymce_plugin.sql +0 -25
- package/migrations/023_add_easy_mdx_plugin.sql +0 -25
- package/migrations/024_add_quill_editor_plugin.sql +0 -25
- package/migrations/025_add_easymde_plugin.sql +0 -25
- package/migrations/026_add_otp_login.sql +0 -42
- package/migrations/027_fix_slug_field_type.sql +0 -18
- package/migrations/028_fix_slug_field_type_in_schemas.sql +0 -30
- package/migrations/029_add_forms_system.sql +0 -184
- package/migrations/030_add_turnstile_to_forms.sql +0 -14
- package/migrations/031_ai_search_plugin.sql +0 -45
- package/migrations/032_user_profiles.sql +0 -37
- package/migrations/033_form_content_integration.sql +0 -19
- package/migrations/034_security_audit_plugin.sql +0 -27
- package/migrations/035_user_profiles_data_column.sql +0 -16
- package/migrations/036_analytics_events.sql +0 -22
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var chunkJEQ7FLOD_cjs = require('./chunk-JEQ7FLOD.cjs');
|
|
4
|
+
|
|
5
|
+
// src/services/documents.ts
|
|
6
|
+
var DEFAULT_MAX_VERSIONS = 50;
|
|
7
|
+
function documentSecondsToMs(ts) {
|
|
8
|
+
return ts == null ? null : ts * 1e3;
|
|
9
|
+
}
|
|
10
|
+
function rowToDocument(row) {
|
|
11
|
+
return {
|
|
12
|
+
id: row.id,
|
|
13
|
+
rootId: row.root_id,
|
|
14
|
+
typeId: row.type_id,
|
|
15
|
+
typeVersion: row.type_version,
|
|
16
|
+
versionOfId: row.version_of_id,
|
|
17
|
+
versionNumber: row.version_number,
|
|
18
|
+
isCurrentDraft: row.is_current_draft === 1,
|
|
19
|
+
isPublished: row.is_published === 1,
|
|
20
|
+
status: row.status,
|
|
21
|
+
parentRootId: row.parent_root_id,
|
|
22
|
+
slug: row.slug,
|
|
23
|
+
path: row.path,
|
|
24
|
+
title: row.title,
|
|
25
|
+
zone: row.zone,
|
|
26
|
+
sortOrder: row.sort_order,
|
|
27
|
+
visible: row.visible === 1,
|
|
28
|
+
publishedAt: row.published_at,
|
|
29
|
+
scheduledAt: row.scheduled_at,
|
|
30
|
+
expiresAt: row.expires_at,
|
|
31
|
+
deletedAt: row.deleted_at,
|
|
32
|
+
tenantId: row.tenant_id,
|
|
33
|
+
locale: row.locale,
|
|
34
|
+
translationGroupId: row.translation_group_id,
|
|
35
|
+
data: JSON.parse(row.data),
|
|
36
|
+
metadata: JSON.parse(row.metadata),
|
|
37
|
+
ownerId: row.owner_id,
|
|
38
|
+
createdBy: row.created_by,
|
|
39
|
+
updatedBy: row.updated_by,
|
|
40
|
+
createdAt: row.created_at,
|
|
41
|
+
updatedAt: row.updated_at
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
var DocumentsService = class {
|
|
45
|
+
constructor(db, opts = {}) {
|
|
46
|
+
this.db = db;
|
|
47
|
+
this.opts = opts;
|
|
48
|
+
this.projection = new chunkJEQ7FLOD_cjs.DocumentProjection(db);
|
|
49
|
+
this.tenantId = opts.tenantId ?? "default";
|
|
50
|
+
this.versioning = opts.versioning ?? false;
|
|
51
|
+
}
|
|
52
|
+
projection;
|
|
53
|
+
tenantId;
|
|
54
|
+
versioning;
|
|
55
|
+
// ─── Create ───────────────────────────────────────────────────────────────
|
|
56
|
+
async create(input, createdBy) {
|
|
57
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
58
|
+
const id = chunkJEQ7FLOD_cjs.nanoid();
|
|
59
|
+
const publish = input.publishOnCreate ?? false;
|
|
60
|
+
const createdAt = input.createdAt ?? now;
|
|
61
|
+
const updatedAt = input.updatedAt ?? now;
|
|
62
|
+
const doc = {
|
|
63
|
+
id,
|
|
64
|
+
rootId: id,
|
|
65
|
+
typeId: input.typeId,
|
|
66
|
+
typeVersion: this.opts.typeSchemaVersion ?? 1,
|
|
67
|
+
versionOfId: null,
|
|
68
|
+
versionNumber: 1,
|
|
69
|
+
isCurrentDraft: true,
|
|
70
|
+
isPublished: publish,
|
|
71
|
+
status: publish ? "published" : "draft",
|
|
72
|
+
parentRootId: input.parentRootId ?? "",
|
|
73
|
+
slug: input.slug ?? null,
|
|
74
|
+
path: null,
|
|
75
|
+
title: input.title ?? null,
|
|
76
|
+
zone: input.zone ?? null,
|
|
77
|
+
sortOrder: input.sortOrder ?? 0,
|
|
78
|
+
visible: input.visible ?? true,
|
|
79
|
+
publishedAt: publish ? createdAt : null,
|
|
80
|
+
scheduledAt: input.scheduledAt ?? null,
|
|
81
|
+
expiresAt: input.expiresAt ?? null,
|
|
82
|
+
deletedAt: null,
|
|
83
|
+
// Tenant comes from the service scope unless the caller passes one explicitly. Never trust a
|
|
84
|
+
// request-body tenant: route handlers must override with the resolved request-context tenant.
|
|
85
|
+
tenantId: input.tenantId ?? this.tenantId,
|
|
86
|
+
locale: input.locale ?? "default",
|
|
87
|
+
translationGroupId: "",
|
|
88
|
+
data: input.data ?? {},
|
|
89
|
+
metadata: input.metadata ?? {},
|
|
90
|
+
ownerId: input.ownerId ?? null,
|
|
91
|
+
createdBy: createdBy ?? null,
|
|
92
|
+
updatedBy: createdBy ?? null,
|
|
93
|
+
createdAt,
|
|
94
|
+
updatedAt
|
|
95
|
+
};
|
|
96
|
+
const insertDoc = this.db.prepare(
|
|
97
|
+
`INSERT INTO documents (id, root_id, type_id, type_version, version_of_id, version_number,
|
|
98
|
+
is_current_draft, is_published, status, parent_root_id, slug, path, title, zone,
|
|
99
|
+
sort_order, visible, published_at, scheduled_at, expires_at, deleted_at,
|
|
100
|
+
tenant_id, locale, translation_group_id, data, metadata,
|
|
101
|
+
owner_id, created_by, updated_by, created_at, updated_at)
|
|
102
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
|
103
|
+
).bind(
|
|
104
|
+
doc.id,
|
|
105
|
+
doc.rootId,
|
|
106
|
+
doc.typeId,
|
|
107
|
+
doc.typeVersion,
|
|
108
|
+
null,
|
|
109
|
+
1,
|
|
110
|
+
1,
|
|
111
|
+
publish ? 1 : 0,
|
|
112
|
+
doc.status,
|
|
113
|
+
doc.parentRootId,
|
|
114
|
+
doc.slug,
|
|
115
|
+
null,
|
|
116
|
+
doc.title,
|
|
117
|
+
doc.zone,
|
|
118
|
+
doc.sortOrder,
|
|
119
|
+
doc.visible ? 1 : 0,
|
|
120
|
+
doc.publishedAt,
|
|
121
|
+
doc.scheduledAt,
|
|
122
|
+
doc.expiresAt,
|
|
123
|
+
null,
|
|
124
|
+
doc.tenantId,
|
|
125
|
+
doc.locale,
|
|
126
|
+
"",
|
|
127
|
+
JSON.stringify(doc.data),
|
|
128
|
+
JSON.stringify(doc.metadata),
|
|
129
|
+
doc.ownerId,
|
|
130
|
+
doc.createdBy,
|
|
131
|
+
doc.updatedBy,
|
|
132
|
+
createdAt,
|
|
133
|
+
updatedAt
|
|
134
|
+
);
|
|
135
|
+
const derivedInserts = this.projection.buildDerivedInsertStatements(doc, this.opts.queryableFields ?? [], now);
|
|
136
|
+
await this.db.batch([insertDoc, ...derivedInserts]);
|
|
137
|
+
return doc;
|
|
138
|
+
}
|
|
139
|
+
// ─── Save new draft ───────────────────────────────────────────────────────
|
|
140
|
+
// Atomically: demote previous draft → delete its derived rows (if not published) →
|
|
141
|
+
// insert new draft → materialize derived rows → prune excess versions.
|
|
142
|
+
async saveDraft(rootId, input, updatedBy) {
|
|
143
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
144
|
+
const newId = chunkJEQ7FLOD_cjs.nanoid();
|
|
145
|
+
const prevDraftRow = await this.db.prepare("SELECT * FROM documents WHERE root_id = ? AND tenant_id = ? AND is_current_draft = 1").bind(rootId, this.tenantId).first();
|
|
146
|
+
if (!prevDraftRow) throw new Error(`No current draft found for root ${rootId}`);
|
|
147
|
+
const prevDraft = rowToDocument(prevDraftRow);
|
|
148
|
+
const mergedData = { ...prevDraft.data, ...input.data ?? {} };
|
|
149
|
+
const mergedMeta = { ...prevDraft.metadata, ...input.metadata ?? {} };
|
|
150
|
+
const newDoc = {
|
|
151
|
+
...prevDraft,
|
|
152
|
+
id: newId,
|
|
153
|
+
rootId,
|
|
154
|
+
typeVersion: this.opts.typeSchemaVersion ?? prevDraft.typeVersion,
|
|
155
|
+
versionOfId: prevDraft.id,
|
|
156
|
+
versionNumber: 0,
|
|
157
|
+
// computed by SQL below
|
|
158
|
+
isCurrentDraft: true,
|
|
159
|
+
isPublished: false,
|
|
160
|
+
status: "draft",
|
|
161
|
+
slug: input.slug !== void 0 ? input.slug ?? null : prevDraft.slug,
|
|
162
|
+
title: input.title !== void 0 ? input.title ?? null : prevDraft.title,
|
|
163
|
+
zone: input.zone !== void 0 ? input.zone ?? null : prevDraft.zone,
|
|
164
|
+
sortOrder: input.sortOrder ?? prevDraft.sortOrder,
|
|
165
|
+
visible: input.visible ?? prevDraft.visible,
|
|
166
|
+
scheduledAt: input.scheduledAt !== void 0 ? input.scheduledAt : prevDraft.scheduledAt,
|
|
167
|
+
expiresAt: input.expiresAt !== void 0 ? input.expiresAt : prevDraft.expiresAt,
|
|
168
|
+
data: mergedData,
|
|
169
|
+
metadata: mergedMeta,
|
|
170
|
+
updatedBy: updatedBy ?? prevDraft.updatedBy,
|
|
171
|
+
updatedAt: now,
|
|
172
|
+
createdAt: now
|
|
173
|
+
};
|
|
174
|
+
const prevIsPublished = prevDraftRow.is_published === 1;
|
|
175
|
+
if (!this.versioning && !prevIsPublished) {
|
|
176
|
+
return this.updateInPlace(prevDraft, input, now, updatedBy);
|
|
177
|
+
}
|
|
178
|
+
const statements = [
|
|
179
|
+
// 1. Demote previous current draft FIRST (unique index: never two current drafts mid-batch).
|
|
180
|
+
this.db.prepare("UPDATE documents SET is_current_draft = 0, updated_at = ? WHERE id = ? AND tenant_id = ?").bind(now, prevDraft.id, this.tenantId),
|
|
181
|
+
// 2. If the previous draft was not also the published row, delete its derived rows.
|
|
182
|
+
...!prevIsPublished ? this.projection.buildDerivedDeleteStatements(prevDraft.id) : [],
|
|
183
|
+
// 3. Insert new draft. version_number derived in SQL (COALESCE(MAX)+1 from existing rows).
|
|
184
|
+
// R5 arithmetic — keep balanced: 30 columns = 5 leading '?' + 1 version_number subquery
|
|
185
|
+
// + 3 literals (1,0,'draft') + 21 trailing '?'. Total placeholders: 5 + 1 (subquery
|
|
186
|
+
// root_id) + 21 = 27, which MUST equal the 27 .bind() args below. Do not change one side
|
|
187
|
+
// without recounting the other.
|
|
188
|
+
this.db.prepare(
|
|
189
|
+
`INSERT INTO documents (id, root_id, type_id, type_version, version_of_id, version_number,
|
|
190
|
+
is_current_draft, is_published, status, parent_root_id, slug, path, title, zone,
|
|
191
|
+
sort_order, visible, published_at, scheduled_at, expires_at, deleted_at,
|
|
192
|
+
tenant_id, locale, translation_group_id, data, metadata,
|
|
193
|
+
owner_id, created_by, updated_by, created_at, updated_at)
|
|
194
|
+
SELECT ?,?,?,?,?,
|
|
195
|
+
(SELECT COALESCE(MAX(version_number), 0) + 1 FROM documents WHERE root_id = ?),
|
|
196
|
+
1,0,'draft',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?
|
|
197
|
+
WHERE 1=1`
|
|
198
|
+
).bind(
|
|
199
|
+
newId,
|
|
200
|
+
rootId,
|
|
201
|
+
newDoc.typeId,
|
|
202
|
+
newDoc.typeVersion,
|
|
203
|
+
prevDraft.id,
|
|
204
|
+
rootId,
|
|
205
|
+
newDoc.parentRootId,
|
|
206
|
+
newDoc.slug,
|
|
207
|
+
null,
|
|
208
|
+
newDoc.title,
|
|
209
|
+
newDoc.zone,
|
|
210
|
+
newDoc.sortOrder,
|
|
211
|
+
newDoc.visible ? 1 : 0,
|
|
212
|
+
null,
|
|
213
|
+
newDoc.scheduledAt,
|
|
214
|
+
newDoc.expiresAt,
|
|
215
|
+
null,
|
|
216
|
+
newDoc.tenantId,
|
|
217
|
+
newDoc.locale,
|
|
218
|
+
newDoc.translationGroupId,
|
|
219
|
+
JSON.stringify(newDoc.data),
|
|
220
|
+
JSON.stringify(newDoc.metadata),
|
|
221
|
+
newDoc.ownerId,
|
|
222
|
+
newDoc.createdBy,
|
|
223
|
+
newDoc.updatedBy,
|
|
224
|
+
now,
|
|
225
|
+
now
|
|
226
|
+
),
|
|
227
|
+
// 4. Materialize derived rows for new draft.
|
|
228
|
+
...this.projection.buildDerivedInsertStatements(newDoc, this.opts.queryableFields ?? [], now)
|
|
229
|
+
];
|
|
230
|
+
const maxVersions = this.opts.maxVersionsPerRoot ?? DEFAULT_MAX_VERSIONS;
|
|
231
|
+
statements.push(
|
|
232
|
+
this.db.prepare(
|
|
233
|
+
`DELETE FROM documents WHERE root_id = ? AND tenant_id = ? AND is_current_draft = 0 AND is_published = 0
|
|
234
|
+
AND id NOT IN (
|
|
235
|
+
SELECT id FROM documents WHERE root_id = ? AND tenant_id = ? AND is_current_draft = 0 AND is_published = 0
|
|
236
|
+
ORDER BY version_number DESC LIMIT ?
|
|
237
|
+
)
|
|
238
|
+
AND id NOT IN (SELECT version_of_id FROM documents WHERE version_of_id IS NOT NULL AND root_id = ? AND tenant_id = ?)`
|
|
239
|
+
).bind(rootId, this.tenantId, rootId, this.tenantId, maxVersions, rootId, this.tenantId)
|
|
240
|
+
);
|
|
241
|
+
await this.db.batch(statements);
|
|
242
|
+
const saved = await this.db.prepare("SELECT * FROM documents WHERE id = ?").bind(newId).first();
|
|
243
|
+
return rowToDocument(saved);
|
|
244
|
+
}
|
|
245
|
+
// In-place draft update (versioning off). Mutates the existing draft row; preserves id/root_id/
|
|
246
|
+
// version_number/version_of_id and the is_current_draft/is_published flags. Rebuilds derived rows.
|
|
247
|
+
async updateInPlace(prevDraft, input, now, updatedBy) {
|
|
248
|
+
const mergedData = { ...prevDraft.data, ...input.data ?? {} };
|
|
249
|
+
const mergedMeta = { ...prevDraft.metadata, ...input.metadata ?? {} };
|
|
250
|
+
const updated = {
|
|
251
|
+
...prevDraft,
|
|
252
|
+
slug: input.slug !== void 0 ? input.slug ?? null : prevDraft.slug,
|
|
253
|
+
title: input.title !== void 0 ? input.title ?? null : prevDraft.title,
|
|
254
|
+
zone: input.zone !== void 0 ? input.zone ?? null : prevDraft.zone,
|
|
255
|
+
sortOrder: input.sortOrder ?? prevDraft.sortOrder,
|
|
256
|
+
visible: input.visible ?? prevDraft.visible,
|
|
257
|
+
scheduledAt: input.scheduledAt !== void 0 ? input.scheduledAt : prevDraft.scheduledAt,
|
|
258
|
+
expiresAt: input.expiresAt !== void 0 ? input.expiresAt : prevDraft.expiresAt,
|
|
259
|
+
data: mergedData,
|
|
260
|
+
metadata: mergedMeta,
|
|
261
|
+
updatedBy: updatedBy ?? prevDraft.updatedBy,
|
|
262
|
+
updatedAt: now
|
|
263
|
+
};
|
|
264
|
+
const statements = [
|
|
265
|
+
// R5: 11 SET '?' + 2 WHERE '?' (id, tenant_id) = 13 binds, matching .bind() below.
|
|
266
|
+
this.db.prepare(
|
|
267
|
+
`UPDATE documents SET
|
|
268
|
+
slug = ?, title = ?, zone = ?, sort_order = ?, visible = ?,
|
|
269
|
+
scheduled_at = ?, expires_at = ?, data = ?, metadata = ?, updated_by = ?, updated_at = ?
|
|
270
|
+
WHERE id = ? AND tenant_id = ?`
|
|
271
|
+
).bind(
|
|
272
|
+
updated.slug,
|
|
273
|
+
updated.title,
|
|
274
|
+
updated.zone,
|
|
275
|
+
updated.sortOrder,
|
|
276
|
+
updated.visible ? 1 : 0,
|
|
277
|
+
updated.scheduledAt,
|
|
278
|
+
updated.expiresAt,
|
|
279
|
+
JSON.stringify(updated.data),
|
|
280
|
+
JSON.stringify(updated.metadata),
|
|
281
|
+
updated.updatedBy,
|
|
282
|
+
now,
|
|
283
|
+
updated.id,
|
|
284
|
+
this.tenantId
|
|
285
|
+
),
|
|
286
|
+
// R7: derived rows track the new data — delete then reinsert for this row.
|
|
287
|
+
...this.projection.buildDerivedDeleteStatements(updated.id),
|
|
288
|
+
...this.projection.buildDerivedInsertStatements(updated, this.opts.queryableFields ?? [], now)
|
|
289
|
+
];
|
|
290
|
+
await this.db.batch(statements);
|
|
291
|
+
const saved = await this.db.prepare("SELECT * FROM documents WHERE id = ?").bind(updated.id).first();
|
|
292
|
+
return rowToDocument(saved);
|
|
293
|
+
}
|
|
294
|
+
// ─── Publish ──────────────────────────────────────────────────────────────
|
|
295
|
+
async publish(documentId, publishedBy) {
|
|
296
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
297
|
+
const targetRow = await this.db.prepare("SELECT * FROM documents WHERE id = ? AND tenant_id = ?").bind(documentId, this.tenantId).first();
|
|
298
|
+
if (!targetRow) throw new Error(`Document ${documentId} not found`);
|
|
299
|
+
const prevPublishedRow = await this.db.prepare("SELECT * FROM documents WHERE root_id = ? AND tenant_id = ? AND is_published = 1 AND id != ?").bind(targetRow.root_id, this.tenantId, documentId).first();
|
|
300
|
+
const statements = [];
|
|
301
|
+
if (prevPublishedRow) {
|
|
302
|
+
if (!this.versioning && prevPublishedRow.is_current_draft !== 1) {
|
|
303
|
+
statements.push(this.db.prepare("UPDATE documents SET version_of_id = NULL WHERE version_of_id = ? AND tenant_id = ?").bind(prevPublishedRow.id, this.tenantId));
|
|
304
|
+
statements.push(...this.projection.buildDerivedDeleteStatements(prevPublishedRow.id));
|
|
305
|
+
statements.push(this.db.prepare("DELETE FROM documents WHERE id = ? AND tenant_id = ?").bind(prevPublishedRow.id, this.tenantId));
|
|
306
|
+
} else {
|
|
307
|
+
statements.push(
|
|
308
|
+
this.db.prepare("UPDATE documents SET is_published = 0, updated_at = ? WHERE id = ?").bind(now, prevPublishedRow.id)
|
|
309
|
+
);
|
|
310
|
+
if (prevPublishedRow.is_current_draft !== 1) {
|
|
311
|
+
statements.push(...this.projection.buildDerivedDeleteStatements(prevPublishedRow.id));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
statements.push(
|
|
316
|
+
this.db.prepare(
|
|
317
|
+
`UPDATE documents SET is_published = 1, status = 'published', published_at = ?, updated_at = ?, updated_by = ? WHERE id = ?`
|
|
318
|
+
).bind(now, now, publishedBy ?? null, documentId)
|
|
319
|
+
);
|
|
320
|
+
if (targetRow.is_current_draft !== 1) {
|
|
321
|
+
const targetDoc = rowToDocument(targetRow);
|
|
322
|
+
statements.push(...this.projection.buildDerivedInsertStatements(targetDoc, this.opts.queryableFields ?? [], now));
|
|
323
|
+
}
|
|
324
|
+
await this.db.batch(statements);
|
|
325
|
+
const saved = await this.db.prepare("SELECT * FROM documents WHERE id = ?").bind(documentId).first();
|
|
326
|
+
return rowToDocument(saved);
|
|
327
|
+
}
|
|
328
|
+
// ─── Unpublish ────────────────────────────────────────────────────────────
|
|
329
|
+
async unpublish(documentId) {
|
|
330
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
331
|
+
const row = await this.db.prepare("SELECT * FROM documents WHERE id = ? AND tenant_id = ?").bind(documentId, this.tenantId).first();
|
|
332
|
+
if (!row) throw new Error(`Document ${documentId} not found`);
|
|
333
|
+
if (!row.is_published) throw new Error(`Document ${documentId} is not published`);
|
|
334
|
+
const statements = [
|
|
335
|
+
this.db.prepare(`UPDATE documents SET is_published = 0, status = 'draft', updated_at = ? WHERE id = ?`).bind(now, documentId)
|
|
336
|
+
];
|
|
337
|
+
if (row.is_current_draft !== 1) {
|
|
338
|
+
statements.push(...this.projection.buildDerivedDeleteStatements(documentId));
|
|
339
|
+
}
|
|
340
|
+
await this.db.batch(statements);
|
|
341
|
+
const saved = await this.db.prepare("SELECT * FROM documents WHERE id = ?").bind(documentId).first();
|
|
342
|
+
return rowToDocument(saved);
|
|
343
|
+
}
|
|
344
|
+
// ─── Soft delete ──────────────────────────────────────────────────────────
|
|
345
|
+
async softDelete(documentId) {
|
|
346
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
347
|
+
await this.db.prepare("UPDATE documents SET deleted_at = ?, updated_at = ? WHERE id = ? AND tenant_id = ?").bind(now, now, documentId, this.tenantId).run();
|
|
348
|
+
}
|
|
349
|
+
// ─── Hard erase (PII types) ───────────────────────────────────────────────
|
|
350
|
+
// Deletes every version row for a root plus all derived data, in dependency order.
|
|
351
|
+
async erase(rootId, tenantId) {
|
|
352
|
+
const result = await this.db.prepare("SELECT id FROM documents WHERE root_id = ? AND tenant_id = ?").bind(rootId, tenantId).all();
|
|
353
|
+
const docIds = (result.results ?? []).map((r) => r.id);
|
|
354
|
+
if (docIds.length === 0) return;
|
|
355
|
+
const statements = [];
|
|
356
|
+
for (const id of docIds) {
|
|
357
|
+
statements.push(this.db.prepare("DELETE FROM document_facets WHERE document_id = ?").bind(id));
|
|
358
|
+
statements.push(this.db.prepare("DELETE FROM document_references WHERE from_document_id = ?").bind(id));
|
|
359
|
+
}
|
|
360
|
+
statements.push(this.db.prepare("DELETE FROM document_permissions WHERE root_id = ? AND tenant_id = ?").bind(rootId, tenantId));
|
|
361
|
+
for (const id of docIds) {
|
|
362
|
+
statements.push(this.db.prepare("DELETE FROM documents WHERE id = ?").bind(id));
|
|
363
|
+
}
|
|
364
|
+
await this.db.batch(statements);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
// src/services/rbac.ts
|
|
369
|
+
var TENANT = "default";
|
|
370
|
+
var T_ROLE = "rbac_role";
|
|
371
|
+
var T_VERB = "rbac_verb";
|
|
372
|
+
var T_USER_ROLES = "rbac_user_roles";
|
|
373
|
+
var SYSTEM_RESOURCES = [
|
|
374
|
+
{ key: "*", label: "All resources", group: "system" },
|
|
375
|
+
{ key: "portal", label: "Admin Portal", group: "system" },
|
|
376
|
+
{ key: "dashboard", label: "Dashboard", group: "system" },
|
|
377
|
+
{ key: "rbac", label: "Roles & Permissions", group: "system" },
|
|
378
|
+
{ key: "documents", label: "Documents", group: "system" },
|
|
379
|
+
{ key: "document_types", label: "Document Types", group: "system" },
|
|
380
|
+
{ key: "email", label: "Email Management", group: "system" },
|
|
381
|
+
{ key: "users", label: "Users", group: "system" },
|
|
382
|
+
{ key: "settings", label: "Settings", group: "system" },
|
|
383
|
+
{ key: "logs", label: "Logs", group: "system" }
|
|
384
|
+
];
|
|
385
|
+
var RbacService = class _RbacService {
|
|
386
|
+
constructor(db, kv) {
|
|
387
|
+
this.db = db;
|
|
388
|
+
this.kv = kv;
|
|
389
|
+
}
|
|
390
|
+
// Precedence for projecting the user's RBAC roles back onto the legacy
|
|
391
|
+
// users.role compat column (highest privilege first). Only `admin` is
|
|
392
|
+
// hardcoded as a seeded role — `editor` is listed here purely so that if an
|
|
393
|
+
// administrator chooses to recreate a role named `editor`, legacy code that
|
|
394
|
+
// still gates on the `editor` label keeps working.
|
|
395
|
+
static LEGACY_ROLE_PRECEDENCE = ["admin", "editor"];
|
|
396
|
+
_docs;
|
|
397
|
+
// ── Document access helpers ──────────────────────────────────────────────────
|
|
398
|
+
docs() {
|
|
399
|
+
if (!this._docs) {
|
|
400
|
+
this._docs = new DocumentsService(this.db, { tenantId: TENANT, maxVersionsPerRoot: 1, queryableFields: [] });
|
|
401
|
+
}
|
|
402
|
+
return this._docs;
|
|
403
|
+
}
|
|
404
|
+
parse(row) {
|
|
405
|
+
let data;
|
|
406
|
+
try {
|
|
407
|
+
data = JSON.parse(row.data);
|
|
408
|
+
} catch {
|
|
409
|
+
data = {};
|
|
410
|
+
}
|
|
411
|
+
return { id: row.id, rootId: row.root_id, slug: row.slug ?? "", data };
|
|
412
|
+
}
|
|
413
|
+
async listDocs(typeId) {
|
|
414
|
+
const res = await this.db.prepare(
|
|
415
|
+
`SELECT id, root_id, slug, data FROM documents
|
|
416
|
+
WHERE type_id = ? AND tenant_id = ? AND is_current_draft = 1 AND deleted_at IS NULL`
|
|
417
|
+
).bind(typeId, TENANT).all();
|
|
418
|
+
return (res.results ?? []).map((r) => this.parse(r));
|
|
419
|
+
}
|
|
420
|
+
async getDoc(typeId, slug) {
|
|
421
|
+
const row = await this.db.prepare(
|
|
422
|
+
`SELECT id, root_id, slug, data FROM documents
|
|
423
|
+
WHERE type_id = ? AND tenant_id = ? AND slug = ? AND is_current_draft = 1 AND deleted_at IS NULL`
|
|
424
|
+
).bind(typeId, TENANT, slug).first();
|
|
425
|
+
return row ? this.parse(row) : null;
|
|
426
|
+
}
|
|
427
|
+
async upsertDoc(typeId, slug, data, title) {
|
|
428
|
+
const existing = await this.db.prepare(
|
|
429
|
+
`SELECT root_id FROM documents
|
|
430
|
+
WHERE type_id = ? AND tenant_id = ? AND slug = ? AND is_current_draft = 1 AND deleted_at IS NULL`
|
|
431
|
+
).bind(typeId, TENANT, slug).first();
|
|
432
|
+
const payload = data;
|
|
433
|
+
if (existing?.root_id) {
|
|
434
|
+
await this.docs().saveDraft(existing.root_id, { data: payload, title });
|
|
435
|
+
} else {
|
|
436
|
+
await this.docs().create({
|
|
437
|
+
typeId,
|
|
438
|
+
tenantId: TENANT,
|
|
439
|
+
locale: "default",
|
|
440
|
+
parentRootId: "",
|
|
441
|
+
slug,
|
|
442
|
+
title,
|
|
443
|
+
sortOrder: 0,
|
|
444
|
+
visible: true,
|
|
445
|
+
data: payload,
|
|
446
|
+
metadata: {},
|
|
447
|
+
ownerId: null,
|
|
448
|
+
publishOnCreate: false
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
async deleteDoc(typeId, slug) {
|
|
453
|
+
const doc = await this.getDoc(typeId, slug);
|
|
454
|
+
if (doc) await this.docs().softDelete(doc.id);
|
|
455
|
+
}
|
|
456
|
+
roleToRow(d) {
|
|
457
|
+
return {
|
|
458
|
+
id: d.slug,
|
|
459
|
+
name: d.data.name,
|
|
460
|
+
display_name: d.data.displayName,
|
|
461
|
+
description: d.data.description ?? null,
|
|
462
|
+
is_system: d.data.isSystem ? 1 : 0
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
// ── Reads ────────────────────────────────────────────────────────────────────
|
|
466
|
+
async getRoles() {
|
|
467
|
+
const docs = await this.listDocs(T_ROLE);
|
|
468
|
+
return docs.map((d) => this.roleToRow(d)).sort((a, b) => b.is_system - a.is_system || a.name.localeCompare(b.name));
|
|
469
|
+
}
|
|
470
|
+
async getVerbs() {
|
|
471
|
+
const docs = await this.listDocs(T_VERB);
|
|
472
|
+
return docs.map((d) => ({
|
|
473
|
+
id: d.slug,
|
|
474
|
+
name: d.data.name,
|
|
475
|
+
description: d.data.description ?? null,
|
|
476
|
+
is_system: d.data.isSystem ? 1 : 0,
|
|
477
|
+
sort_order: d.data.sortOrder ?? 100
|
|
478
|
+
})).sort((a, b) => a.sort_order - b.sort_order || a.name.localeCompare(b.name));
|
|
479
|
+
}
|
|
480
|
+
/** System resources + one `document_type:<name>` per active document type. */
|
|
481
|
+
async getResources() {
|
|
482
|
+
const types = (await this.db.prepare("SELECT name, display_name FROM document_types WHERE is_active = 1 ORDER BY name").all()).results;
|
|
483
|
+
const documentTypeResources = [
|
|
484
|
+
{ key: "document_type:*", label: "All document types", group: "document_type" },
|
|
485
|
+
...types.map((t) => ({
|
|
486
|
+
key: `document_type:${t.name}`,
|
|
487
|
+
label: t.display_name || t.name,
|
|
488
|
+
group: "document_type"
|
|
489
|
+
}))
|
|
490
|
+
];
|
|
491
|
+
return [...SYSTEM_RESOURCES, ...documentTypeResources];
|
|
492
|
+
}
|
|
493
|
+
async getGrants() {
|
|
494
|
+
const roles = await this.listDocs(T_ROLE);
|
|
495
|
+
const out = [];
|
|
496
|
+
for (const r of roles) {
|
|
497
|
+
for (const g of r.data.grants ?? []) {
|
|
498
|
+
out.push({ role_id: r.slug, resource: g.resource, verb: g.verb, scope: g.scope === "own" ? "own" : "any" });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return out;
|
|
502
|
+
}
|
|
503
|
+
async getRolesForUser(userId) {
|
|
504
|
+
const ur = await this.getDoc(T_USER_ROLES, userId);
|
|
505
|
+
const roleIds = new Set(ur?.data.roleIds ?? []);
|
|
506
|
+
if (roleIds.size === 0) return [];
|
|
507
|
+
const roles = await this.listDocs(T_ROLE);
|
|
508
|
+
return roles.filter((r) => roleIds.has(r.slug)).map((d) => this.roleToRow(d));
|
|
509
|
+
}
|
|
510
|
+
/** Grants attached to a set of role ids (from the embedded role grants). */
|
|
511
|
+
async grantsForRoleIds(roleIds) {
|
|
512
|
+
if (roleIds.length === 0) return [];
|
|
513
|
+
const want = new Set(roleIds);
|
|
514
|
+
const roles = await this.listDocs(T_ROLE);
|
|
515
|
+
const out = [];
|
|
516
|
+
for (const r of roles) {
|
|
517
|
+
if (!want.has(r.slug)) continue;
|
|
518
|
+
for (const g of r.data.grants ?? []) {
|
|
519
|
+
out.push({ resource: g.resource, verb: g.verb, scope: g.scope === "own" ? "own" : "any" });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
/** Does a single grant row satisfy the requested (resource, verb)? */
|
|
525
|
+
grantMatches(g, resource, verb) {
|
|
526
|
+
const resourceOk = g.resource === "*" || g.resource === resource || g.resource === "document_type:*" && resource.startsWith("document_type:");
|
|
527
|
+
if (!resourceOk) return false;
|
|
528
|
+
return g.verb === "*" || g.verb === verb || g.verb === "manage";
|
|
529
|
+
}
|
|
530
|
+
strongestScope(scopes) {
|
|
531
|
+
if (scopes.includes("any")) return "any";
|
|
532
|
+
if (scopes.includes("own")) return "own";
|
|
533
|
+
return "none";
|
|
534
|
+
}
|
|
535
|
+
/** Can the user perform `verb` on `resource`? Reads the live grant matrix. */
|
|
536
|
+
async can(userId, resource, verb) {
|
|
537
|
+
return await this.getPermissionScope(userId, resource, verb) !== "none";
|
|
538
|
+
}
|
|
539
|
+
/** Highest scope granted to the user for `resource:verb`. */
|
|
540
|
+
async getPermissionScope(userId, resource, verb) {
|
|
541
|
+
const ur = await this.getDoc(T_USER_ROLES, userId);
|
|
542
|
+
const grants = await this.grantsForRoleIds(ur?.data.roleIds ?? []);
|
|
543
|
+
return this.strongestScope(
|
|
544
|
+
grants.filter((g) => this.grantMatches(g, resource, verb)).map((g) => g.scope === "own" ? "own" : "any")
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
/** Flattened, human-readable permission list for a user. Cached in KV for 60 s. */
|
|
548
|
+
async permissionsForUser(userId) {
|
|
549
|
+
if (this.kv) {
|
|
550
|
+
const cached = await this.kv.get(`rbac:perms:${userId}`);
|
|
551
|
+
if (cached !== null) return JSON.parse(cached);
|
|
552
|
+
}
|
|
553
|
+
const ur = await this.getDoc(T_USER_ROLES, userId);
|
|
554
|
+
const roleIds = ur?.data.roleIds ?? [];
|
|
555
|
+
if (roleIds.length === 0) return [];
|
|
556
|
+
const grants = await this.grantsForRoleIds(roleIds);
|
|
557
|
+
const resources = await this.getResources();
|
|
558
|
+
const verbs = await this.getVerbs();
|
|
559
|
+
const out = /* @__PURE__ */ new Set();
|
|
560
|
+
for (const r of resources) {
|
|
561
|
+
for (const v of verbs) {
|
|
562
|
+
if (grants.some((g) => this.grantMatches(g, r.key, v.name))) out.add(`${r.key}:${v.name}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const result = [...out].sort();
|
|
566
|
+
if (this.kv) {
|
|
567
|
+
await this.kv.put(`rbac:perms:${userId}`, JSON.stringify(result), { expirationTtl: 60 });
|
|
568
|
+
}
|
|
569
|
+
return result;
|
|
570
|
+
}
|
|
571
|
+
// ── Mutations ──────────────────────────────────────────────────────────────
|
|
572
|
+
async createRole(name, displayName, description = "") {
|
|
573
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
574
|
+
const id = `role-${slug}`;
|
|
575
|
+
await this.upsertDoc(
|
|
576
|
+
T_ROLE,
|
|
577
|
+
id,
|
|
578
|
+
{ name: name.toLowerCase(), displayName, description, isSystem: false, grants: [] },
|
|
579
|
+
displayName
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
async deleteRole(roleId) {
|
|
583
|
+
const role = await this.getDoc(T_ROLE, roleId);
|
|
584
|
+
if (!role || role.data.isSystem) return;
|
|
585
|
+
await this.deleteDoc(T_ROLE, roleId);
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Update a role's display name and description. The `name` (slug) can only be
|
|
589
|
+
* changed for custom roles — system role names are referenced by the legacy
|
|
590
|
+
* mapping, so they stay fixed.
|
|
591
|
+
*/
|
|
592
|
+
async updateRole(roleId, displayName, description = "", name) {
|
|
593
|
+
const role = await this.getDoc(T_ROLE, roleId);
|
|
594
|
+
if (!role) return;
|
|
595
|
+
const next = { ...role.data, displayName, description };
|
|
596
|
+
if (!role.data.isSystem && name) {
|
|
597
|
+
next.name = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
598
|
+
}
|
|
599
|
+
await this.upsertDoc(T_ROLE, roleId, next, displayName);
|
|
600
|
+
}
|
|
601
|
+
/** Update displayName + portal access in a single write to avoid double-saveDraft FK issues. */
|
|
602
|
+
async updateRoleAndPortalAccess(roleId, displayName, name, portalEnabled, description) {
|
|
603
|
+
const role = await this.getDoc(T_ROLE, roleId);
|
|
604
|
+
if (!role) return;
|
|
605
|
+
const next = { ...role.data, displayName, description: description ?? role.data.description ?? "" };
|
|
606
|
+
if (!role.data.isSystem && name) {
|
|
607
|
+
next.name = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
608
|
+
}
|
|
609
|
+
const grants = (next.grants ?? []).filter((g) => !(g.resource === "portal" && g.verb === "access"));
|
|
610
|
+
if (portalEnabled) grants.push({ resource: "portal", verb: "access", scope: "any" });
|
|
611
|
+
next.grants = grants;
|
|
612
|
+
await this.upsertDoc(T_ROLE, roleId, next, displayName);
|
|
613
|
+
}
|
|
614
|
+
async createVerb(name, description = "") {
|
|
615
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
616
|
+
const id = `verb-${slug}`;
|
|
617
|
+
await this.upsertDoc(
|
|
618
|
+
T_VERB,
|
|
619
|
+
id,
|
|
620
|
+
{ name: name.toLowerCase(), description, isSystem: false, sortOrder: 100 },
|
|
621
|
+
name
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
async deleteVerb(verbId) {
|
|
625
|
+
const verb = await this.getDoc(T_VERB, verbId);
|
|
626
|
+
if (!verb || verb.data.isSystem) return;
|
|
627
|
+
await this.deleteDoc(T_VERB, verbId);
|
|
628
|
+
}
|
|
629
|
+
/** Replace all grants for one role with the supplied (resource, verb, scope) rows. */
|
|
630
|
+
async setRoleGrants(roleId, pairs) {
|
|
631
|
+
const role = await this.getDoc(T_ROLE, roleId);
|
|
632
|
+
if (!role) return;
|
|
633
|
+
const grants = pairs.map((p) => ({ resource: p.resource, verb: p.verb, scope: p.scope === "own" ? "own" : "any" }));
|
|
634
|
+
await this.upsertDoc(T_ROLE, roleId, { ...role.data, grants }, role.data.displayName);
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Count active users (optionally excluding one) who hold BOTH an effective
|
|
638
|
+
* portal:access grant and an effective rbac:manage grant — the users who could
|
|
639
|
+
* recover from a permission lockout. Powers the self-lockout guard.
|
|
640
|
+
*/
|
|
641
|
+
async countPortalAdmins(excludeUserId) {
|
|
642
|
+
const active = (await this.db.prepare("SELECT id FROM auth_user WHERE is_active = 1").all()).results;
|
|
643
|
+
const activeIds = new Set(active.map((u) => u.id));
|
|
644
|
+
const roles = await this.listDocs(T_ROLE);
|
|
645
|
+
const grantsByRole = new Map(roles.map((r) => [r.slug, r.data.grants ?? []]));
|
|
646
|
+
const userRoles = await this.listDocs(T_USER_ROLES);
|
|
647
|
+
let count = 0;
|
|
648
|
+
for (const ur of userRoles) {
|
|
649
|
+
const userId = ur.slug;
|
|
650
|
+
if (!activeIds.has(userId)) continue;
|
|
651
|
+
if (excludeUserId && userId === excludeUserId) continue;
|
|
652
|
+
let portal = false;
|
|
653
|
+
let rbac = false;
|
|
654
|
+
for (const rid of ur.data.roleIds ?? []) {
|
|
655
|
+
for (const g of grantsByRole.get(rid) ?? []) {
|
|
656
|
+
if (this.grantMatches(g, "portal", "access")) portal = true;
|
|
657
|
+
if (this.grantMatches(g, "rbac", "manage")) rbac = true;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (portal && rbac) count++;
|
|
661
|
+
}
|
|
662
|
+
return count;
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Replace a user's RBAC role assignments. The `rbac_user_roles` document is the
|
|
666
|
+
* single source of truth for authorization; the legacy `auth_user.role` column
|
|
667
|
+
* is kept as a derived projection (highest-precedence system role, else
|
|
668
|
+
* 'viewer') so the two never diverge.
|
|
669
|
+
*/
|
|
670
|
+
async setUserRoles(userId, roleIds) {
|
|
671
|
+
const allRoles = await this.listDocs(T_ROLE);
|
|
672
|
+
const byId = new Map(allRoles.map((r) => [r.slug, r.data]));
|
|
673
|
+
const names = roleIds.map((id) => byId.get(id)?.name).filter((n) => !!n);
|
|
674
|
+
const primaryRole = _RbacService.LEGACY_ROLE_PRECEDENCE.find((r) => names.includes(r)) || "viewer";
|
|
675
|
+
const newGrants = [];
|
|
676
|
+
for (const id of roleIds) for (const g of byId.get(id)?.grants ?? []) newGrants.push(g);
|
|
677
|
+
const userWillBeAdmin = newGrants.some((g) => this.grantMatches(g, "portal", "access")) && newGrants.some((g) => this.grantMatches(g, "rbac", "manage"));
|
|
678
|
+
if (!userWillBeAdmin && await this.countPortalAdmins(userId) === 0) {
|
|
679
|
+
throw new Error(
|
|
680
|
+
"Refusing to update roles: this would leave no user able to manage Roles & Permissions and access the portal. Grant another user portal access + Roles & Permissions first."
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
await this.upsertDoc(T_USER_ROLES, userId, { roleIds }, null);
|
|
684
|
+
await this.db.prepare("UPDATE auth_user SET role = ?, updated_at = ? WHERE id = ?").bind(primaryRole, Date.now(), userId).run();
|
|
685
|
+
if (this.kv) await this.kv.delete(`rbac:perms:${userId}`);
|
|
686
|
+
}
|
|
687
|
+
async setRolePortalAccess(roleId, enabled) {
|
|
688
|
+
const role = await this.getDoc(T_ROLE, roleId);
|
|
689
|
+
if (!role) return;
|
|
690
|
+
const grants = (role.data.grants ?? []).filter((g) => !(g.resource === "portal" && g.verb === "access"));
|
|
691
|
+
if (enabled) grants.push({ resource: "portal", verb: "access", scope: "any" });
|
|
692
|
+
await this.upsertDoc(T_ROLE, roleId, { ...role.data, grants }, role.data.displayName);
|
|
693
|
+
}
|
|
694
|
+
// ── Bootstrap helpers ────────────────────────────────────────────────────────
|
|
695
|
+
/**
|
|
696
|
+
* Seed the system roles, verbs, and their grants as documents. Idempotent —
|
|
697
|
+
* existing roles/verbs (by slug) are left untouched. Replaces the INSERT OR
|
|
698
|
+
* IGNORE seeds that lived in migration 0001. Call at bootstrap, after the rbac
|
|
699
|
+
* document types are registered.
|
|
700
|
+
*/
|
|
701
|
+
async ensureSystemRbacSeed() {
|
|
702
|
+
const roles = [
|
|
703
|
+
{
|
|
704
|
+
id: "role-admin",
|
|
705
|
+
name: "admin",
|
|
706
|
+
displayName: "Administrator",
|
|
707
|
+
description: "Full access to everything",
|
|
708
|
+
isSystem: true,
|
|
709
|
+
grants: [
|
|
710
|
+
{ resource: "*", verb: "manage" },
|
|
711
|
+
{ resource: "portal", verb: "access" },
|
|
712
|
+
{ resource: "rbac", verb: "manage" },
|
|
713
|
+
{ resource: "document_types", verb: "manage" },
|
|
714
|
+
{ resource: "email", verb: "manage" },
|
|
715
|
+
{ resource: "users", verb: "manage" }
|
|
716
|
+
]
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
id: "role-editor",
|
|
720
|
+
name: "editor",
|
|
721
|
+
displayName: "Editor",
|
|
722
|
+
description: "Manage documents across all types",
|
|
723
|
+
isSystem: false,
|
|
724
|
+
grants: [
|
|
725
|
+
{ resource: "portal", verb: "access" },
|
|
726
|
+
{ resource: "documents", verb: "manage" },
|
|
727
|
+
{ resource: "document_type:*", verb: "read" },
|
|
728
|
+
{ resource: "document_type:*", verb: "create" },
|
|
729
|
+
{ resource: "document_type:*", verb: "update" },
|
|
730
|
+
{ resource: "document_type:*", verb: "delete" },
|
|
731
|
+
{ resource: "settings", verb: "read" }
|
|
732
|
+
]
|
|
733
|
+
}
|
|
734
|
+
];
|
|
735
|
+
const verbs = [
|
|
736
|
+
{ id: "verb-access", name: "access", description: "Enter or use a portal/resource", isSystem: true, sortOrder: 5 },
|
|
737
|
+
{ id: "verb-read", name: "read", description: "View a resource", isSystem: true, sortOrder: 10 },
|
|
738
|
+
{ id: "verb-create", name: "create", description: "Create a resource", isSystem: true, sortOrder: 20 },
|
|
739
|
+
{ id: "verb-update", name: "update", description: "Edit a resource", isSystem: true, sortOrder: 30 },
|
|
740
|
+
{ id: "verb-delete", name: "delete", description: "Remove a resource", isSystem: true, sortOrder: 40 },
|
|
741
|
+
{ id: "verb-manage", name: "manage", description: "Full control (implies all verbs)", isSystem: true, sortOrder: 50 }
|
|
742
|
+
];
|
|
743
|
+
for (const r of roles) {
|
|
744
|
+
if (await this.getDoc(T_ROLE, r.id)) continue;
|
|
745
|
+
const { id, ...data } = r;
|
|
746
|
+
await this.upsertDoc(T_ROLE, id, data, r.displayName);
|
|
747
|
+
}
|
|
748
|
+
for (const v of verbs) {
|
|
749
|
+
if (await this.getDoc(T_VERB, v.id)) continue;
|
|
750
|
+
const { id, ...data } = v;
|
|
751
|
+
await this.upsertDoc(T_VERB, id, data, v.name);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
/** Assign a role to a user by role name (e.g. 'admin'), preserving existing roles. */
|
|
755
|
+
async addUserRoleByName(userId, roleName) {
|
|
756
|
+
const roles = await this.listDocs(T_ROLE);
|
|
757
|
+
const role = roles.find((r) => r.data.name === roleName.toLowerCase());
|
|
758
|
+
if (!role) return;
|
|
759
|
+
const ur = await this.getDoc(T_USER_ROLES, userId);
|
|
760
|
+
const roleIds = new Set(ur?.data.roleIds ?? []);
|
|
761
|
+
if (roleIds.has(role.slug)) return;
|
|
762
|
+
roleIds.add(role.slug);
|
|
763
|
+
await this.setUserRoles(userId, [...roleIds]);
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
exports.DocumentsService = DocumentsService;
|
|
768
|
+
exports.RbacService = RbacService;
|
|
769
|
+
exports.documentSecondsToMs = documentSecondsToMs;
|
|
770
|
+
//# sourceMappingURL=chunk-2CB4KY7I.cjs.map
|
|
771
|
+
//# sourceMappingURL=chunk-2CB4KY7I.cjs.map
|