@happyvertical/smrt-preferences 0.0.0-stage → 0.55.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/AGENTS.md +133 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +134 -2
- package/agents/preference-kinds.md +79 -0
- package/dist/__smrt-register__.d.ts +2 -0
- package/dist/__smrt-register__.d.ts.map +1 -0
- package/dist/collections/UiPreferenceRecordCollection.d.ts +18 -0
- package/dist/collections/UiPreferenceRecordCollection.d.ts.map +1 -0
- package/dist/context.d.ts +36 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +990 -0
- package/dist/index.js.map +1 -0
- package/dist/kinds/builtin.d.ts +2 -0
- package/dist/kinds/builtin.d.ts.map +1 -0
- package/dist/kinds/overview.d.ts +32 -0
- package/dist/kinds/overview.d.ts.map +1 -0
- package/dist/kinds/shell-layout.d.ts +59 -0
- package/dist/kinds/shell-layout.d.ts.map +1 -0
- package/dist/kinds.d.ts +77 -0
- package/dist/kinds.d.ts.map +1 -0
- package/dist/manifest.json +533 -0
- package/dist/models/UiPreferenceRecord.d.ts +100 -0
- package/dist/models/UiPreferenceRecord.d.ts.map +1 -0
- package/dist/overview.d.ts +127 -0
- package/dist/overview.d.ts.map +1 -0
- package/dist/permissions.d.ts +22 -0
- package/dist/permissions.d.ts.map +1 -0
- package/dist/read-guard.d.ts +9 -0
- package/dist/read-guard.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +341 -0
- package/dist/store.d.ts +86 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/write-capability.d.ts +39 -0
- package/dist/write-capability.d.ts.map +1 -0
- package/package.json +63 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
import { GlobalInterceptors, ObjectRegistry, RuntimeError, SmrtCollection, SmrtObject, crossPackageRef, field, resolveGetStringFilter, smrt } from "@happyvertical/smrt-core";
|
|
2
|
+
import { assertOperationPermission, checkOperationPermission, getCurrentSessionPermissionContext, registerPermissionDefinitions } from "@happyvertical/smrt-users";
|
|
3
|
+
import { checkOverviewOverride, diffOverview, loadOverview, optionsEqual, parseOverviewDocument, resolveOverview, sanitizeOverview } from "@happyvertical/smrt-svelte/overview/server";
|
|
4
|
+
import { mergeShellSettingsDelta, sanitizeShellSettingsDelta } from "@happyvertical/smrt-svelte/workspace/server";
|
|
5
|
+
import { TenantIsolationError, TenantScoped, getCurrentTenant, isSuperAdminBypass, tenantId, withTenant } from "@happyvertical/smrt-tenancy";
|
|
6
|
+
//#region src/__smrt-register__.ts
|
|
7
|
+
ObjectRegistry.registerPackageManifest(JSON.parse(`{"version":"1.0.0","timestamp":0,"packageName":"@happyvertical/smrt-preferences","packageVersion":"0.55.10","objects":{"@happyvertical/smrt-preferences:UiPreferenceRecordCollection":{"name":"uipreferencerecordcollection","className":"UiPreferenceRecordCollection","qualifiedName":"@happyvertical/smrt-preferences:UiPreferenceRecordCollection","collection":"uipreferencerecords","description":"Reads of UiPreferenceRecord. Not decorated: the model has no generated surface, so a decorated collection would only add a second manifest schema for the same table.","filePath":"/home/runner/work/smrt/smrt/packages/preferences/src/collections/UiPreferenceRecordCollection.ts","packageName":"@happyvertical/smrt-preferences","fields":{},"methods":{"findTier":{"name":"findTier","async":true,"parameters":[{"name":"tenantId","type":"string","optional":false},{"name":"kind","type":"string","optional":false},{"name":"surfaceId","type":"string","optional":false},{"name":"scope","type":"PreferenceScope","optional":false},{"name":"userId","type":"string","optional":true}],"returnType":"Promise<UiPreferenceRecord | null>","isStatic":false,"isPublic":true}},"decoratorConfig":{"tableName":"_smrt_ui_preferences","cli":false},"extends":"SmrtCollection","extendsTypeArg":"UiPreferenceRecord","exportName":"UiPreferenceRecordCollection","collectionExportName":"UiPreferenceRecordCollectionCollection","schema":{"tableName":"_smrt_ui_preferences","ddl":"CREATE TABLE IF NOT EXISTS \\"_smrt_ui_preferences\\" (\\n \\"id\\" UUID PRIMARY KEY NOT NULL,\\n \\"slug\\" TEXT NOT NULL,\\n \\"context\\" TEXT NOT NULL DEFAULT '',\\n \\"created_at\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\"updated_at\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\"tenant_id\\" UUID NOT NULL,\\n \\"kind\\" TEXT NOT NULL DEFAULT '',\\n \\"surface_id\\" TEXT NOT NULL DEFAULT '',\\n \\"scope_type\\" TEXT NOT NULL DEFAULT 'tenant',\\n \\"user_id\\" UUID,\\n \\"scope_key\\" TEXT NOT NULL,\\n \\"payload_json\\" TEXT NOT NULL,\\n \\"format_version\\" INTEGER,\\n \\"updated_by\\" UUID\\n);","columns":{"id":{"type":"UUID","primaryKey":true,"referenceKind":"id","notNull":true},"slug":{"type":"TEXT","notNull":true},"context":{"type":"TEXT","notNull":true,"default":""},"created_at":{"type":"TIMESTAMP","notNull":true,"default":"current_timestamp"},"updated_at":{"type":"TIMESTAMP","notNull":true,"default":"current_timestamp"},"tenant_id":{"type":"UUID","referenceKind":"tenantId","notNull":true,"unique":false},"kind":{"type":"TEXT","notNull":true,"unique":false,"default":""},"surface_id":{"type":"TEXT","notNull":true,"unique":false,"default":""},"scope_type":{"type":"TEXT","notNull":true,"unique":false,"default":"tenant"},"user_id":{"type":"UUID","referenceKind":"crossPackageRef","notNull":false,"unique":false},"scope_key":{"type":"TEXT","notNull":true,"unique":false},"payload_json":{"type":"TEXT","notNull":true,"unique":false},"format_version":{"type":"INTEGER","notNull":false,"unique":false},"updated_by":{"type":"UUID","referenceKind":"crossPackageRef","notNull":false,"unique":false}},"indexes":[{"name":"_smrt_ui_preferences_tenant_id_kind_idx","columns":["tenant_id","kind","surface_id","scope_type","scope_key"],"unique":true},{"name":"_smrt_ui_preferences_slug_context_idx","columns":["slug","context"]},{"name":"_smrt_ui_preferences_tenant_id_created_at_idx","columns":["tenant_id","created_at"]},{"name":"_smrt_ui_preferences_user_id_idx","columns":["user_id"]},{"name":"_smrt_ui_preferences_updated_by_idx","columns":["updated_by"]}],"version":"3f9df5fd"},"importPath":"@happyvertical/smrt-preferences"},"@happyvertical/smrt-preferences:UiPreferenceRecord":{"name":"uipreferencerecord","className":"UiPreferenceRecord","qualifiedName":"@happyvertical/smrt-preferences:UiPreferenceRecord","collection":"uipreferencerecords","description":"One stored user-interface preference (#3727): the canonical payload of a registered preference kind (overview, shell-layout, ...) for one surface at one tier of one tenant.","filePath":"/home/runner/work/smrt/smrt/packages/preferences/src/models/UiPreferenceRecord.ts","packageName":"@happyvertical/smrt-preferences","fields":{"tenantId":{"type":"text","required":true,"_meta":{"sqlType":"UUID","__tenancy":{"isTenantIdField":true,"autoFilter":true,"required":true,"autoPopulate":true,"nullable":false,"mode":"required","field":"tenantId","allowSuperAdminBypass":false}}},"kind":{"type":"text","required":true,"default":"","_meta":{"required":true}},"surfaceId":{"type":"text","required":true,"default":"","_meta":{"required":true}},"scopeType":{"type":"text","required":true,"default":"tenant","_meta":{"required":true}},"userId":{"type":"crossPackageRef","required":false,"related":"@happyvertical/smrt-users:User","_meta":{"nullable":true}},"scopeKey":{"type":"text","required":true,"_meta":{"required":true}},"payloadJson":{"type":"text","required":true,"_meta":{"required":true}},"formatVersion":{"type":"integer","required":false,"_meta":{}},"updatedBy":{"type":"crossPackageRef","required":false,"related":"@happyvertical/smrt-users:User","_meta":{"nullable":true}}},"methods":{"loadFromId":{"name":"loadFromId","async":true,"parameters":[],"returnType":"Promise<void>","isStatic":false,"isPublic":true},"loadFromSlug":{"name":"loadFromSlug","async":true,"parameters":[],"returnType":"Promise<void>","isStatic":false,"isPublic":true},"getPayload":{"name":"getPayload","async":false,"parameters":[],"returnType":"unknown","isStatic":false,"isPublic":true},"setPayload":{"name":"setPayload","async":false,"parameters":[{"name":"value","type":"unknown","optional":false}],"returnType":"void","isStatic":false,"isPublic":true},"save":{"name":"save","async":true,"parameters":[{"name":"options","type":"SaveOptions","optional":true}],"returnType":"Promise<this>","isStatic":false,"isPublic":true},"delete":{"name":"delete","async":true,"parameters":[{"name":"options","type":"DeleteOptions","optional":true}],"returnType":"Promise<void>","isStatic":false,"isPublic":true}},"decoratorConfig":{"tableName":"_smrt_ui_preferences","conflictColumns":["tenant_id","kind","surface_id","scope_type","scope_key"],"api":{"include":[]},"cli":false,"mcp":{"include":[]},"tenantScoped":{"mode":"required"}},"extends":"SmrtObject","exportName":"UiPreferenceRecord","collectionExportName":"UiPreferenceRecordCollection","validationRules":[{"field":"kind","rule":"required","fieldType":"text"},{"field":"surfaceId","rule":"required","fieldType":"text"},{"field":"scopeType","rule":"required","fieldType":"text"},{"field":"scopeKey","rule":"required","fieldType":"text"},{"field":"payloadJson","rule":"required","fieldType":"text"}],"schema":{"tableName":"_smrt_ui_preferences","ddl":"CREATE TABLE IF NOT EXISTS \\"_smrt_ui_preferences\\" (\\n \\"id\\" UUID PRIMARY KEY NOT NULL,\\n \\"slug\\" TEXT NOT NULL,\\n \\"context\\" TEXT NOT NULL DEFAULT '',\\n \\"created_at\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\"updated_at\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\"tenant_id\\" UUID NOT NULL,\\n \\"kind\\" TEXT NOT NULL DEFAULT '',\\n \\"surface_id\\" TEXT NOT NULL DEFAULT '',\\n \\"scope_type\\" TEXT NOT NULL DEFAULT 'tenant',\\n \\"user_id\\" UUID,\\n \\"scope_key\\" TEXT NOT NULL,\\n \\"payload_json\\" TEXT NOT NULL,\\n \\"format_version\\" INTEGER,\\n \\"updated_by\\" UUID\\n);","columns":{"id":{"type":"UUID","primaryKey":true,"referenceKind":"id","notNull":true},"slug":{"type":"TEXT","notNull":true},"context":{"type":"TEXT","notNull":true,"default":""},"created_at":{"type":"TIMESTAMP","notNull":true,"default":"current_timestamp"},"updated_at":{"type":"TIMESTAMP","notNull":true,"default":"current_timestamp"},"tenant_id":{"type":"UUID","referenceKind":"tenantId","notNull":true,"unique":false},"kind":{"type":"TEXT","notNull":true,"unique":false,"default":""},"surface_id":{"type":"TEXT","notNull":true,"unique":false,"default":""},"scope_type":{"type":"TEXT","notNull":true,"unique":false,"default":"tenant"},"user_id":{"type":"UUID","referenceKind":"crossPackageRef","notNull":false,"unique":false},"scope_key":{"type":"TEXT","notNull":true,"unique":false},"payload_json":{"type":"TEXT","notNull":true,"unique":false},"format_version":{"type":"INTEGER","notNull":false,"unique":false},"updated_by":{"type":"UUID","referenceKind":"crossPackageRef","notNull":false,"unique":false}},"indexes":[{"name":"_smrt_ui_preferences_tenant_id_kind_idx","columns":["tenant_id","kind","surface_id","scope_type","scope_key"],"unique":true},{"name":"_smrt_ui_preferences_slug_context_idx","columns":["slug","context"]},{"name":"_smrt_ui_preferences_tenant_id_created_at_idx","columns":["tenant_id","created_at"]},{"name":"_smrt_ui_preferences_user_id_idx","columns":["user_id"]},{"name":"_smrt_ui_preferences_updated_by_idx","columns":["updated_by"]}],"version":"3f9df5fd"},"importPath":"@happyvertical/smrt-preferences"}},"moduleType":"smrt","smrtDependencies":["@happyvertical/smrt-core","@happyvertical/smrt-tenancy","@happyvertical/smrt-users"],"browser":{"status":"server-only","issues":["#3617","#3624","#3626"],"via":["@happyvertical/smrt-profiles","@happyvertical/smrt-tenancy","@happyvertical/smrt-users"]},"stampsConstructors":true}`));
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/permissions.ts
|
|
10
|
+
var CUSTOMIZE_OVERVIEW_PERMISSION = "overviews.customize";
|
|
11
|
+
var PERSONALIZE_OVERVIEW_PERMISSION = "overviews.personalize";
|
|
12
|
+
var CUSTOMIZE_SHELL_PERMISSION = "shell.customize";
|
|
13
|
+
var PERSONALIZE_SHELL_PERMISSION = "shell.personalize";
|
|
14
|
+
var PREFERENCE_PERMISSION_DEFINITIONS = [
|
|
15
|
+
{
|
|
16
|
+
slug: CUSTOMIZE_OVERVIEW_PERMISSION,
|
|
17
|
+
category: "preferences",
|
|
18
|
+
name: "Customize Overviews",
|
|
19
|
+
description: "Change the default overview layouts everyone in the tenant sees"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
slug: PERSONALIZE_OVERVIEW_PERMISSION,
|
|
23
|
+
category: "preferences",
|
|
24
|
+
name: "Personalize Overviews",
|
|
25
|
+
description: "Keep a personal overview layout on top of the tenant default"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
slug: CUSTOMIZE_SHELL_PERMISSION,
|
|
29
|
+
category: "preferences",
|
|
30
|
+
name: "Customize Shell Layout",
|
|
31
|
+
description: "Change the default navigation and panel layout everyone in the tenant sees"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
slug: PERSONALIZE_SHELL_PERMISSION,
|
|
35
|
+
category: "preferences",
|
|
36
|
+
name: "Personalize Shell Layout",
|
|
37
|
+
description: "Keep a personal navigation and panel layout on top of the tenant default"
|
|
38
|
+
}
|
|
39
|
+
];
|
|
40
|
+
var registered = false;
|
|
41
|
+
function ensurePreferencePermissionsRegistered() {
|
|
42
|
+
if (registered) return;
|
|
43
|
+
registered = true;
|
|
44
|
+
registerPermissionDefinitions(PREFERENCE_PERMISSION_DEFINITIONS);
|
|
45
|
+
}
|
|
46
|
+
function splitPermissionSlug(slug) {
|
|
47
|
+
const dot = slug.lastIndexOf(".");
|
|
48
|
+
if (dot <= 0 || dot === slug.length - 1) throw new Error(`Preference permission "${slug}" is not collection.action`);
|
|
49
|
+
return {
|
|
50
|
+
collection: slug.slice(0, dot),
|
|
51
|
+
action: slug.slice(dot + 1)
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/kinds.ts
|
|
56
|
+
var PREFERENCE_SCOPES = ["tenant", "user"];
|
|
57
|
+
var PREFERENCE_KIND_PATTERN = /^[a-z][a-z0-9-]{0,47}$/;
|
|
58
|
+
var PREFERENCE_SURFACE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,95}$/;
|
|
59
|
+
var UnknownPreferenceKindError = class extends Error {
|
|
60
|
+
code = "unknown_preference_kind";
|
|
61
|
+
status = 400;
|
|
62
|
+
constructor(kind) {
|
|
63
|
+
super(`Preference kind "${kind}" is not registered`);
|
|
64
|
+
this.name = "UnknownPreferenceKindError";
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
var kinds = /* @__PURE__ */ new Map();
|
|
68
|
+
function registerPreferenceKind(definition, options = {}) {
|
|
69
|
+
if (!PREFERENCE_KIND_PATTERN.test(definition.kind)) throw new Error(`Invalid preference kind "${definition.kind}"`);
|
|
70
|
+
if (!Number.isSafeInteger(definition.formatVersion) || definition.formatVersion < 1) throw new Error(`Preference kind "${definition.kind}" needs a positive formatVersion`);
|
|
71
|
+
splitPermissionSlug(definition.permissions.tenant);
|
|
72
|
+
splitPermissionSlug(definition.permissions.user);
|
|
73
|
+
if (typeof definition.validate !== "function") throw new Error(`Preference kind "${definition.kind}" needs validate()`);
|
|
74
|
+
if (kinds.has(definition.kind) && !options.replace) throw new Error(`Preference kind "${definition.kind}" is already registered`);
|
|
75
|
+
const frozen = Object.freeze({
|
|
76
|
+
...definition,
|
|
77
|
+
permissions: Object.freeze({ ...definition.permissions })
|
|
78
|
+
});
|
|
79
|
+
kinds.set(definition.kind, frozen);
|
|
80
|
+
return () => {
|
|
81
|
+
if (kinds.get(definition.kind) === frozen) kinds.delete(definition.kind);
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function getPreferenceKind(kind) {
|
|
85
|
+
return kinds.get(kind);
|
|
86
|
+
}
|
|
87
|
+
function requirePreferenceKind(kind) {
|
|
88
|
+
const definition = kinds.get(kind);
|
|
89
|
+
if (!definition) throw new UnknownPreferenceKindError(kind);
|
|
90
|
+
return definition;
|
|
91
|
+
}
|
|
92
|
+
function listPreferenceKinds() {
|
|
93
|
+
return [...kinds.keys()];
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/kinds/overview.ts
|
|
97
|
+
var OVERVIEW_PREFERENCE_KIND = "overview";
|
|
98
|
+
function withTenantDefaults(definition, document) {
|
|
99
|
+
return {
|
|
100
|
+
...definition,
|
|
101
|
+
defaults: document.widgets
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function toPreferenceIssue$1(issue) {
|
|
105
|
+
return {
|
|
106
|
+
path: issue.widgetId,
|
|
107
|
+
code: issue.code,
|
|
108
|
+
message: issue.message,
|
|
109
|
+
detail: issue
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function toOverviewIssue(issue) {
|
|
113
|
+
if (issue.detail && typeof issue.detail === "object") return issue.detail;
|
|
114
|
+
return {
|
|
115
|
+
widgetId: null,
|
|
116
|
+
type: null,
|
|
117
|
+
code: issue.code === "future_version" ? "future_version" : "malformed",
|
|
118
|
+
message: issue.message
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function readOptions(options) {
|
|
122
|
+
if (!options || typeof options !== "object") return null;
|
|
123
|
+
const { definition, registry } = options;
|
|
124
|
+
if (!definition || !registry) return null;
|
|
125
|
+
return {
|
|
126
|
+
definition,
|
|
127
|
+
registry
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function overviewTierDefinition({ definition, registry }, scope, tenantOverride) {
|
|
131
|
+
if (scope === "tenant") return definition;
|
|
132
|
+
return withTenantDefaults(definition, resolveOverview(definition, tenantOverride ?? null, registry).document);
|
|
133
|
+
}
|
|
134
|
+
var overviewPreferenceKind = {
|
|
135
|
+
kind: OVERVIEW_PREFERENCE_KIND,
|
|
136
|
+
formatVersion: 1,
|
|
137
|
+
permissions: {
|
|
138
|
+
tenant: CUSTOMIZE_OVERVIEW_PERMISSION,
|
|
139
|
+
user: PERSONALIZE_OVERVIEW_PERMISSION
|
|
140
|
+
},
|
|
141
|
+
validate(payload, ctx) {
|
|
142
|
+
const options = readOptions(ctx.options);
|
|
143
|
+
if (!options || options.definition.id !== ctx.surfaceId) return {
|
|
144
|
+
ok: false,
|
|
145
|
+
canonical: null,
|
|
146
|
+
issues: [{
|
|
147
|
+
path: null,
|
|
148
|
+
code: "missing_context",
|
|
149
|
+
message: "the overview kind needs { definition, registry } for this surface"
|
|
150
|
+
}]
|
|
151
|
+
};
|
|
152
|
+
const definition = overviewTierDefinition(options, ctx.scope, ctx.tenant);
|
|
153
|
+
if (ctx.phase === "save") {
|
|
154
|
+
const checked = checkOverviewOverride(definition, payload, options.registry);
|
|
155
|
+
return checked.ok ? {
|
|
156
|
+
ok: true,
|
|
157
|
+
canonical: checked.override,
|
|
158
|
+
issues: []
|
|
159
|
+
} : {
|
|
160
|
+
ok: false,
|
|
161
|
+
canonical: null,
|
|
162
|
+
issues: checked.issues.map(toPreferenceIssue$1)
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const resolved = resolveOverview(definition, payload, options.registry);
|
|
166
|
+
return {
|
|
167
|
+
ok: resolved.overrideIssues.length === 0,
|
|
168
|
+
canonical: diffOverview(resolved.base, resolved.document),
|
|
169
|
+
issues: resolved.overrideIssues.map(toPreferenceIssue$1)
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region src/kinds/shell-layout.ts
|
|
175
|
+
var SHELL_LAYOUT_PREFERENCE_KIND = "shell-layout";
|
|
176
|
+
function toPreferenceIssue(issue) {
|
|
177
|
+
return {
|
|
178
|
+
path: issue.path || null,
|
|
179
|
+
code: "invalid_setting",
|
|
180
|
+
message: issue.message,
|
|
181
|
+
detail: issue
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
var shellLayoutPreferenceKind = {
|
|
185
|
+
kind: SHELL_LAYOUT_PREFERENCE_KIND,
|
|
186
|
+
formatVersion: 1,
|
|
187
|
+
permissions: {
|
|
188
|
+
tenant: CUSTOMIZE_SHELL_PERMISSION,
|
|
189
|
+
user: PERSONALIZE_SHELL_PERMISSION
|
|
190
|
+
},
|
|
191
|
+
validate(payload) {
|
|
192
|
+
const { delta, issues } = sanitizeShellSettingsDelta(payload);
|
|
193
|
+
return {
|
|
194
|
+
ok: issues.length === 0,
|
|
195
|
+
canonical: Object.keys(delta).length > 0 ? delta : null,
|
|
196
|
+
issues: issues.map(toPreferenceIssue)
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
function compact(delta) {
|
|
201
|
+
return Object.fromEntries(Object.entries(delta).filter(([, value]) => value !== void 0));
|
|
202
|
+
}
|
|
203
|
+
function createShellSettingsPreferences(store) {
|
|
204
|
+
return {
|
|
205
|
+
async read(surfaceId) {
|
|
206
|
+
const state = await store.load(SHELL_LAYOUT_PREFERENCE_KIND, surfaceId);
|
|
207
|
+
const tenant = state.tenant.payload ?? null;
|
|
208
|
+
const user = state.user?.payload ?? null;
|
|
209
|
+
return {
|
|
210
|
+
delta: compact(mergeShellSettingsDelta(tenant ?? {}, user ?? {})),
|
|
211
|
+
tenant,
|
|
212
|
+
user,
|
|
213
|
+
revisions: {
|
|
214
|
+
tenant: state.tenant.revision,
|
|
215
|
+
user: state.user?.revision ?? null
|
|
216
|
+
},
|
|
217
|
+
issues: state.issues,
|
|
218
|
+
canCustomize: state.canCustomize
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
async write(surfaceId, delta, options = {}) {
|
|
222
|
+
const scope = options.scope ?? "user";
|
|
223
|
+
let revision = options.revision;
|
|
224
|
+
if (revision === void 0) {
|
|
225
|
+
const state = await store.load(SHELL_LAYOUT_PREFERENCE_KIND, surfaceId);
|
|
226
|
+
revision = scope === "tenant" ? state.tenant.revision : state.user?.revision ?? null;
|
|
227
|
+
}
|
|
228
|
+
return store.save(SHELL_LAYOUT_PREFERENCE_KIND, surfaceId, {
|
|
229
|
+
scope,
|
|
230
|
+
payload: delta,
|
|
231
|
+
revision
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
reset(surfaceId, options = {}) {
|
|
235
|
+
return store.reset(SHELL_LAYOUT_PREFERENCE_KIND, surfaceId, {
|
|
236
|
+
scope: options.scope ?? "user",
|
|
237
|
+
revision: options.revision
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
//#endregion
|
|
243
|
+
//#region src/kinds/builtin.ts
|
|
244
|
+
function ensureBuiltinPreferenceKinds() {
|
|
245
|
+
for (const kind of [overviewPreferenceKind, shellLayoutPreferenceKind]) if (!getPreferenceKind(kind.kind)) registerPreferenceKind(kind);
|
|
246
|
+
}
|
|
247
|
+
ensureBuiltinPreferenceKinds();
|
|
248
|
+
//#endregion
|
|
249
|
+
//#region src/context.ts
|
|
250
|
+
function getPreferencePrincipal() {
|
|
251
|
+
const tenant = getCurrentTenant();
|
|
252
|
+
if (tenant?.tenantId) return {
|
|
253
|
+
tenantId: tenant.tenantId,
|
|
254
|
+
userId: tenant.userId,
|
|
255
|
+
permissions: tenant.permissions,
|
|
256
|
+
superAdminBypass: isSuperAdminBypass(),
|
|
257
|
+
fromTenancy: true
|
|
258
|
+
};
|
|
259
|
+
const session = getCurrentSessionPermissionContext();
|
|
260
|
+
if (!session?.tenantId) return void 0;
|
|
261
|
+
return {
|
|
262
|
+
tenantId: session.tenantId,
|
|
263
|
+
userId: session.userId ?? void 0,
|
|
264
|
+
permissions: session.permissionSet,
|
|
265
|
+
superAdminBypass: session.superAdminBypass,
|
|
266
|
+
fromTenancy: false
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
var PreferenceAccessError = class extends TenantIsolationError {
|
|
270
|
+
status = 403;
|
|
271
|
+
publicMessage;
|
|
272
|
+
constructor(message) {
|
|
273
|
+
super(message);
|
|
274
|
+
this.name = "PreferenceAccessError";
|
|
275
|
+
this.publicMessage = message;
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
function requirePreferencePrincipal() {
|
|
279
|
+
const principal = getPreferencePrincipal();
|
|
280
|
+
if (!principal) throw new PreferenceAccessError("User-interface preferences need an authenticated tenant context");
|
|
281
|
+
return principal;
|
|
282
|
+
}
|
|
283
|
+
async function withPrincipal(principal, fn) {
|
|
284
|
+
if (principal.fromTenancy) return fn();
|
|
285
|
+
return withTenant({
|
|
286
|
+
tenantId: principal.tenantId,
|
|
287
|
+
userId: principal.userId,
|
|
288
|
+
permissions: new Set(principal.permissions)
|
|
289
|
+
}, fn);
|
|
290
|
+
}
|
|
291
|
+
//#endregion
|
|
292
|
+
//#region src/read-guard.ts
|
|
293
|
+
var GUARD_NAME = "smrt-preferences:owner-reads";
|
|
294
|
+
var CLASS_NAME = "UiPreferenceRecord";
|
|
295
|
+
var QUALIFIED_NAME = "@happyvertical/smrt-preferences:UiPreferenceRecord";
|
|
296
|
+
function field$1(row, camel, snake) {
|
|
297
|
+
if (!row || typeof row !== "object") return void 0;
|
|
298
|
+
const record = row;
|
|
299
|
+
return record[camel] !== void 0 ? record[camel] : record[snake];
|
|
300
|
+
}
|
|
301
|
+
function isPreferenceRowVisible(row) {
|
|
302
|
+
const scope = field$1(row, "scopeType", "scope_type");
|
|
303
|
+
if (scope === "tenant") return true;
|
|
304
|
+
if (scope !== "user") return false;
|
|
305
|
+
const owner = field$1(row, "userId", "user_id");
|
|
306
|
+
const userId = getPreferencePrincipal()?.userId;
|
|
307
|
+
return Boolean(userId) && typeof owner === "string" && owner === userId;
|
|
308
|
+
}
|
|
309
|
+
function isPreferenceClass(className, qualifiedClassName) {
|
|
310
|
+
return qualifiedClassName === QUALIFIED_NAME || className === CLASS_NAME;
|
|
311
|
+
}
|
|
312
|
+
var TENANT_KEY = "__tenant__";
|
|
313
|
+
var NO_ROW_KEY = "";
|
|
314
|
+
var SCOPE_KEY_IN = ["scopeKey in", "scope_key in"];
|
|
315
|
+
function visibleScopeKeys() {
|
|
316
|
+
const userId = getPreferencePrincipal()?.userId;
|
|
317
|
+
return userId ? [TENANT_KEY, userId] : [TENANT_KEY];
|
|
318
|
+
}
|
|
319
|
+
function withOwnerPredicate(where) {
|
|
320
|
+
const visible = visibleScopeKeys();
|
|
321
|
+
const next = { ...where ?? {} };
|
|
322
|
+
let keys = visible;
|
|
323
|
+
for (const key of SCOPE_KEY_IN) {
|
|
324
|
+
if (!(key in next)) continue;
|
|
325
|
+
const requested = next[key];
|
|
326
|
+
delete next[key];
|
|
327
|
+
const list = Array.isArray(requested) ? requested : [requested];
|
|
328
|
+
keys = keys.filter((value) => list.includes(value));
|
|
329
|
+
}
|
|
330
|
+
next["scopeKey in"] = keys.length > 0 ? keys : [NO_ROW_KEY];
|
|
331
|
+
return next;
|
|
332
|
+
}
|
|
333
|
+
function filterRows(className, rows, qualifiedClassName) {
|
|
334
|
+
if (!isPreferenceClass(className, qualifiedClassName)) return void 0;
|
|
335
|
+
return rows.filter(isPreferenceRowVisible);
|
|
336
|
+
}
|
|
337
|
+
function ensurePreferenceReadGuard() {
|
|
338
|
+
if (GlobalInterceptors.getAll().some((entry) => entry.name === GUARD_NAME)) return;
|
|
339
|
+
GlobalInterceptors.register({
|
|
340
|
+
name: GUARD_NAME,
|
|
341
|
+
priority: 90,
|
|
342
|
+
beforeList(className, options, context) {
|
|
343
|
+
if (!isPreferenceClass(className, context.qualifiedClassName)) return;
|
|
344
|
+
const where = options.where;
|
|
345
|
+
if (Array.isArray(where)) return {
|
|
346
|
+
...options,
|
|
347
|
+
where: where.map((group) => [...group, withOwnerPredicate({})])
|
|
348
|
+
};
|
|
349
|
+
return {
|
|
350
|
+
...options,
|
|
351
|
+
where: withOwnerPredicate(where)
|
|
352
|
+
};
|
|
353
|
+
},
|
|
354
|
+
beforeGet(className, filter, context) {
|
|
355
|
+
if (!isPreferenceClass(className, context.qualifiedClassName)) return;
|
|
356
|
+
return withOwnerPredicate(typeof filter === "string" ? resolveGetStringFilter(filter) : filter);
|
|
357
|
+
},
|
|
358
|
+
beforeQuery(className, _options, context) {
|
|
359
|
+
if (!isPreferenceClass(className, context.qualifiedClassName)) return;
|
|
360
|
+
throw new PreferenceAccessError("Raw queries over user-interface preferences are not allowed");
|
|
361
|
+
},
|
|
362
|
+
afterList(className, results, context) {
|
|
363
|
+
return filterRows(className, results, context.qualifiedClassName);
|
|
364
|
+
},
|
|
365
|
+
afterQuery(className, results, context) {
|
|
366
|
+
return filterRows(className, results, context.qualifiedClassName);
|
|
367
|
+
},
|
|
368
|
+
afterGet(className, instance, context) {
|
|
369
|
+
if (!instance || !isPreferenceClass(className, context.qualifiedClassName)) return;
|
|
370
|
+
return isPreferenceRowVisible(instance) ? instance : null;
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
//#endregion
|
|
375
|
+
//#region src/write-capability.ts
|
|
376
|
+
var STORE_WRITE = /* @__PURE__ */ Symbol("smrt-preferences.storeWrite");
|
|
377
|
+
var TOKEN = Object.freeze({ capability: "smrt-preferences.storeWrite" });
|
|
378
|
+
var writes = /* @__PURE__ */ new WeakMap();
|
|
379
|
+
var deletes = /* @__PURE__ */ new WeakSet();
|
|
380
|
+
function storeWriteOption() {
|
|
381
|
+
return { [STORE_WRITE]: TOKEN };
|
|
382
|
+
}
|
|
383
|
+
function adoptStoreWrite(instance, options) {
|
|
384
|
+
if (options && typeof options === "object" && options[STORE_WRITE] === TOKEN && typeof options.payloadJson === "string") writes.set(instance, options.payloadJson);
|
|
385
|
+
}
|
|
386
|
+
function grantStoreWrite(instance) {
|
|
387
|
+
writes.set(instance, instance.payloadJson);
|
|
388
|
+
}
|
|
389
|
+
function grantStoreDelete(instance) {
|
|
390
|
+
deletes.add(instance);
|
|
391
|
+
}
|
|
392
|
+
function hasStoreWrite(instance, payloadJson) {
|
|
393
|
+
return writes.get(instance) === payloadJson;
|
|
394
|
+
}
|
|
395
|
+
function spendStoreWrite(instance) {
|
|
396
|
+
writes.delete(instance);
|
|
397
|
+
}
|
|
398
|
+
function takeStoreDelete(instance) {
|
|
399
|
+
return deletes.delete(instance);
|
|
400
|
+
}
|
|
401
|
+
//#endregion
|
|
402
|
+
//#region src/models/UiPreferenceRecord.ts
|
|
403
|
+
var __defProp = Object.defineProperty;
|
|
404
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
405
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, {
|
|
406
|
+
enumerable: true,
|
|
407
|
+
configurable: true,
|
|
408
|
+
writable: true,
|
|
409
|
+
value
|
|
410
|
+
}) : obj[key] = value;
|
|
411
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
412
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
413
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
414
|
+
if (kind && result) __defProp(target, key, result);
|
|
415
|
+
return result;
|
|
416
|
+
};
|
|
417
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
418
|
+
ensurePreferencePermissionsRegistered();
|
|
419
|
+
ensurePreferenceReadGuard();
|
|
420
|
+
var TENANT_SCOPE_KEY = "__tenant__";
|
|
421
|
+
var UiPreferenceRecord = class extends SmrtObject {
|
|
422
|
+
tenantId = "";
|
|
423
|
+
kind = "";
|
|
424
|
+
surfaceId = "";
|
|
425
|
+
scopeType = "tenant";
|
|
426
|
+
userId = null;
|
|
427
|
+
scopeKey = "";
|
|
428
|
+
payloadJson = "";
|
|
429
|
+
formatVersion = 1;
|
|
430
|
+
updatedBy = null;
|
|
431
|
+
constructor(options = {}) {
|
|
432
|
+
super(options);
|
|
433
|
+
adoptStoreWrite(this, options);
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Single-row hydration (`new UiPreferenceRecord({ id }).initialize()`)
|
|
437
|
+
* reads the table directly, outside the collection interceptors, so the
|
|
438
|
+
* owner check runs here: another user's row is wiped and refused.
|
|
439
|
+
*/
|
|
440
|
+
async loadFromId() {
|
|
441
|
+
await super.loadFromId();
|
|
442
|
+
this.assertReadable();
|
|
443
|
+
}
|
|
444
|
+
async loadFromSlug() {
|
|
445
|
+
await super.loadFromSlug();
|
|
446
|
+
this.assertReadable();
|
|
447
|
+
}
|
|
448
|
+
assertReadable() {
|
|
449
|
+
if (!this.kind || isPreferenceRowVisible(this)) return;
|
|
450
|
+
this.payloadJson = "";
|
|
451
|
+
throw new PreferenceAccessError("A personal preference can only be read by its owner");
|
|
452
|
+
}
|
|
453
|
+
/** The stored payload, parsed. `undefined` when the text is not JSON. */
|
|
454
|
+
getPayload() {
|
|
455
|
+
try {
|
|
456
|
+
return JSON.parse(this.payloadJson);
|
|
457
|
+
} catch {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/** Store a payload; the caller passes the kind's canonical value. */
|
|
462
|
+
setPayload(value) {
|
|
463
|
+
this.payloadJson = JSON.stringify(value);
|
|
464
|
+
}
|
|
465
|
+
/** The optimistic-concurrency token a client echoes back on save. */
|
|
466
|
+
get revision() {
|
|
467
|
+
const at = this.updated_at;
|
|
468
|
+
if (!at) return null;
|
|
469
|
+
const date = at instanceof Date ? at : new Date(at);
|
|
470
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
471
|
+
}
|
|
472
|
+
async save(options = {}) {
|
|
473
|
+
const principal = requirePreferencePrincipal();
|
|
474
|
+
if (!this.tenantId) this.tenantId = principal.tenantId;
|
|
475
|
+
this.syncScopeKey();
|
|
476
|
+
await this.assertPersistedIdentityUnchanged();
|
|
477
|
+
this.assertStoreWrite();
|
|
478
|
+
this.validateShape();
|
|
479
|
+
await this.authorize();
|
|
480
|
+
this.updatedBy = principal.userId ?? null;
|
|
481
|
+
try {
|
|
482
|
+
return await super.save(options);
|
|
483
|
+
} finally {
|
|
484
|
+
spendStoreWrite(this);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Runs for ordinary saves and bulk creates alike, so a collection-level
|
|
489
|
+
* bulk insert is refused too (`save()` checks first so callers see it).
|
|
490
|
+
*/
|
|
491
|
+
async validateBeforeSave() {
|
|
492
|
+
await super.validateBeforeSave();
|
|
493
|
+
this.assertStoreWrite();
|
|
494
|
+
}
|
|
495
|
+
async delete(options = {}) {
|
|
496
|
+
if (!takeStoreDelete(this)) throw new PreferenceAccessError("Preferences are reset only through the preference store");
|
|
497
|
+
this.syncScopeKey();
|
|
498
|
+
await this.assertPersistedIdentityUnchanged();
|
|
499
|
+
await this.authorize();
|
|
500
|
+
return super.delete(options);
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Only the store, after the kind's validator accepted exactly this
|
|
504
|
+
* payload, may write a row (see `write-capability.ts`).
|
|
505
|
+
*/
|
|
506
|
+
assertStoreWrite() {
|
|
507
|
+
if (!hasStoreWrite(this, this.payloadJson)) throw new PreferenceAccessError("Preferences are written only through the preference store, which validates them");
|
|
508
|
+
}
|
|
509
|
+
syncScopeKey() {
|
|
510
|
+
if (this.scopeType === "tenant" && this.userId === null) this.scopeKey = TENANT_SCOPE_KEY;
|
|
511
|
+
else if (this.scopeType === "user" && this.userId) this.scopeKey = this.userId;
|
|
512
|
+
}
|
|
513
|
+
validateShape() {
|
|
514
|
+
if (!PREFERENCE_KIND_PATTERN.test(this.kind)) throw new Error("UiPreferenceRecord.kind is not a valid kind id");
|
|
515
|
+
if (!PREFERENCE_SURFACE_PATTERN.test(this.surfaceId)) throw new Error("UiPreferenceRecord.surfaceId is not a valid id");
|
|
516
|
+
if (!PREFERENCE_SCOPES.includes(this.scopeType)) throw new Error("UiPreferenceRecord.scopeType must be tenant or user");
|
|
517
|
+
if (this.scopeType === "tenant" && this.userId !== null) throw new Error("A tenant-default preference row has no userId");
|
|
518
|
+
if (this.scopeType === "user" && !this.userId) throw new Error("A user preference row needs its userId");
|
|
519
|
+
if (!Number.isSafeInteger(this.formatVersion) || this.formatVersion < 1) throw new Error("UiPreferenceRecord.formatVersion must be positive");
|
|
520
|
+
if (this.getPayload() === void 0) throw new Error("UiPreferenceRecord.payloadJson must be JSON");
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Fail-closed tier guard. The tenant must be the principal's (the
|
|
524
|
+
* interceptor checks this too; a super-admin bypass does not filter reads,
|
|
525
|
+
* so it is re-checked here), a user row must belong to the principal (a
|
|
526
|
+
* principal with no user id may not touch the user tier), the kind must be
|
|
527
|
+
* registered, and its permission for the tier must be held.
|
|
528
|
+
*/
|
|
529
|
+
async authorize() {
|
|
530
|
+
const principal = requirePreferencePrincipal();
|
|
531
|
+
const kind = requirePreferenceKind(this.kind);
|
|
532
|
+
if (this.tenantId !== principal.tenantId) throw new PreferenceAccessError("This preference belongs to another tenant");
|
|
533
|
+
if (this.scopeType === "user") {
|
|
534
|
+
if (!principal.userId || this.userId !== principal.userId) throw new PreferenceAccessError("A personal preference can only be changed by its owner");
|
|
535
|
+
}
|
|
536
|
+
await assertOperationPermission({
|
|
537
|
+
...splitPermissionSlug(this.scopeType === "user" ? kind.permissions.user : kind.permissions.tenant),
|
|
538
|
+
db: this.options.db ?? this.options.persistence,
|
|
539
|
+
tenantId: principal.tenantId,
|
|
540
|
+
userId: principal.userId ?? null,
|
|
541
|
+
permissionSet: principal.permissions
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* A persisted row keeps its identity: the stored tenant, kind, surface and
|
|
546
|
+
* tier must be the ones in memory, so {@link authorize} always judges the
|
|
547
|
+
* row as stored. Matched by predicate rather than by reading the columns
|
|
548
|
+
* back, because DuckDB returns raw UUID columns as lossy HUGEINTs.
|
|
549
|
+
*/
|
|
550
|
+
async assertPersistedIdentityUnchanged() {
|
|
551
|
+
if (!this.id) return;
|
|
552
|
+
if (!await this.db.get(this.tableName, { id: this.id })) return;
|
|
553
|
+
if (!await this.db.get(this.tableName, {
|
|
554
|
+
id: this.id,
|
|
555
|
+
tenant_id: this.tenantId,
|
|
556
|
+
kind: this.kind,
|
|
557
|
+
surface_id: this.surfaceId,
|
|
558
|
+
scope_type: this.scopeType,
|
|
559
|
+
scope_key: this.scopeKey
|
|
560
|
+
})) throw new PreferenceAccessError("A preference row cannot change its tenant, kind, surface or owner");
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
__publicField(UiPreferenceRecord, "__smrtPackage__", "@happyvertical/smrt-preferences");
|
|
564
|
+
__decorateClass([tenantId()], UiPreferenceRecord.prototype, "tenantId", 2);
|
|
565
|
+
__decorateClass([field({ required: true })], UiPreferenceRecord.prototype, "kind", 2);
|
|
566
|
+
__decorateClass([field({ required: true })], UiPreferenceRecord.prototype, "surfaceId", 2);
|
|
567
|
+
__decorateClass([field({ required: true })], UiPreferenceRecord.prototype, "scopeType", 2);
|
|
568
|
+
__decorateClass([crossPackageRef("@happyvertical/smrt-users:User", { nullable: true })], UiPreferenceRecord.prototype, "userId", 2);
|
|
569
|
+
__decorateClass([field({
|
|
570
|
+
type: "text",
|
|
571
|
+
required: true
|
|
572
|
+
})], UiPreferenceRecord.prototype, "scopeKey", 2);
|
|
573
|
+
__decorateClass([field({
|
|
574
|
+
type: "text",
|
|
575
|
+
required: true
|
|
576
|
+
})], UiPreferenceRecord.prototype, "payloadJson", 2);
|
|
577
|
+
__decorateClass([field({ type: "integer" })], UiPreferenceRecord.prototype, "formatVersion", 2);
|
|
578
|
+
__decorateClass([crossPackageRef("@happyvertical/smrt-users:User", { nullable: true })], UiPreferenceRecord.prototype, "updatedBy", 2);
|
|
579
|
+
UiPreferenceRecord = __decorateClass([TenantScoped({ mode: "required" }), smrt({
|
|
580
|
+
tableName: "_smrt_ui_preferences",
|
|
581
|
+
conflictColumns: [
|
|
582
|
+
"tenant_id",
|
|
583
|
+
"kind",
|
|
584
|
+
"surface_id",
|
|
585
|
+
"scope_type",
|
|
586
|
+
"scope_key"
|
|
587
|
+
],
|
|
588
|
+
api: { include: [] },
|
|
589
|
+
cli: false,
|
|
590
|
+
mcp: { include: [] }
|
|
591
|
+
})], UiPreferenceRecord);
|
|
592
|
+
//#endregion
|
|
593
|
+
//#region src/collections/UiPreferenceRecordCollection.ts
|
|
594
|
+
var UiPreferenceRecordCollection = class extends SmrtCollection {
|
|
595
|
+
static _itemClass = UiPreferenceRecord;
|
|
596
|
+
/**
|
|
597
|
+
* The row of one tier, or `null`. The tenant predicate is explicit (not
|
|
598
|
+
* only the interceptor's) because a super-admin bypass context does not
|
|
599
|
+
* filter reads.
|
|
600
|
+
*/
|
|
601
|
+
async findTier(tenantId, kind, surfaceId, scope, userId) {
|
|
602
|
+
const scopeKey = scope === "tenant" ? TENANT_SCOPE_KEY : userId;
|
|
603
|
+
if (!scopeKey) return null;
|
|
604
|
+
return (await this.list({
|
|
605
|
+
where: {
|
|
606
|
+
tenantId,
|
|
607
|
+
kind,
|
|
608
|
+
surfaceId,
|
|
609
|
+
scopeType: scope,
|
|
610
|
+
scopeKey
|
|
611
|
+
},
|
|
612
|
+
limit: 1
|
|
613
|
+
}))[0] ?? null;
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
//#endregion
|
|
617
|
+
//#region src/store.ts
|
|
618
|
+
function isRevisionConflict(error) {
|
|
619
|
+
return error instanceof RuntimeError && error.code === "RUNTIME_REVISION_CONFLICT";
|
|
620
|
+
}
|
|
621
|
+
function assertSurfaceId(surfaceId) {
|
|
622
|
+
if (!PREFERENCE_SURFACE_PATTERN.test(surfaceId)) throw new Error(`Invalid preference surface id "${surfaceId}"`);
|
|
623
|
+
}
|
|
624
|
+
async function holds(principal, slug, db) {
|
|
625
|
+
return (await checkOperationPermission({
|
|
626
|
+
...splitPermissionSlug(slug),
|
|
627
|
+
db,
|
|
628
|
+
tenantId: principal.tenantId,
|
|
629
|
+
userId: principal.userId ?? null,
|
|
630
|
+
permissionSet: principal.permissions,
|
|
631
|
+
onDeny: "return"
|
|
632
|
+
})).allowed;
|
|
633
|
+
}
|
|
634
|
+
async function canWrite(principal, kind, scope, db) {
|
|
635
|
+
if (scope === "user") return Boolean(principal.userId) && await holds(principal, kind.permissions.user, db);
|
|
636
|
+
return holds(principal, kind.permissions.tenant, db);
|
|
637
|
+
}
|
|
638
|
+
function readTier(kind, surfaceId, scope, row, options, tenant) {
|
|
639
|
+
if (!row) return {
|
|
640
|
+
payload: null,
|
|
641
|
+
revision: null,
|
|
642
|
+
issues: []
|
|
643
|
+
};
|
|
644
|
+
const revision = row.revision;
|
|
645
|
+
const payload = row.getPayload();
|
|
646
|
+
if (payload === void 0) return {
|
|
647
|
+
payload: null,
|
|
648
|
+
revision,
|
|
649
|
+
issues: [{
|
|
650
|
+
path: null,
|
|
651
|
+
code: "malformed",
|
|
652
|
+
message: "stored payload is not JSON"
|
|
653
|
+
}]
|
|
654
|
+
};
|
|
655
|
+
if (row.formatVersion > kind.formatVersion) return {
|
|
656
|
+
payload: null,
|
|
657
|
+
revision,
|
|
658
|
+
issues: [{
|
|
659
|
+
path: null,
|
|
660
|
+
code: "future_version",
|
|
661
|
+
message: "stored payload was written by a newer format version"
|
|
662
|
+
}]
|
|
663
|
+
};
|
|
664
|
+
let checked;
|
|
665
|
+
try {
|
|
666
|
+
checked = kind.validate(payload, {
|
|
667
|
+
kind: kind.kind,
|
|
668
|
+
surfaceId,
|
|
669
|
+
scope,
|
|
670
|
+
phase: "load",
|
|
671
|
+
formatVersion: row.formatVersion,
|
|
672
|
+
...scope === "user" ? { tenant: tenant ?? null } : {},
|
|
673
|
+
options
|
|
674
|
+
});
|
|
675
|
+
} catch {
|
|
676
|
+
return {
|
|
677
|
+
payload: null,
|
|
678
|
+
revision,
|
|
679
|
+
issues: [{
|
|
680
|
+
path: null,
|
|
681
|
+
code: "validator_error",
|
|
682
|
+
message: "the preference kind failed to validate the stored payload"
|
|
683
|
+
}]
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
return {
|
|
687
|
+
payload: checked.canonical,
|
|
688
|
+
revision,
|
|
689
|
+
issues: checked.issues
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
function createPreferenceStore(options) {
|
|
693
|
+
const { db } = options;
|
|
694
|
+
const collection = () => {
|
|
695
|
+
ensurePreferenceReadGuard();
|
|
696
|
+
return UiPreferenceRecordCollection.create({ db });
|
|
697
|
+
};
|
|
698
|
+
async function tenantTier(records, principal, kind, surfaceId, kindOptions) {
|
|
699
|
+
return readTier(kind, surfaceId, "tenant", await records.findTier(principal.tenantId, kind.kind, surfaceId, "tenant"), kindOptions);
|
|
700
|
+
}
|
|
701
|
+
return {
|
|
702
|
+
async load(kindId, surfaceId, input = {}) {
|
|
703
|
+
const kind = requirePreferenceKind(kindId);
|
|
704
|
+
assertSurfaceId(surfaceId);
|
|
705
|
+
const principal = requirePreferencePrincipal();
|
|
706
|
+
return withPrincipal(principal, async () => {
|
|
707
|
+
const records = await collection();
|
|
708
|
+
const tenant = await tenantTier(records, principal, kind, surfaceId, input.options);
|
|
709
|
+
const userRow = principal.userId ? await records.findTier(principal.tenantId, kind.kind, surfaceId, "user", principal.userId) : null;
|
|
710
|
+
const user = principal.userId ? readTier(kind, surfaceId, "user", userRow, input.options, tenant.payload) : null;
|
|
711
|
+
const [tenantWrite, userWrite] = await Promise.all([canWrite(principal, kind, "tenant", db), canWrite(principal, kind, "user", db)]);
|
|
712
|
+
return {
|
|
713
|
+
kind: kind.kind,
|
|
714
|
+
surfaceId,
|
|
715
|
+
tenant,
|
|
716
|
+
user,
|
|
717
|
+
issues: [...tenant.issues, ...user?.issues ?? []],
|
|
718
|
+
canCustomize: {
|
|
719
|
+
tenant: tenantWrite,
|
|
720
|
+
user: userWrite
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
});
|
|
724
|
+
},
|
|
725
|
+
async save(kindId, surfaceId, input) {
|
|
726
|
+
const kind = requirePreferenceKind(kindId);
|
|
727
|
+
assertSurfaceId(surfaceId);
|
|
728
|
+
const principal = requirePreferencePrincipal();
|
|
729
|
+
return withPrincipal(principal, async () => {
|
|
730
|
+
if (!await canWrite(principal, kind, input.scope, db)) return {
|
|
731
|
+
ok: false,
|
|
732
|
+
reason: "not_allowed"
|
|
733
|
+
};
|
|
734
|
+
const records = await collection();
|
|
735
|
+
const tenant = input.scope === "user" ? (await tenantTier(records, principal, kind, surfaceId, input.options)).payload : void 0;
|
|
736
|
+
const checked = kind.validate(input.payload, {
|
|
737
|
+
kind: kind.kind,
|
|
738
|
+
surfaceId,
|
|
739
|
+
scope: input.scope,
|
|
740
|
+
phase: "save",
|
|
741
|
+
formatVersion: kind.formatVersion,
|
|
742
|
+
...input.scope === "user" ? { tenant: tenant ?? null } : {},
|
|
743
|
+
options: input.options
|
|
744
|
+
});
|
|
745
|
+
if (!checked.ok) return {
|
|
746
|
+
ok: false,
|
|
747
|
+
reason: "invalid",
|
|
748
|
+
issues: checked.issues
|
|
749
|
+
};
|
|
750
|
+
const canonical = checked.canonical ?? null;
|
|
751
|
+
const userId = input.scope === "user" ? principal.userId : void 0;
|
|
752
|
+
const existing = await records.findTier(principal.tenantId, kind.kind, surfaceId, input.scope, userId);
|
|
753
|
+
const revision = input.revision ?? null;
|
|
754
|
+
if (canonical === null) {
|
|
755
|
+
if (!existing) return revision === null ? {
|
|
756
|
+
ok: true,
|
|
757
|
+
payload: null,
|
|
758
|
+
revision: null
|
|
759
|
+
} : {
|
|
760
|
+
ok: false,
|
|
761
|
+
reason: "conflict"
|
|
762
|
+
};
|
|
763
|
+
if (revision === null) return {
|
|
764
|
+
ok: false,
|
|
765
|
+
reason: "conflict"
|
|
766
|
+
};
|
|
767
|
+
try {
|
|
768
|
+
grantStoreDelete(existing);
|
|
769
|
+
await existing.delete({ expectedUpdatedAt: revision });
|
|
770
|
+
} catch (error) {
|
|
771
|
+
if (isRevisionConflict(error)) return {
|
|
772
|
+
ok: false,
|
|
773
|
+
reason: "conflict"
|
|
774
|
+
};
|
|
775
|
+
throw error;
|
|
776
|
+
}
|
|
777
|
+
return {
|
|
778
|
+
ok: true,
|
|
779
|
+
payload: null,
|
|
780
|
+
revision: null
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
if (existing) {
|
|
784
|
+
if (revision === null) return {
|
|
785
|
+
ok: false,
|
|
786
|
+
reason: "conflict"
|
|
787
|
+
};
|
|
788
|
+
existing.setPayload(canonical);
|
|
789
|
+
existing.formatVersion = kind.formatVersion;
|
|
790
|
+
grantStoreWrite(existing);
|
|
791
|
+
try {
|
|
792
|
+
await existing.save({ expectedUpdatedAt: revision });
|
|
793
|
+
} catch (error) {
|
|
794
|
+
if (isRevisionConflict(error)) return {
|
|
795
|
+
ok: false,
|
|
796
|
+
reason: "conflict"
|
|
797
|
+
};
|
|
798
|
+
throw error;
|
|
799
|
+
}
|
|
800
|
+
return {
|
|
801
|
+
ok: true,
|
|
802
|
+
payload: canonical,
|
|
803
|
+
revision: existing.revision
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
if (revision !== null) return {
|
|
807
|
+
ok: false,
|
|
808
|
+
reason: "conflict"
|
|
809
|
+
};
|
|
810
|
+
try {
|
|
811
|
+
return {
|
|
812
|
+
ok: true,
|
|
813
|
+
payload: canonical,
|
|
814
|
+
revision: (await records.create({
|
|
815
|
+
tenantId: principal.tenantId,
|
|
816
|
+
kind: kind.kind,
|
|
817
|
+
surfaceId,
|
|
818
|
+
scopeType: input.scope,
|
|
819
|
+
userId: userId ?? null,
|
|
820
|
+
payloadJson: JSON.stringify(canonical),
|
|
821
|
+
formatVersion: kind.formatVersion,
|
|
822
|
+
_insertOnly: true,
|
|
823
|
+
...storeWriteOption()
|
|
824
|
+
})).revision
|
|
825
|
+
};
|
|
826
|
+
} catch (error) {
|
|
827
|
+
if (await records.findTier(principal.tenantId, kind.kind, surfaceId, input.scope, userId)) return {
|
|
828
|
+
ok: false,
|
|
829
|
+
reason: "conflict"
|
|
830
|
+
};
|
|
831
|
+
throw error;
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
},
|
|
835
|
+
async reset(kindId, surfaceId, input) {
|
|
836
|
+
const kind = requirePreferenceKind(kindId);
|
|
837
|
+
assertSurfaceId(surfaceId);
|
|
838
|
+
const principal = requirePreferencePrincipal();
|
|
839
|
+
return withPrincipal(principal, async () => {
|
|
840
|
+
if (!await canWrite(principal, kind, input.scope, db)) return {
|
|
841
|
+
ok: false,
|
|
842
|
+
reason: "not_allowed"
|
|
843
|
+
};
|
|
844
|
+
const existing = await (await collection()).findTier(principal.tenantId, kind.kind, surfaceId, input.scope, input.scope === "user" ? principal.userId : void 0);
|
|
845
|
+
if (!existing) return { ok: true };
|
|
846
|
+
if (input.revision === null) return {
|
|
847
|
+
ok: false,
|
|
848
|
+
reason: "conflict"
|
|
849
|
+
};
|
|
850
|
+
try {
|
|
851
|
+
grantStoreDelete(existing);
|
|
852
|
+
await existing.delete(typeof input.revision === "string" ? { expectedUpdatedAt: input.revision } : {});
|
|
853
|
+
} catch (error) {
|
|
854
|
+
if (isRevisionConflict(error)) return {
|
|
855
|
+
ok: false,
|
|
856
|
+
reason: "conflict"
|
|
857
|
+
};
|
|
858
|
+
throw error;
|
|
859
|
+
}
|
|
860
|
+
return { ok: true };
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
//#endregion
|
|
866
|
+
//#region src/overview.ts
|
|
867
|
+
function sameWidget(a, b) {
|
|
868
|
+
return a.id === b.id && a.type === b.type && (a.version ?? 1) === (b.version ?? 1) && optionsEqual(a.options, b.options);
|
|
869
|
+
}
|
|
870
|
+
function createOverviewStore(options) {
|
|
871
|
+
const preferences = options.preferences ?? createPreferenceStore({ db: options.db });
|
|
872
|
+
const kindOptions = (definition, registry) => ({
|
|
873
|
+
definition,
|
|
874
|
+
registry
|
|
875
|
+
});
|
|
876
|
+
const store = {
|
|
877
|
+
async load(definition, registry) {
|
|
878
|
+
const state = await preferences.load(OVERVIEW_PREFERENCE_KIND, definition.id, { options: kindOptions(definition, registry) });
|
|
879
|
+
const tenantOverride = state.tenant.payload;
|
|
880
|
+
const tenantResolved = resolveOverview(definition, tenantOverride, registry);
|
|
881
|
+
const tenant = {
|
|
882
|
+
base: tenantResolved.base,
|
|
883
|
+
document: tenantResolved.document,
|
|
884
|
+
override: tenantOverride,
|
|
885
|
+
revision: state.tenant.revision,
|
|
886
|
+
issues: state.tenant.issues.map(toOverviewIssue)
|
|
887
|
+
};
|
|
888
|
+
let user = null;
|
|
889
|
+
if (state.user) {
|
|
890
|
+
const userOverride = state.user.payload;
|
|
891
|
+
const userResolved = resolveOverview(withTenantDefaults(definition, tenant.document), userOverride, registry);
|
|
892
|
+
user = {
|
|
893
|
+
base: userResolved.base,
|
|
894
|
+
document: userResolved.document,
|
|
895
|
+
override: userOverride,
|
|
896
|
+
revision: state.user.revision,
|
|
897
|
+
issues: state.user.issues.map(toOverviewIssue)
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
return {
|
|
901
|
+
overviewId: definition.id,
|
|
902
|
+
tenant,
|
|
903
|
+
user,
|
|
904
|
+
document: user?.document ?? tenant.document,
|
|
905
|
+
issues: [...tenant.issues, ...user?.issues ?? []],
|
|
906
|
+
canCustomize: state.canCustomize
|
|
907
|
+
};
|
|
908
|
+
},
|
|
909
|
+
async save(definition, registry, input) {
|
|
910
|
+
const result = await preferences.save(OVERVIEW_PREFERENCE_KIND, definition.id, {
|
|
911
|
+
scope: input.scope,
|
|
912
|
+
payload: input.override,
|
|
913
|
+
revision: input.revision,
|
|
914
|
+
options: kindOptions(definition, registry)
|
|
915
|
+
});
|
|
916
|
+
if (result.ok) return {
|
|
917
|
+
ok: true,
|
|
918
|
+
override: result.payload,
|
|
919
|
+
revision: result.revision
|
|
920
|
+
};
|
|
921
|
+
if (result.reason === "invalid") return {
|
|
922
|
+
ok: false,
|
|
923
|
+
reason: "invalid",
|
|
924
|
+
issues: result.issues.map(toOverviewIssue)
|
|
925
|
+
};
|
|
926
|
+
return result;
|
|
927
|
+
},
|
|
928
|
+
async reset(definition, input) {
|
|
929
|
+
const result = await preferences.reset(OVERVIEW_PREFERENCE_KIND, definition.id, input);
|
|
930
|
+
if (!result.ok && result.reason === "invalid") return {
|
|
931
|
+
ok: false,
|
|
932
|
+
reason: "invalid",
|
|
933
|
+
issues: result.issues.map(toOverviewIssue)
|
|
934
|
+
};
|
|
935
|
+
return result;
|
|
936
|
+
},
|
|
937
|
+
async loadPage(definition, registry, ctx, pageOptions = {}) {
|
|
938
|
+
const { scope = "user", ...loadOptions } = pageOptions;
|
|
939
|
+
const state = await store.load(definition, registry);
|
|
940
|
+
const loaded = await loadOverview(scope === "tenant" ? state.tenant.document : state.document, definition, registry, ctx, loadOptions);
|
|
941
|
+
return {
|
|
942
|
+
...state,
|
|
943
|
+
loaded
|
|
944
|
+
};
|
|
945
|
+
},
|
|
946
|
+
async loadWidget(definition, registry, widget, ctx, loadOptions = {}) {
|
|
947
|
+
const state = await store.load(definition, registry);
|
|
948
|
+
const parsed = parseOverviewDocument({ widgets: [widget] });
|
|
949
|
+
const candidate = parsed.document.widgets[0];
|
|
950
|
+
if (!candidate || parsed.issues.length > 0) return {
|
|
951
|
+
ok: false,
|
|
952
|
+
reason: "invalid",
|
|
953
|
+
issues: parsed.issues
|
|
954
|
+
};
|
|
955
|
+
if (![...state.document.widgets, ...state.tenant.document.widgets].some((existing) => sameWidget(existing, candidate)) && !state.canCustomize.tenant && !state.canCustomize.user) return {
|
|
956
|
+
ok: false,
|
|
957
|
+
reason: "not_allowed"
|
|
958
|
+
};
|
|
959
|
+
const sanitized = sanitizeOverview({ widgets: [candidate] }, {
|
|
960
|
+
registry,
|
|
961
|
+
definition
|
|
962
|
+
});
|
|
963
|
+
if (sanitized.issues.length > 0) return {
|
|
964
|
+
ok: false,
|
|
965
|
+
reason: "invalid",
|
|
966
|
+
issues: sanitized.issues
|
|
967
|
+
};
|
|
968
|
+
const result = (await loadOverview(sanitized.document, definition, registry, ctx, loadOptions)).widgets[0];
|
|
969
|
+
if (!result) return {
|
|
970
|
+
ok: false,
|
|
971
|
+
reason: "invalid",
|
|
972
|
+
issues: []
|
|
973
|
+
};
|
|
974
|
+
if (result.status === "error") return {
|
|
975
|
+
ok: false,
|
|
976
|
+
reason: "load_failed",
|
|
977
|
+
code: result.error?.code ?? "load_failed"
|
|
978
|
+
};
|
|
979
|
+
return {
|
|
980
|
+
ok: true,
|
|
981
|
+
data: result.data
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
};
|
|
985
|
+
return store;
|
|
986
|
+
}
|
|
987
|
+
//#endregion
|
|
988
|
+
export { CUSTOMIZE_OVERVIEW_PERMISSION, CUSTOMIZE_SHELL_PERMISSION, OVERVIEW_PREFERENCE_KIND, PERSONALIZE_OVERVIEW_PERMISSION, PERSONALIZE_SHELL_PERMISSION, PREFERENCE_KIND_PATTERN, PREFERENCE_PERMISSION_DEFINITIONS, PREFERENCE_SCOPES, PREFERENCE_SURFACE_PATTERN, PreferenceAccessError, SHELL_LAYOUT_PREFERENCE_KIND, TENANT_SCOPE_KEY, UiPreferenceRecord, UnknownPreferenceKindError, createOverviewStore, createPreferenceStore, createShellSettingsPreferences, ensureBuiltinPreferenceKinds, ensurePreferencePermissionsRegistered, getPreferenceKind, getPreferencePrincipal, listPreferenceKinds, overviewPreferenceKind, registerPreferenceKind, requirePreferenceKind, shellLayoutPreferenceKind, splitPermissionSlug, withTenantDefaults };
|
|
989
|
+
|
|
990
|
+
//# sourceMappingURL=index.js.map
|