@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/AGENTS.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# smrt-preferences
|
|
2
|
+
|
|
3
|
+
User-interface preferences (#3727): one `_smrt_ui_preferences` system table
|
|
4
|
+
for tenant defaults and per-user preferences of any registered **kind**, the
|
|
5
|
+
kind registry, the generic store, and two built-in kinds: `overview`
|
|
6
|
+
(customizable overview pages of `@happyvertical/smrt-svelte/overview`, with an
|
|
7
|
+
overview-shaped layer on top) and `shell-layout` (the AdminShell
|
|
8
|
+
`ShellSettingsDelta`). The kind contract is in
|
|
9
|
+
[agents/preference-kinds.md](agents/preference-kinds.md); the overview
|
|
10
|
+
surface itself is in
|
|
11
|
+
[smrt-svelte overview-surfaces.md](../smrt-svelte/agents/overview-surfaces.md).
|
|
12
|
+
|
|
13
|
+
## Why a package of its own
|
|
14
|
+
|
|
15
|
+
The repo puts each layered-override table in the package that owns its
|
|
16
|
+
resolution (`smrt-prompts`, `smrt-playbooks`, `smrt-features`,
|
|
17
|
+
`smrt-fields`). `smrt-svelte` has no runtime dependency on core, tenancy or
|
|
18
|
+
users and holds no tables. This package imports only smrt-svelte's
|
|
19
|
+
Svelte-free entries (`./overview/server`, `./workspace/server`).
|
|
20
|
+
|
|
21
|
+
## Pieces
|
|
22
|
+
|
|
23
|
+
| File | Contents |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `src/models/UiPreferenceRecord.ts` | The row; owns identity, scope shape and authorization; refuses unregistered kinds |
|
|
26
|
+
| `src/collections/UiPreferenceRecordCollection.ts` | Undecorated reads (`findTier`); internal, not exported; no generated surface |
|
|
27
|
+
| `src/write-capability.ts` | The store-only write capability (internal) |
|
|
28
|
+
| `src/read-guard.ts` | The owner-read interceptor (internal) |
|
|
29
|
+
| `src/kinds.ts` | `registerPreferenceKind`, `requirePreferenceKind`, `UnknownPreferenceKindError`, the validation contract |
|
|
30
|
+
| `src/store.ts` | `createPreferenceStore({ db })`: generic `load` / `save` / `reset(kind, surfaceId, ...)` |
|
|
31
|
+
| `src/kinds/overview.ts`, `src/overview.ts` | The `overview` kind and `createOverviewStore` (`load`, `save`, `reset`, `loadPage`, `loadWidget`, `withTenantDefaults`) |
|
|
32
|
+
| `src/kinds/shell-layout.ts` | The `shell-layout` kind and `createShellSettingsPreferences` (the server half of a `ShellSettingsAdapter`) |
|
|
33
|
+
| `src/kinds/builtin.ts` | Registers the two built-in kinds (imported by the entry and the store) |
|
|
34
|
+
| `src/context.ts` | The ambient principal and `PreferenceAccessError` |
|
|
35
|
+
| `src/permissions.ts` | `overviews.customize` / `overviews.personalize`, `shell.customize` / `shell.personalize` |
|
|
36
|
+
|
|
37
|
+
## Table
|
|
38
|
+
|
|
39
|
+
`_smrt_ui_preferences`: `tenant_id` (UUID), `kind`, `surface_id`,
|
|
40
|
+
`scope_type` (`tenant` | `user`), `user_id` (UUID, NULL on tenant rows),
|
|
41
|
+
`scope_key` (`__tenant__` or the user id), `payload_json` (the kind's
|
|
42
|
+
canonical payload), `format_version`, `updated_by`, plus the SmrtObject
|
|
43
|
+
columns. Unique key `(tenant_id, kind, surface_id, scope_type, scope_key)`;
|
|
44
|
+
every key column is NOT NULL, so the index is total on every engine. It ships
|
|
45
|
+
through manifest-driven migrations (`smrt db:migrate`), never runtime DDL;
|
|
46
|
+
`src/__tests__/migration.test.ts` applies it with `migrateSmrtSchemas` on
|
|
47
|
+
SQLite and DuckDB and asserts parity afterwards. On DuckDB only this table is
|
|
48
|
+
migrated in tests: smrt-users' tables carry ON UPDATE CASCADE foreign keys
|
|
49
|
+
DuckDB cannot create.
|
|
50
|
+
|
|
51
|
+
## Invariants
|
|
52
|
+
|
|
53
|
+
- **Kinds gate everything.** The store and the model refuse a kind that is
|
|
54
|
+
not registered (`UnknownPreferenceKindError`), so no row exists that no code
|
|
55
|
+
can validate. A kind's `validate` runs strictly on save (refuse unless `ok`,
|
|
56
|
+
store only `canonical`, `null` deletes the row) and leniently on load (use
|
|
57
|
+
`canonical`, report `issues`, never rewrite the row). A row written at a
|
|
58
|
+
newer `format_version` than the kind's is dropped and reported.
|
|
59
|
+
- **Store-only writes.** Only the store writes rows: it passes a
|
|
60
|
+
module-private symbol capability (`src/write-capability.ts`, not exported;
|
|
61
|
+
the smrt-approvals pattern) bound to the exact `payloadJson` the kind
|
|
62
|
+
validated, single-use, and grants each delete the same way. The model
|
|
63
|
+
refuses any insert, update (also in `validateBeforeSave`, so bulk creates
|
|
64
|
+
are covered) or delete without it, so `new UiPreferenceRecord(...).save()`,
|
|
65
|
+
a collection `create()` or `row.setPayload(); row.save()` never store an
|
|
66
|
+
unvalidated payload. Tests seed corrupt storage with raw SQL
|
|
67
|
+
(`src/__tests__/raw-rows.ts`). This guards in-process callers, not raw SQL.
|
|
68
|
+
- **Owner-safe reads, in the predicate.** A principal never reads another
|
|
69
|
+
user's row. `src/read-guard.ts` (registered when the model loads and again
|
|
70
|
+
before every store read) adds `scope_key IN ('__tenant__', <own user id>)`
|
|
71
|
+
(tenant rows only without a user id) to the read predicate BEFORE SQL
|
|
72
|
+
runs, intersecting any caller `scopeKey in`. Core's read entry points and
|
|
73
|
+
the hook each runs: `list`, `listByIds`, `findAll`, `listWithLatestRelated`
|
|
74
|
+
(`beforeList` + `afterList`); `get`, `findById`, `findOne`, `getOrUpsert`,
|
|
75
|
+
`update` / `delete` lookups, and model hydration `loadFromId` /
|
|
76
|
+
`loadFromSlug` (`beforeGet`; collection `get` also `afterGet`); `count`,
|
|
77
|
+
`counts`, `facets` (`beforeList` only, so ownership must be in the
|
|
78
|
+
predicate); `semanticSearch*`, `findSimilar*` (`beforeList`, some also
|
|
79
|
+
`afterList`); `query` (`beforeQuery` + `afterQuery`; raw SQL cannot carry
|
|
80
|
+
the predicate, so the guard refuses it). Every read path runs at least one
|
|
81
|
+
hook. The after-read hooks re-check hydrated rows as defence in depth (a
|
|
82
|
+
row whose scope cannot be told is dropped), and hydration also re-checks in
|
|
83
|
+
the model. `count()` and `facets()` therefore count only visible rows.
|
|
84
|
+
- **Not an app API.** `UiPreferenceRecord` stays a root export only because
|
|
85
|
+
generated consumer registration imports every public manifest object from
|
|
86
|
+
the root; the collection is not exported. Apps use `createPreferenceStore`,
|
|
87
|
+
`createOverviewStore` and `createShellSettingsPreferences`.
|
|
88
|
+
- **Tiers.** The tenant row is the default; the user row applies on top, and
|
|
89
|
+
`validate` for the user tier receives the tenant tier's canonical payload
|
|
90
|
+
(`ctx.tenant`). User rows are per tenant: preferences name tenant data.
|
|
91
|
+
- **Identity is ambient.** No API takes a tenant or user id. A missing
|
|
92
|
+
principal throws `PreferenceAccessError`; a principal without a user id has
|
|
93
|
+
no user tier and cannot write one. A session-only principal (users
|
|
94
|
+
`createSessionHandler()` without `enterTenantContext`) is entered as a
|
|
95
|
+
tenant context with the same identity before any table access.
|
|
96
|
+
- **Tenancy.** The model is `@TenantScoped({ mode: 'required' })`, and every
|
|
97
|
+
store read also names the tenant explicitly (a super-admin bypass context
|
|
98
|
+
does not filter reads). `save()`/`delete()` refuse a row whose stored
|
|
99
|
+
tenant, kind, surface or tier differs from memory (matched by predicate:
|
|
100
|
+
DuckDB returns raw UUID columns as lossy HUGEINTs), a foreign tenant, and a
|
|
101
|
+
user row that is not the principal's.
|
|
102
|
+
- **Permissions are per kind.** Each kind names `{ tenant, user }` slugs;
|
|
103
|
+
the model asserts them (`assertOperationPermission`, fail closed) and the
|
|
104
|
+
store reports them as `canCustomize` (`checkOperationPermission`). A slug
|
|
105
|
+
must be in the users catalog or it never grants. The users resolver denies
|
|
106
|
+
a principal with no user id even with the slug in its set. The built-in
|
|
107
|
+
`personalize` slugs are in users' self-personalization list (seeded for
|
|
108
|
+
owner/admin/member/viewer once registered).
|
|
109
|
+
- **Permission source.** The principal's permission set is authoritative,
|
|
110
|
+
including an explicit empty set: a tenancy adapter without
|
|
111
|
+
`resolvePermissions` refuses everyone (fails closed). Hosts must configure
|
|
112
|
+
it, or enter identity through users' `enterTenantContext`; never widen it
|
|
113
|
+
here.
|
|
114
|
+
- **Concurrency.** Optimistic: a save passes the `revision` (ISO
|
|
115
|
+
`updated_at`) it loaded, `null` meaning "no row". Updates use core's
|
|
116
|
+
revision CAS (`save({ expectedUpdatedAt })`), a first write is a strict
|
|
117
|
+
insert (`_insertOnly`) so a racing first write hits the unique index, and
|
|
118
|
+
a delete is guarded the same way. `reset` treats `revision: null` like
|
|
119
|
+
save does (a row that exists now conflicts); only an omitted revision
|
|
120
|
+
resets unguarded. Mismatch: `{ ok: false, reason: 'conflict' }`. Exception, documented: `createShellSettingsPreferences().write`
|
|
121
|
+
without a revision reads the current one first, i.e. last-writer-wins,
|
|
122
|
+
because the shell's `ShellSettingsAdapter.write(delta)` carries none.
|
|
123
|
+
- **Overview `loadWidget`** lets a principal without either customize
|
|
124
|
+
permission load only a widget on their resolved overview; anything else is
|
|
125
|
+
`not_allowed`. The widget is sanitized against the page definition first.
|
|
126
|
+
- **No generated surface** (`api: { include: [] }`, `cli: false`,
|
|
127
|
+
`mcp: { include: [] }`): a generated route would skip kind validation.
|
|
128
|
+
|
|
129
|
+
## Tests
|
|
130
|
+
|
|
131
|
+
`pnpm test` runs the overview suite and the kinds suite on SQLite and DuckDB
|
|
132
|
+
plus the migration suite; `pnpm test:postgres` runs both suites on PostgreSQL
|
|
133
|
+
(`*.optional.test.ts`, skipped without a database).
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright <2025> <Happy Vertical Corporation>
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,135 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @happyvertical/smrt-preferences
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Saved user-interface preferences for s-m-r-t apps: how a page or the app
|
|
4
|
+
frame is arranged. An organization sets the default; each person can keep
|
|
5
|
+
their own version on top of it. Reset returns either one to the layer below.
|
|
6
|
+
|
|
7
|
+
Two kinds of preference ship with the package:
|
|
8
|
+
|
|
9
|
+
- **`overview`**: the layout of a customizable overview page from
|
|
10
|
+
[`smrt-svelte/overview`](../smrt-svelte/agents/overview-surfaces.md)
|
|
11
|
+
(which widgets, in what order, how wide, with which options).
|
|
12
|
+
- **`shell-layout`**: the app frame's settings from the AdminShell
|
|
13
|
+
(navigation order and visibility, panel states and widths, keyboard
|
|
14
|
+
shortcuts).
|
|
15
|
+
|
|
16
|
+
An app can register more kinds. Every kind checks its own data when it is
|
|
17
|
+
saved (anything invalid is refused) and again when it is loaded (anything
|
|
18
|
+
that no longer fits is dropped and reported, the rest still applies).
|
|
19
|
+
|
|
20
|
+
Use the stores below; the table's model is not an application API (it
|
|
21
|
+
refuses writes that do not come from the store and never returns another
|
|
22
|
+
person's preferences).
|
|
23
|
+
|
|
24
|
+
See [AGENTS.md](./AGENTS.md) for the storage rules and
|
|
25
|
+
[agents/preference-kinds.md](./agents/preference-kinds.md) for the kind
|
|
26
|
+
contract.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pnpm add @happyvertical/smrt-preferences
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Preferences live in the `_smrt_ui_preferences` table. Create it with your
|
|
35
|
+
normal migration (`smrt db:migrate`); the package never creates tables at
|
|
36
|
+
runtime. `smrt db:status --parity` reports it like any other table.
|
|
37
|
+
|
|
38
|
+
## Permissions
|
|
39
|
+
|
|
40
|
+
| Permission | Lets a person |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `overviews.customize` | Change or reset the organization's default layout of a page |
|
|
43
|
+
| `overviews.personalize` | Keep and reset their own layout of a page |
|
|
44
|
+
| `shell.customize` | Change or reset the organization's default app frame |
|
|
45
|
+
| `shell.personalize` | Keep and reset their own app frame settings |
|
|
46
|
+
|
|
47
|
+
The store reads these from the signed-in person's permission set in the
|
|
48
|
+
request context. If your app enters the tenant context through the tenancy
|
|
49
|
+
SvelteKit or Express adapter, configure its `resolvePermissions` (or let
|
|
50
|
+
`createSessionHandler()` from `@happyvertical/smrt-users` enter the context
|
|
51
|
+
with `enterTenantContext`); without it the adapter supplies an empty
|
|
52
|
+
permission set, and everyone, owners included, is refused.
|
|
53
|
+
|
|
54
|
+
Owners and admins receive all four when role permissions are seeded. Members
|
|
55
|
+
and viewers receive the two `personalize` permissions; for roles created
|
|
56
|
+
before this package was installed, run
|
|
57
|
+
`seedDefaultRolePersonalizationPermissions()` from `@happyvertical/smrt-users`
|
|
58
|
+
once.
|
|
59
|
+
|
|
60
|
+
## Overview pages
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// +page.server.ts
|
|
64
|
+
import { createOverviewStore } from '@happyvertical/smrt-preferences';
|
|
65
|
+
import { eventsHome, widgets } from '$lib/overviews';
|
|
66
|
+
|
|
67
|
+
export const load = async ({ locals }) => {
|
|
68
|
+
const overviews = createOverviewStore({ db: locals.db });
|
|
69
|
+
// Runs inside the request's tenant context; each widget loader receives
|
|
70
|
+
// the capabilities passed here and must authorize through them.
|
|
71
|
+
const overview = await overviews.loadPage(eventsHome, widgets, {
|
|
72
|
+
locale: locals.locale,
|
|
73
|
+
db: locals.db,
|
|
74
|
+
});
|
|
75
|
+
return { overview };
|
|
76
|
+
};
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Save with the revision the page loaded; a stale revision returns `conflict`
|
|
80
|
+
instead of overwriting someone else's change:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
const saved = await overviews.save(eventsHome, widgets, {
|
|
84
|
+
scope: 'user',
|
|
85
|
+
override,
|
|
86
|
+
revision,
|
|
87
|
+
});
|
|
88
|
+
// saved.ok, or saved.reason: 'invalid' (with issues), 'conflict', 'not_allowed'
|
|
89
|
+
await overviews.reset(eventsHome, { scope: 'user', revision });
|
|
90
|
+
const result = await overviews.loadWidget(eventsHome, widgets, widget, ctx);
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## App frame (shell layout)
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import {
|
|
97
|
+
createPreferenceStore,
|
|
98
|
+
createShellSettingsPreferences,
|
|
99
|
+
} from '@happyvertical/smrt-preferences';
|
|
100
|
+
|
|
101
|
+
const shell = createShellSettingsPreferences(createPreferenceStore({ db }));
|
|
102
|
+
|
|
103
|
+
// Remote functions the browser's ShellSettingsAdapter calls.
|
|
104
|
+
export const readShellSettings = async () => (await shell.read('admin')).delta;
|
|
105
|
+
export const writeShellSettings = (delta: unknown) =>
|
|
106
|
+
shell.write('admin', delta);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`read` merges the organization default with the person's own settings.
|
|
110
|
+
`write` without a revision writes over the latest stored value, because the
|
|
111
|
+
shell's adapter does not carry one; pass `{ revision }` to guard it.
|
|
112
|
+
|
|
113
|
+
## Other kinds
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import {
|
|
117
|
+
createPreferenceStore,
|
|
118
|
+
registerPreferenceKind,
|
|
119
|
+
} from '@happyvertical/smrt-preferences';
|
|
120
|
+
|
|
121
|
+
registerPreferenceKind({
|
|
122
|
+
kind: 'board-columns',
|
|
123
|
+
formatVersion: 1,
|
|
124
|
+
permissions: { tenant: 'boards.customize', user: 'boards.personalize' },
|
|
125
|
+
validate: (payload, ctx) => checkColumns(payload, ctx),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const preferences = createPreferenceStore({ db });
|
|
129
|
+
const state = await preferences.load('board-columns', 'projects.board');
|
|
130
|
+
await preferences.save('board-columns', 'projects.board', {
|
|
131
|
+
scope: 'user',
|
|
132
|
+
payload,
|
|
133
|
+
revision: state.user?.revision ?? null,
|
|
134
|
+
});
|
|
135
|
+
```
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Preference kinds
|
|
2
|
+
|
|
3
|
+
The contract between `_smrt_ui_preferences` and the code that owns a
|
|
4
|
+
preference's meaning (#3727). Package rules are in [../AGENTS.md](../AGENTS.md).
|
|
5
|
+
|
|
6
|
+
## Registering a kind
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
registerPreferenceKind({
|
|
10
|
+
kind: 'board-columns', // /^[a-z][a-z0-9-]{0,47}$/
|
|
11
|
+
formatVersion: 1, // positive integer, stored per row
|
|
12
|
+
permissions: { tenant: 'boards.customize', user: 'boards.personalize' },
|
|
13
|
+
validate(payload, ctx) { // never throws
|
|
14
|
+
return { ok, canonical, issues };
|
|
15
|
+
},
|
|
16
|
+
}, { replace? }) // -> dispose()
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Registration is code at module scope, process-wide (one registry per
|
|
20
|
+
process). Registering a kind twice throws unless `{ replace: true }`. Both
|
|
21
|
+
slugs must be `collection.action` and registered in the users catalog
|
|
22
|
+
(`registerPermissionDefinitions`), or they never grant; add a `personalize`
|
|
23
|
+
slug to users' built-in self-personalization list only when every built-in
|
|
24
|
+
role should hold it.
|
|
25
|
+
|
|
26
|
+
The store authorizes against the principal's permission set from the
|
|
27
|
+
ambient context and never widens it. A tenancy adapter (SvelteKit, Express)
|
|
28
|
+
without `resolvePermissions` supplies an explicit empty set, which the store
|
|
29
|
+
treats as authoritative: every write is `not_allowed` and `canCustomize` is
|
|
30
|
+
false for everyone, owners included. Configure `resolvePermissions`, or let
|
|
31
|
+
users' `createSessionHandler({ enterTenantContext: true })` enter the context.
|
|
32
|
+
|
|
33
|
+
## `validate(payload, ctx)`
|
|
34
|
+
|
|
35
|
+
| `ctx` field | Meaning |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `kind`, `surfaceId` | What is being validated (`surfaceId` matches `PREFERENCE_SURFACE_PATTERN`) |
|
|
38
|
+
| `scope` | `tenant` or `user` |
|
|
39
|
+
| `phase` | `save` (strict) or `load` (lenient) |
|
|
40
|
+
| `formatVersion` | The version the payload was written at (the kind's own on save); migrate older payloads here |
|
|
41
|
+
| `tenant` | User tier only: the tenant tier's canonical payload, or `null` |
|
|
42
|
+
| `options` | Host-supplied, kind-specific inputs, passed through from `load` / `save` |
|
|
43
|
+
|
|
44
|
+
Return `canonical`: the payload with every invalid part removed (`null` means
|
|
45
|
+
no preference), `issues`: what was removed and why (`{ path, code, message,
|
|
46
|
+
detail? }`), and `ok`: false whenever anything was removed or refused.
|
|
47
|
+
|
|
48
|
+
- On **save** the store refuses unless `ok`, stores only `canonical` (as
|
|
49
|
+
JSON with the kind's `formatVersion`), and deletes the row when `canonical`
|
|
50
|
+
is `null`.
|
|
51
|
+
- On **load** the store uses `canonical` and reports `issues` per tier; the
|
|
52
|
+
row is not rewritten. Unparseable JSON and rows from a newer
|
|
53
|
+
`formatVersion` are dropped before `validate` runs. A `validate` that
|
|
54
|
+
throws on load drops the tier with a `validator_error` issue; on save the
|
|
55
|
+
error propagates and nothing is stored. Validators should not throw.
|
|
56
|
+
- A payload is data, never code: no query strings, URLs or expressions a
|
|
57
|
+
reader would execute. Authorization happens where the data is read, never
|
|
58
|
+
through payload values.
|
|
59
|
+
|
|
60
|
+
## Built-in kinds
|
|
61
|
+
|
|
62
|
+
| Kind | Payload | Validator | Permissions | `options` |
|
|
63
|
+
| --- | --- | --- | --- | --- |
|
|
64
|
+
| `overview` | `OverviewOverride` (sparse, version 1) | save: `checkOverviewOverride`; load: `resolveOverview` + `diffOverview`, against the page definition (user tier: tenant-merged via `withTenantDefaults`) | `overviews.customize` / `overviews.personalize` | `{ definition, registry }`; `definition.id` must equal the surface id |
|
|
65
|
+
| `shell-layout` | `ShellSettingsDelta` | `sanitizeShellSettingsDelta` from `@happyvertical/smrt-svelte/workspace/server` | `shell.customize` / `shell.personalize` | none |
|
|
66
|
+
|
|
67
|
+
## Store calls
|
|
68
|
+
|
|
69
|
+
`createPreferenceStore({ db })` returns `load(kind, surfaceId, { options })`
|
|
70
|
+
(both tiers, `canCustomize` from the kind's pair), `save(kind, surfaceId, {
|
|
71
|
+
scope, payload, revision, options })` and `reset(kind, surfaceId, { scope,
|
|
72
|
+
revision? })` (`null` conflicts when a row exists; omitted is unguarded). Failures are `{ ok: false, reason: 'not_allowed' | 'conflict'
|
|
73
|
+
| 'invalid' }`; an unregistered kind throws `UnknownPreferenceKindError`.
|
|
74
|
+
|
|
75
|
+
Kind-shaped layers sit on top: `createOverviewStore` (the overview page API)
|
|
76
|
+
and `createShellSettingsPreferences` (the server half of the shell's
|
|
77
|
+
`ShellSettingsAdapter`: `read(surfaceId)` merges tenant and user with
|
|
78
|
+
`mergeShellSettingsDelta`; `write(surfaceId, delta, { scope?, revision? })`
|
|
79
|
+
is last-writer-wins only when the revision is omitted).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"__smrt-register__.d.ts","sourceRoot":"","sources":["../src/__smrt-register__.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { SmrtCollection } from '@happyvertical/smrt-core';
|
|
2
|
+
import type { PreferenceScope } from '../kinds.js';
|
|
3
|
+
import { UiPreferenceRecord } from '../models/UiPreferenceRecord.js';
|
|
4
|
+
/**
|
|
5
|
+
* Reads of {@link UiPreferenceRecord}. Not decorated: the model has no
|
|
6
|
+
* generated surface, so a decorated collection would only add a second
|
|
7
|
+
* manifest schema for the same table.
|
|
8
|
+
*/
|
|
9
|
+
export declare class UiPreferenceRecordCollection extends SmrtCollection<UiPreferenceRecord> {
|
|
10
|
+
static readonly _itemClass: typeof UiPreferenceRecord;
|
|
11
|
+
/**
|
|
12
|
+
* The row of one tier, or `null`. The tenant predicate is explicit (not
|
|
13
|
+
* only the interceptor's) because a super-admin bypass context does not
|
|
14
|
+
* filter reads.
|
|
15
|
+
*/
|
|
16
|
+
findTier(tenantId: string, kind: string, surfaceId: string, scope: PreferenceScope, userId?: string): Promise<UiPreferenceRecord | null>;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=UiPreferenceRecordCollection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"UiPreferenceRecordCollection.d.ts","sourceRoot":"","sources":["../../src/collections/UiPreferenceRecordCollection.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC1D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,EAEL,kBAAkB,EACnB,MAAM,iCAAiC,CAAC;AAEzC;;;;GAIG;AACH,qBAAa,4BAA6B,SAAQ,cAAc,CAAC,kBAAkB,CAAC;IAClF,MAAM,CAAC,QAAQ,CAAC,UAAU,4BAAsB;IAEhD;;;;OAIG;IACG,QAAQ,CACZ,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,eAAe,EACtB,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;CAStC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request principal a preference read or write acts as.
|
|
3
|
+
*
|
|
4
|
+
* Identity always comes from the ambient context, never from a request body:
|
|
5
|
+
* the tenancy ALS (`withTenant`, the SvelteKit/Express adapters) first, and
|
|
6
|
+
* the authenticated users session-permission ALS (`createSessionHandler()`
|
|
7
|
+
* without `enterTenantContext`) as the fallback, the smrt-fields precedent.
|
|
8
|
+
*/
|
|
9
|
+
import { TenantIsolationError } from '@happyvertical/smrt-tenancy';
|
|
10
|
+
export interface PreferencePrincipal {
|
|
11
|
+
tenantId: string;
|
|
12
|
+
/** Absent for service principals, API keys and background jobs. */
|
|
13
|
+
userId?: string;
|
|
14
|
+
permissions: ReadonlySet<string>;
|
|
15
|
+
superAdminBypass: boolean;
|
|
16
|
+
/** True when the tenancy ALS already carries this principal. */
|
|
17
|
+
fromTenancy: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** The preference principal of the current async context, if any. */
|
|
20
|
+
export declare function getPreferencePrincipal(): PreferencePrincipal | undefined;
|
|
21
|
+
/** Bounded, identifier-free denial; generated transports serialize it as 403. */
|
|
22
|
+
export declare class PreferenceAccessError extends TenantIsolationError {
|
|
23
|
+
readonly status = 403;
|
|
24
|
+
readonly publicMessage: string;
|
|
25
|
+
constructor(message: string);
|
|
26
|
+
}
|
|
27
|
+
/** The principal, or a fail-closed error when there is no tenant identity. */
|
|
28
|
+
export declare function requirePreferencePrincipal(): PreferencePrincipal;
|
|
29
|
+
/**
|
|
30
|
+
* Run `fn` with the tenancy ALS carrying `principal`. A session-only
|
|
31
|
+
* principal is entered as a tenant context with the same tenant, user and
|
|
32
|
+
* permissions, so the tenancy interceptor scopes the table exactly as it would
|
|
33
|
+
* under the tenancy adapter. Nothing is widened.
|
|
34
|
+
*/
|
|
35
|
+
export declare function withPrincipal<T>(principal: PreferencePrincipal, fn: () => Promise<T>): Promise<T>;
|
|
36
|
+
//# sourceMappingURL=context.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAGL,oBAAoB,EAErB,MAAM,6BAA6B,CAAC;AAGrC,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,mEAAmE;IACnE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACjC,gBAAgB,EAAE,OAAO,CAAC;IAC1B,gEAAgE;IAChE,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,qEAAqE;AACrE,wBAAgB,sBAAsB,IAAI,mBAAmB,GAAG,SAAS,CAoBxE;AAED,iFAAiF;AACjF,qBAAa,qBAAsB,SAAQ,oBAAoB;IAC7D,QAAQ,CAAC,MAAM,OAAO;IACtB,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;gBAEnB,OAAO,EAAE,MAAM;CAK5B;AAED,8EAA8E;AAC9E,wBAAgB,0BAA0B,IAAI,mBAAmB,CAQhE;AAED;;;;;GAKG;AACH,wBAAsB,aAAa,CAAC,CAAC,EACnC,SAAS,EAAE,mBAAmB,EAC9B,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,CAAC,CAUZ"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @happyvertical/smrt-preferences
|
|
3
|
+
*
|
|
4
|
+
* Tenant defaults and per-user preferences for user-interface surfaces
|
|
5
|
+
* (#3727), stored in the `_smrt_ui_preferences` system table and validated
|
|
6
|
+
* per registered kind. Built-in kinds: `overview` (customizable overview
|
|
7
|
+
* pages of `@happyvertical/smrt-svelte/overview`) and `shell-layout` (the
|
|
8
|
+
* AdminShell settings delta).
|
|
9
|
+
*
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
*/
|
|
12
|
+
import './__smrt-register__.js';
|
|
13
|
+
import './kinds/builtin.js';
|
|
14
|
+
export { getPreferencePrincipal, PreferenceAccessError, type PreferencePrincipal, } from './context.js';
|
|
15
|
+
export { ensureBuiltinPreferenceKinds } from './kinds/builtin.js';
|
|
16
|
+
export { OVERVIEW_PREFERENCE_KIND, type OverviewKindOptions, overviewPreferenceKind, withTenantDefaults, } from './kinds/overview.js';
|
|
17
|
+
export { createShellSettingsPreferences, SHELL_LAYOUT_PREFERENCE_KIND, type ShellSettingsPreferenceRead, type ShellSettingsPreferences, type ShellSettingsPreferenceWriteOptions, shellLayoutPreferenceKind, } from './kinds/shell-layout.js';
|
|
18
|
+
export { getPreferenceKind, listPreferenceKinds, PREFERENCE_KIND_PATTERN, PREFERENCE_SCOPES, PREFERENCE_SURFACE_PATTERN, type PreferenceIssue, type PreferenceKindDefinition, type PreferenceScope, type PreferenceValidateContext, type PreferenceValidation, registerPreferenceKind, requirePreferenceKind, UnknownPreferenceKindError, } from './kinds.js';
|
|
19
|
+
export { TENANT_SCOPE_KEY, UiPreferenceRecord, } from './models/UiPreferenceRecord.js';
|
|
20
|
+
export { createOverviewStore, type OverviewPageOptions, type OverviewResetInput, type OverviewResetResult, type OverviewSaveInput, type OverviewSaveResult, type OverviewState, type OverviewStore, type OverviewStoreOptions, type OverviewTierState, type OverviewWidgetResult, type OverviewWriteFailure, } from './overview.js';
|
|
21
|
+
export { CUSTOMIZE_OVERVIEW_PERMISSION, CUSTOMIZE_SHELL_PERMISSION, ensurePreferencePermissionsRegistered, PERSONALIZE_OVERVIEW_PERMISSION, PERSONALIZE_SHELL_PERMISSION, PREFERENCE_PERMISSION_DEFINITIONS, splitPermissionSlug, } from './permissions.js';
|
|
22
|
+
export { createPreferenceStore, type PreferenceLoadInput, type PreferenceResetInput, type PreferenceResetResult, type PreferenceSaveInput, type PreferenceSaveResult, type PreferenceState, type PreferenceStore, type PreferenceStoreOptions, type PreferenceTier, type PreferenceWriteFailure, } from './store.js';
|
|
23
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,wBAAwB,CAAC;AAChC,OAAO,oBAAoB,CAAC;AAE5B,OAAO,EACL,sBAAsB,EACtB,qBAAqB,EACrB,KAAK,mBAAmB,GACzB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,4BAA4B,EAAE,MAAM,oBAAoB,CAAC;AAClE,OAAO,EACL,wBAAwB,EACxB,KAAK,mBAAmB,EACxB,sBAAsB,EACtB,kBAAkB,GACnB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,8BAA8B,EAC9B,4BAA4B,EAC5B,KAAK,2BAA2B,EAChC,KAAK,wBAAwB,EAC7B,KAAK,mCAAmC,EACxC,yBAAyB,GAC1B,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACL,iBAAiB,EACjB,mBAAmB,EACnB,uBAAuB,EACvB,iBAAiB,EACjB,0BAA0B,EAC1B,KAAK,eAAe,EACpB,KAAK,wBAAwB,EAC7B,KAAK,eAAe,EACpB,KAAK,yBAAyB,EAC9B,KAAK,oBAAoB,EACzB,sBAAsB,EACtB,qBAAqB,EACrB,0BAA0B,GAC3B,MAAM,YAAY,CAAC;AAMpB,OAAO,EACL,gBAAgB,EAChB,kBAAkB,GACnB,MAAM,gCAAgC,CAAC;AACxC,OAAO,EACL,mBAAmB,EACnB,KAAK,mBAAmB,EACxB,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,EACxB,KAAK,iBAAiB,EACtB,KAAK,kBAAkB,EACvB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,oBAAoB,EACzB,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,GAC1B,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,6BAA6B,EAC7B,0BAA0B,EAC1B,qCAAqC,EACrC,+BAA+B,EAC/B,4BAA4B,EAC5B,iCAAiC,EACjC,mBAAmB,GACpB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,qBAAqB,EAC1B,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC3B,KAAK,cAAc,EACnB,KAAK,sBAAsB,GAC5B,MAAM,YAAY,CAAC"}
|