esoul-sdk 0.7.0 → 0.16.0
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 +575 -153
- package/api-reference.md +3291 -0
- package/dist/assets.d.ts +72 -0
- package/dist/assets.js +139 -0
- package/dist/audience.d.ts +2 -0
- package/dist/audience.js +2 -0
- package/dist/bindings.d.ts +3 -0
- package/dist/bindings.js +3 -0
- package/dist/chart-font.d.ts +19 -0
- package/dist/chart-font.js +15 -0
- package/dist/chart.d.ts +83 -0
- package/dist/chart.js +247 -0
- package/dist/computer.d.ts +191 -0
- package/dist/computer.js +226 -0
- package/dist/db/client-core.d.ts +38 -0
- package/dist/db/client-core.js +68 -1
- package/dist/db/compile-rules.d.ts +60 -2
- package/dist/db/compile-rules.js +178 -30
- package/dist/db/custom-roles.d.ts +156 -0
- package/dist/db/custom-roles.js +280 -0
- package/dist/db/memory-client.d.ts +0 -15
- package/dist/db/memory-client.js +13 -23
- package/dist/db/schema-gen.js +2 -8
- package/dist/editor-sync.d.ts +130 -0
- package/dist/editor-sync.js +413 -0
- package/dist/files.d.ts +70 -0
- package/dist/files.js +48 -0
- package/dist/helpers.d.ts +10 -0
- package/dist/helpers.js +10 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +25 -0
- package/dist/labelme.d.ts +84 -0
- package/dist/labelme.js +118 -0
- package/dist/manifest.d.ts +400 -106
- package/dist/manifest.js +59 -5
- package/dist/ops.d.ts +115 -0
- package/dist/ops.js +120 -0
- package/dist/react.d.ts +280 -4
- package/dist/react.js +100 -3
- package/dist/server.d.ts +268 -10
- package/dist/server.js +120 -4
- package/dist/testing/db.d.ts +6 -0
- package/dist/testing/db.js +3 -8
- package/dist/testing/files.d.ts +22 -0
- package/dist/testing/files.js +175 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +1 -0
- package/dist/types.d.ts +47 -0
- package/docs/02-manifest.md +2 -0
- package/docs/03-events-and-state.md +8 -0
- package/docs/04-tools.md +58 -26
- package/docs/05-ui.md +35 -0
- package/docs/06-server.md +121 -7
- package/docs/07-background-tasks.md +7 -9
- package/docs/09-files.md +126 -17
- package/docs/10-testing.md +6 -0
- package/docs/11-shipping.md +10 -2
- package/docs/13-people-and-access.md +180 -0
- package/docs/14-database.md +6 -0
- package/docs/15-realtime.md +3 -0
- package/docs/17-editing-and-merging.md +155 -0
- package/llms-full.txt +1296 -214
- package/llms.txt +3 -3
- package/package.json +9 -4
- package/schemas/plugin.schema.json +134 -15
- package/scripts/build-api-reference.mjs +104 -0
- package/scripts/build-llms.mjs +1 -2
package/dist/db/client-core.d.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* agree on the values.
|
|
22
22
|
*/
|
|
23
23
|
import { type CompiledModel, type CompiledRules, type ReadPlan, type RuleViewer, type WritePlan } from "./compile-rules.js";
|
|
24
|
+
import { type Narrowing } from "./custom-roles.js";
|
|
24
25
|
export type Row = Record<string, unknown>;
|
|
25
26
|
export type RefuseCode = "forbidden" | "login-required" | "invalid" | "not-bound" | "rate-limited";
|
|
26
27
|
export type Refuse = (code: RefuseCode, message: string, detail?: string) => never;
|
|
@@ -52,6 +53,14 @@ export declare const FILTER_OPS: readonly ["in", "notIn", "not", "gt", "gte", "l
|
|
|
52
53
|
/** The only operator a LIST field takes, and the only one a scalar does not. */
|
|
53
54
|
export declare const LIST_ONLY_OPS: readonly ["has"];
|
|
54
55
|
export type FilterOp = (typeof FILTER_OPS)[number];
|
|
56
|
+
/**
|
|
57
|
+
* A MALFORMED CALL SAYS WHAT IS WRONG WITH IT. The reason is about the app's own
|
|
58
|
+
* code — a take over the cap, an unindexed filter — never about a viewer's data, so
|
|
59
|
+
* it rides in the message, where an author sees it: in a test, a tool result, a
|
|
60
|
+
* log. It used to be the bare word "invalid", with the reason tucked into `detail`
|
|
61
|
+
* that only a workbench box returns, so an author who asked for 201 rows was told
|
|
62
|
+
* nothing (2026-09-14).
|
|
63
|
+
*/
|
|
55
64
|
export declare function bad(c: Core, detail: string): never;
|
|
56
65
|
/**
|
|
57
66
|
* A caller-supplied scope key is REFUSED, never overridden. An app that sets
|
|
@@ -128,6 +137,35 @@ export declare function scopeConjunct(c: Core, model?: CompiledModel): ScopeConj
|
|
|
128
137
|
export declare function scopeMatches(c: Core, row: Row, model?: CompiledModel): boolean;
|
|
129
138
|
/** Sealed fields are readable by the row's owner and by the app's own task. */
|
|
130
139
|
export declare function mayUnseal(c: Core, row: Row): boolean;
|
|
140
|
+
/** Does this row fall inside the composed role's `where`? (Memory's predicate; Prisma spells `narrowWhere`.) */
|
|
141
|
+
export declare function narrowMatches(plan: {
|
|
142
|
+
narrow: Narrowing;
|
|
143
|
+
}, row: Row): boolean;
|
|
144
|
+
/** The narrowing as Prisma conjuncts — one `{ field: value }` or `{ field: { in } }` per clause. */
|
|
145
|
+
export declare function narrowWhere(plan: {
|
|
146
|
+
narrow: Narrowing;
|
|
147
|
+
}): Record<string, unknown>[];
|
|
148
|
+
/**
|
|
149
|
+
* A CREATE lands inside the caller's scope or not at all. For each scalar
|
|
150
|
+
* conjunct of the narrowing, a missing value is filled in (a customer's reading
|
|
151
|
+
* is theirs without saying so) and a different one is refused — a scoped role
|
|
152
|
+
* can no more write another customer's row than read it. A list conjunct is a
|
|
153
|
+
* membership check.
|
|
154
|
+
*/
|
|
155
|
+
export declare function withinScope(c: Core, plan: {
|
|
156
|
+
narrow: Narrowing;
|
|
157
|
+
}, data: Row): Row;
|
|
158
|
+
/** Fields the composed role may not see: read as null, exactly as a sealed field is for a stranger. */
|
|
159
|
+
export declare function hideFields(plan: {
|
|
160
|
+
narrow: Narrowing;
|
|
161
|
+
}, row: Row): Row;
|
|
162
|
+
/**
|
|
163
|
+
* A move the composed role was not handed is refused, by name. `target` is the
|
|
164
|
+
* row as it stands; a data that does not touch the transition field is not a move.
|
|
165
|
+
*/
|
|
166
|
+
export declare function assertTransition(c: Core, plan: {
|
|
167
|
+
narrow: Narrowing;
|
|
168
|
+
}, target: Row, data: Row): void;
|
|
131
169
|
/**
|
|
132
170
|
* Refuses, or hands back the plan — never both, never neither.
|
|
133
171
|
*
|
package/dist/db/client-core.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* agree on the values.
|
|
22
22
|
*/
|
|
23
23
|
import { INJECTED_COLUMNS, canUnseal, planRead, planWrite, } from "./compile-rules.js";
|
|
24
|
+
import { rowMatchesNarrowing, transitionAllowed } from "./custom-roles.js";
|
|
24
25
|
/** Standalone default so the package works with no host attached. */
|
|
25
26
|
export const defaultRefuse = (code, message, detail) => {
|
|
26
27
|
const err = new Error(message);
|
|
@@ -35,8 +36,16 @@ export const FILTER_OPS = ["in", "notIn", "not", "gt", "gte", "lt", "lte", "cont
|
|
|
35
36
|
/** The only operator a LIST field takes, and the only one a scalar does not. */
|
|
36
37
|
export const LIST_ONLY_OPS = ["has"];
|
|
37
38
|
/* ───────────────────────── argument validation ────────────────────────── */
|
|
39
|
+
/**
|
|
40
|
+
* A MALFORMED CALL SAYS WHAT IS WRONG WITH IT. The reason is about the app's own
|
|
41
|
+
* code — a take over the cap, an unindexed filter — never about a viewer's data, so
|
|
42
|
+
* it rides in the message, where an author sees it: in a test, a tool result, a
|
|
43
|
+
* log. It used to be the bare word "invalid", with the reason tucked into `detail`
|
|
44
|
+
* that only a workbench box returns, so an author who asked for 201 rows was told
|
|
45
|
+
* nothing (2026-09-14).
|
|
46
|
+
*/
|
|
38
47
|
export function bad(c, detail) {
|
|
39
|
-
return c.refuse("invalid",
|
|
48
|
+
return c.refuse("invalid", `invalid: ${detail}`, detail);
|
|
40
49
|
}
|
|
41
50
|
/**
|
|
42
51
|
* A caller-supplied scope key is REFUSED, never overridden. An app that sets
|
|
@@ -194,6 +203,64 @@ export function mayUnseal(c, row) {
|
|
|
194
203
|
return true;
|
|
195
204
|
return canUnseal(c.viewer, row);
|
|
196
205
|
}
|
|
206
|
+
/* ───────────────────── a composed role's narrowing ───────────────────── */
|
|
207
|
+
/** Does this row fall inside the composed role's `where`? (Memory's predicate; Prisma spells `narrowWhere`.) */
|
|
208
|
+
export function narrowMatches(plan, row) {
|
|
209
|
+
return rowMatchesNarrowing(plan.narrow, row);
|
|
210
|
+
}
|
|
211
|
+
/** The narrowing as Prisma conjuncts — one `{ field: value }` or `{ field: { in } }` per clause. */
|
|
212
|
+
export function narrowWhere(plan) {
|
|
213
|
+
const w = plan.narrow.where;
|
|
214
|
+
if (!w)
|
|
215
|
+
return [];
|
|
216
|
+
return Object.entries(w).map(([f, v]) => (Array.isArray(v) ? { [f]: { in: v } } : { [f]: v }));
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* A CREATE lands inside the caller's scope or not at all. For each scalar
|
|
220
|
+
* conjunct of the narrowing, a missing value is filled in (a customer's reading
|
|
221
|
+
* is theirs without saying so) and a different one is refused — a scoped role
|
|
222
|
+
* can no more write another customer's row than read it. A list conjunct is a
|
|
223
|
+
* membership check.
|
|
224
|
+
*/
|
|
225
|
+
export function withinScope(c, plan, data) {
|
|
226
|
+
const w = plan.narrow.where;
|
|
227
|
+
if (!w)
|
|
228
|
+
return data;
|
|
229
|
+
const out = { ...data };
|
|
230
|
+
for (const [field, v] of Object.entries(w)) {
|
|
231
|
+
if (Array.isArray(v)) {
|
|
232
|
+
if (out[field] === undefined || !v.includes(out[field])) {
|
|
233
|
+
c.refuse("forbidden", "forbidden", `${c.model.name}.create: ${field} must be one of ${v.map((x) => JSON.stringify(x)).join(", ")} for this caller`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else if (out[field] === undefined)
|
|
237
|
+
out[field] = v;
|
|
238
|
+
else if (out[field] !== v)
|
|
239
|
+
c.refuse("forbidden", "forbidden", `${c.model.name}.create: ${field} is ${JSON.stringify(v)} for this caller, not ${JSON.stringify(out[field])}`);
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
/** Fields the composed role may not see: read as null, exactly as a sealed field is for a stranger. */
|
|
244
|
+
export function hideFields(plan, row) {
|
|
245
|
+
if (!plan.narrow.hide.length)
|
|
246
|
+
return row;
|
|
247
|
+
const out = { ...row };
|
|
248
|
+
for (const f of plan.narrow.hide)
|
|
249
|
+
out[f] = null;
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* A move the composed role was not handed is refused, by name. `target` is the
|
|
254
|
+
* row as it stands; a data that does not touch the transition field is not a move.
|
|
255
|
+
*/
|
|
256
|
+
export function assertTransition(c, plan, target, data) {
|
|
257
|
+
const ok = transitionAllowed(plan.narrow, target, data);
|
|
258
|
+
if (ok === false) {
|
|
259
|
+
const t = plan.narrow.transitions;
|
|
260
|
+
const from = String(target[t.field]);
|
|
261
|
+
c.refuse("forbidden", "forbidden", `${c.model.name}.update: this role may move ${t.field} from "${from}" to ${(t.moves[from] ?? []).map((x) => `"${x}"`).join(", ") || "nothing"} — not "${String(data[t.field])}"`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
197
264
|
/* ─────────────────────────────── writes ───────────────────────────────── */
|
|
198
265
|
/**
|
|
199
266
|
* Refuses, or hands back the plan — never both, never neither.
|
|
@@ -35,8 +35,16 @@ export interface RuleViewer {
|
|
|
35
35
|
userId: string | null;
|
|
36
36
|
/** Every id this caller has acted under (guest cookie ∪ account ∪ linked). */
|
|
37
37
|
viewerIds: string[];
|
|
38
|
-
/** The app's own word for this caller. */
|
|
38
|
+
/** The app's own word for this caller — for a composed role, the BASE word it stands on. */
|
|
39
39
|
role: string;
|
|
40
|
+
/**
|
|
41
|
+
* A role the OWNER composed (custom-roles.ts): the compiled narrowing and the
|
|
42
|
+
* person's attribute values. Every rule naming `role` still applies; this can
|
|
43
|
+
* only take away — rows outside its `where`, fields it hides, moves it lacks.
|
|
44
|
+
*/
|
|
45
|
+
custom?: CustomRoleGrant | null;
|
|
46
|
+
/** The person's attribute values from their grant, typed by `roles.attributes` — what a scoped rule's `viewer.<attribute>` reads. */
|
|
47
|
+
attrs?: Record<string, AttrValue>;
|
|
40
48
|
}
|
|
41
49
|
/**
|
|
42
50
|
* The closed list. A `default: never` in `principalOutcome` makes adding a
|
|
@@ -58,10 +66,18 @@ export type Principal =
|
|
|
58
66
|
| {
|
|
59
67
|
t: "creator";
|
|
60
68
|
}
|
|
61
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* A caller holding this role in this app — optionally SCOPED to the rows a
|
|
71
|
+
* `where` names, with `viewer.<attribute>` filled from the person's grant:
|
|
72
|
+
* `{ "role": "customer", "where": { "customer": "viewer.customer" } }` is the
|
|
73
|
+
* whole of "a customer sees only their own readings", enforced on every
|
|
74
|
+
* read and write, always, by the manifest — not by anything an owner must
|
|
75
|
+
* remember to compose.
|
|
76
|
+
*/
|
|
62
77
|
| {
|
|
63
78
|
t: "role";
|
|
64
79
|
role: string;
|
|
80
|
+
where?: Record<string, WhereValue>;
|
|
65
81
|
}
|
|
66
82
|
/** Another app reading through a binding it was granted (`uses`/`provides`). */
|
|
67
83
|
| {
|
|
@@ -73,6 +89,7 @@ export type Principal =
|
|
|
73
89
|
};
|
|
74
90
|
export declare const PRINCIPAL_KINDS: readonly ["anyone", "account", "creator", "role", "apps", "internal"];
|
|
75
91
|
export type PrincipalKind = (typeof PRINCIPAL_KINDS)[number];
|
|
92
|
+
import { type AttrType, type AttrValue, type CustomRoleGrant, type CustomRolesEnvelope, type CustomRolesEnvelopeRaw, type Narrowing, type WhereValue } from "./custom-roles.js";
|
|
76
93
|
export type Scope = "instance" | "workspace" | "user";
|
|
77
94
|
export type RuleOp = "read" | "create" | "update" | "delete";
|
|
78
95
|
export declare const RULE_OPS: readonly RuleOp[];
|
|
@@ -141,6 +158,10 @@ export interface CompiledRules {
|
|
|
141
158
|
version: 1;
|
|
142
159
|
pluginId: string;
|
|
143
160
|
models: Record<string, CompiledModel>;
|
|
161
|
+
/** What an owner-composed role may be made of (`roles.custom`), or null. */
|
|
162
|
+
custom?: CustomRolesEnvelope | null;
|
|
163
|
+
/** `roles.attributes` — what a grant may say about a person, and its type. Absent in an artefact generated before 0.14. */
|
|
164
|
+
attributes?: Record<string, AttrType>;
|
|
144
165
|
}
|
|
145
166
|
/** Stamped by the platform on every row. A manifest may not declare these. */
|
|
146
167
|
export declare const PLATFORM_COLUMNS: readonly ["id", "workspaceId", "nodeId", "ownerId", "createdBy", "createdAt", "updatedAt", "deletedAt"];
|
|
@@ -178,7 +199,40 @@ export interface CompileInput {
|
|
|
178
199
|
* compile error about a rule its author never wrote.
|
|
179
200
|
*/
|
|
180
201
|
ownerRole?: string;
|
|
202
|
+
/** From `roles.custom` — the envelope an owner composes roles inside. */
|
|
203
|
+
custom?: CustomRolesEnvelopeRaw;
|
|
204
|
+
/** From `roles.attributes` — `{ customer: "int" }`: what a grant may carry, typed. */
|
|
205
|
+
attributes?: Record<string, string>;
|
|
206
|
+
/** Every surface name (ops, routes, tasks) — what `roles.custom.ops` may list. */
|
|
207
|
+
surfaces?: string[];
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The parts of a `plugin.json` the rule compiler reads. Every place that compiles
|
|
211
|
+
* a MANIFEST goes through `compileManifestRules` — the generator, the install
|
|
212
|
+
* job, the author's `memoryDb`, the box. Callers that picked the fields out one
|
|
213
|
+
* by one each learned about `roles.custom` and then `roles.attributes` late, and
|
|
214
|
+
* the install job would have refused every app with a scoped rule (2026-09-14);
|
|
215
|
+
* a repo test now refuses a caller that reaches into `roles` itself.
|
|
216
|
+
*/
|
|
217
|
+
export interface ManifestForRules {
|
|
218
|
+
id?: string;
|
|
219
|
+
db?: Record<string, ManifestDbModel> | Record<string, never> | null;
|
|
220
|
+
roles?: {
|
|
221
|
+
vocabulary?: string[];
|
|
222
|
+
default?: {
|
|
223
|
+
owner?: string;
|
|
224
|
+
} & Record<string, string | undefined>;
|
|
225
|
+
custom?: CustomRolesEnvelopeRaw;
|
|
226
|
+
attributes?: Record<string, string>;
|
|
227
|
+
} | null;
|
|
228
|
+
ops?: unknown;
|
|
229
|
+
routes?: unknown;
|
|
230
|
+
kickableTasks?: unknown;
|
|
181
231
|
}
|
|
232
|
+
/** The compiler's input, from a manifest — the one reading of `roles`, `db` and the surfaces. */
|
|
233
|
+
export declare function rulesInputFromManifest(m: ManifestForRules, pluginId?: string): CompileInput;
|
|
234
|
+
/** Compile a manifest's rules. Throws `RuleCompileError` with the key path, as `compileRules` does. */
|
|
235
|
+
export declare function compileManifestRules(m: ManifestForRules, pluginId?: string): CompiledRules;
|
|
182
236
|
/** An authoring mistake. Thrown at build time, never at request time. */
|
|
183
237
|
export declare class RuleCompileError extends Error {
|
|
184
238
|
readonly keyPath: string;
|
|
@@ -200,6 +254,8 @@ export interface ReadPlan {
|
|
|
200
254
|
visible: boolean;
|
|
201
255
|
/** When set, only rows whose `ownerId` is in this list. */
|
|
202
256
|
ownerIn: string[] | null;
|
|
257
|
+
/** A composed role's narrowing of this model: extra conjuncts and hidden fields. */
|
|
258
|
+
narrow: Narrowing;
|
|
203
259
|
}
|
|
204
260
|
/**
|
|
205
261
|
* What this caller may READ. Never refuses — see the header. A caller with no
|
|
@@ -213,6 +269,8 @@ export type WritePlan = {
|
|
|
213
269
|
ownerIn: string[] | null;
|
|
214
270
|
/** When set, the only fields this caller may write. */
|
|
215
271
|
fields: string[] | null;
|
|
272
|
+
/** A composed role's narrowing: rows it may touch, fields it hides, moves it may make. */
|
|
273
|
+
narrow: Narrowing;
|
|
216
274
|
} | {
|
|
217
275
|
ok: false;
|
|
218
276
|
code: "forbidden" | "login-required";
|
package/dist/db/compile-rules.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* never order, when the truth is they may, after signing in.
|
|
25
25
|
*/
|
|
26
26
|
export const PRINCIPAL_KINDS = ["anyone", "account", "creator", "role", "apps", "internal"];
|
|
27
|
+
import { ATTR_TYPES, compileEnvelope, mergeWhere, narrowingFor, resolveWhere } from "./custom-roles.js";
|
|
27
28
|
export const RULE_OPS = ["read", "create", "update", "delete"];
|
|
28
29
|
/* ───────────────────── columns the platform owns ──────────────────────── */
|
|
29
30
|
/** Stamped by the platform on every row. A manifest may not declare these. */
|
|
@@ -41,6 +42,23 @@ export const PLATFORM_COLUMNS = [
|
|
|
41
42
|
export const PLATFORM_FILTERABLE = ["id", "createdAt", "ownerId"];
|
|
42
43
|
/** Scope keys an app may never supply itself — the platform injects them. */
|
|
43
44
|
export const INJECTED_COLUMNS = ["workspaceId", "nodeId", "ownerId", "createdBy"];
|
|
45
|
+
const surfaceNamesOf = (x) => (Array.isArray(x) ? x.map(String) : x && typeof x === "object" ? Object.keys(x) : []);
|
|
46
|
+
/** The compiler's input, from a manifest — the one reading of `roles`, `db` and the surfaces. */
|
|
47
|
+
export function rulesInputFromManifest(m, pluginId) {
|
|
48
|
+
return {
|
|
49
|
+
pluginId: pluginId ?? m.id ?? "app",
|
|
50
|
+
db: (m.db ?? {}),
|
|
51
|
+
vocabulary: m.roles?.vocabulary,
|
|
52
|
+
ownerRole: m.roles?.default?.owner,
|
|
53
|
+
custom: m.roles?.custom,
|
|
54
|
+
attributes: m.roles?.attributes,
|
|
55
|
+
surfaces: [...surfaceNamesOf(m.ops), ...surfaceNamesOf(m.routes), ...surfaceNamesOf(m.kickableTasks)],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Compile a manifest's rules. Throws `RuleCompileError` with the key path, as `compileRules` does. */
|
|
59
|
+
export function compileManifestRules(m, pluginId) {
|
|
60
|
+
return compileRules(rulesInputFromManifest(m, pluginId));
|
|
61
|
+
}
|
|
44
62
|
/** An authoring mistake. Thrown at build time, never at request time. */
|
|
45
63
|
export class RuleCompileError extends Error {
|
|
46
64
|
keyPath;
|
|
@@ -88,7 +106,45 @@ function parseField(keyPath, spec, models) {
|
|
|
88
106
|
}
|
|
89
107
|
return field;
|
|
90
108
|
}
|
|
91
|
-
function parsePrincipal(keyPath, raw, vocabulary) {
|
|
109
|
+
function parsePrincipal(keyPath, raw, vocabulary, ctx) {
|
|
110
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
111
|
+
const o = raw;
|
|
112
|
+
if (typeof o.role !== "string")
|
|
113
|
+
throw new RuleCompileError(`${keyPath}.role`, 'a scoped principal is { "role": "<word>", "where": { … } }');
|
|
114
|
+
const base = parsePrincipal(`${keyPath}.role`, o.role, vocabulary, ctx);
|
|
115
|
+
if (base.t !== "role")
|
|
116
|
+
throw new RuleCompileError(`${keyPath}.role`, `only a role of this app can be scoped — "${o.role}" is a principal`);
|
|
117
|
+
if (!o.where || typeof o.where !== "object" || Array.isArray(o.where) || !Object.keys(o.where).length) {
|
|
118
|
+
throw new RuleCompileError(`${keyPath}.where`, "a scoped principal names at least one field: { \"<field>\": <value> | [<values>] | \"viewer.<attribute>\" }");
|
|
119
|
+
}
|
|
120
|
+
const where = {};
|
|
121
|
+
for (const [field, value] of Object.entries(o.where)) {
|
|
122
|
+
const at = `${keyPath}.where.${field}`;
|
|
123
|
+
if (!ctx.fields.includes(field))
|
|
124
|
+
throw new RuleCompileError(at, `"${field}" is not a declared field — declared: ${ctx.fields.join(", ")}`);
|
|
125
|
+
if (!ctx.filterable.includes(field))
|
|
126
|
+
throw new RuleCompileError(at, `"${field}" has no index — a rule scoped by it would scan the table on every read; add it to indexes`);
|
|
127
|
+
if (typeof value === "string" && value.startsWith("viewer.")) {
|
|
128
|
+
const attr = value.slice(7);
|
|
129
|
+
if (!ctx.attributes[attr])
|
|
130
|
+
throw new RuleCompileError(at, `"${value}" names no attribute — declare it in roles.attributes (${Object.keys(ctx.attributes).map((a) => `viewer.${a}`).join(", ") || "none declared"})`);
|
|
131
|
+
}
|
|
132
|
+
else if (Array.isArray(value)) {
|
|
133
|
+
if (!value.length)
|
|
134
|
+
throw new RuleCompileError(at, "an empty list would match nothing");
|
|
135
|
+
for (const v of value)
|
|
136
|
+
if (!["string", "number", "boolean"].includes(typeof v))
|
|
137
|
+
throw new RuleCompileError(at, "values are strings, numbers or booleans");
|
|
138
|
+
}
|
|
139
|
+
else if (!["string", "number", "boolean"].includes(typeof value)) {
|
|
140
|
+
throw new RuleCompileError(at, "a value, a list of values, or viewer.<attribute>");
|
|
141
|
+
}
|
|
142
|
+
where[field] = value;
|
|
143
|
+
}
|
|
144
|
+
return { t: "role", role: base.role, where };
|
|
145
|
+
}
|
|
146
|
+
if (typeof raw !== "string")
|
|
147
|
+
throw new RuleCompileError(keyPath, `a principal is a word or { "role", "where" } — got ${typeof raw}`);
|
|
92
148
|
switch (raw) {
|
|
93
149
|
case "anyone":
|
|
94
150
|
return { t: "anyone" };
|
|
@@ -108,17 +164,38 @@ function parsePrincipal(keyPath, raw, vocabulary) {
|
|
|
108
164
|
}
|
|
109
165
|
return { t: "role", role: raw };
|
|
110
166
|
}
|
|
111
|
-
|
|
167
|
+
/**
|
|
168
|
+
* A rule's principals are alternatives (any one grants), but a scope narrows by
|
|
169
|
+
* AND. Two shapes would therefore answer LESS than the author wrote, silently:
|
|
170
|
+
* `creator` beside a scoped role (own rows OR the scope — the plan can only give
|
|
171
|
+
* one), and two scopes for the same role (either scope — the plan keeps one).
|
|
172
|
+
* Refused by name until the engine carries a disjunction; a rule that says less
|
|
173
|
+
* than it means is the failure this compiler exists to prevent.
|
|
174
|
+
*/
|
|
175
|
+
function checkAlternatives(keyPath, principals) {
|
|
176
|
+
const scoped = principals.filter((p) => p.t === "role" && !!p.where);
|
|
177
|
+
if (scoped.length && principals.some((p) => p.t === "creator")) {
|
|
178
|
+
throw new RuleCompileError(keyPath, `"creator" and a scoped role (${scoped[0].role}) on one rule would mean own rows OR the scope, which a rule cannot say yet — scope by a field instead, or give the role one of the two`);
|
|
179
|
+
}
|
|
180
|
+
const seen = new Set();
|
|
181
|
+
for (const p of scoped) {
|
|
182
|
+
if (seen.has(p.role))
|
|
183
|
+
throw new RuleCompileError(keyPath, `"${p.role}" is scoped twice on one rule — merge the two where clauses into one (a list of values means any of them)`);
|
|
184
|
+
seen.add(p.role);
|
|
185
|
+
}
|
|
186
|
+
return principals;
|
|
187
|
+
}
|
|
188
|
+
function parseRule(keyPath, raw, fields, vocabulary, op, pctx) {
|
|
112
189
|
const empty = { principals: [], requiresAccount: false, creatorMay: null, fields: null };
|
|
113
190
|
if (raw === undefined)
|
|
114
191
|
return empty;
|
|
115
192
|
if (typeof raw === "string") {
|
|
116
|
-
return { ...empty, principals: [parsePrincipal(keyPath, raw, vocabulary)] };
|
|
193
|
+
return { ...empty, principals: checkAlternatives(keyPath, [parsePrincipal(keyPath, raw, vocabulary, pctx)]) };
|
|
117
194
|
}
|
|
118
195
|
if (Array.isArray(raw)) {
|
|
119
196
|
return {
|
|
120
197
|
...empty,
|
|
121
|
-
principals: raw.map((p, i) => parsePrincipal(`${keyPath}[${i}]`,
|
|
198
|
+
principals: checkAlternatives(keyPath, raw.map((p, i) => parsePrincipal(`${keyPath}[${i}]`, p, vocabulary, pctx))),
|
|
122
199
|
};
|
|
123
200
|
}
|
|
124
201
|
if (typeof raw !== "object" || raw === null) {
|
|
@@ -126,7 +203,7 @@ function parseRule(keyPath, raw, fields, vocabulary, op) {
|
|
|
126
203
|
}
|
|
127
204
|
const o = raw;
|
|
128
205
|
const roles = Array.isArray(o.roles) ? o.roles : [];
|
|
129
|
-
const principals = roles.map((p, i) => parsePrincipal(`${keyPath}.roles[${i}]`,
|
|
206
|
+
const principals = checkAlternatives(`${keyPath}.roles`, roles.map((p, i) => parsePrincipal(`${keyPath}.roles[${i}]`, p, vocabulary, pctx)));
|
|
130
207
|
const checkFields = (list, label) => {
|
|
131
208
|
if (list === undefined)
|
|
132
209
|
return null;
|
|
@@ -162,6 +239,14 @@ export function compileRules(input) {
|
|
|
162
239
|
const modelNames = Object.keys(input.db ?? {});
|
|
163
240
|
const ownerRole = defaultRoleFor(input);
|
|
164
241
|
const models = {};
|
|
242
|
+
const attributes = {};
|
|
243
|
+
for (const [name, type] of Object.entries(input.attributes ?? {})) {
|
|
244
|
+
if (!/^[a-z][a-z0-9_-]{0,30}$/.test(name))
|
|
245
|
+
throw new RuleCompileError(`roles.attributes.${name}`, "an attribute name is a-z, digits, - and _ (at most 31 characters)");
|
|
246
|
+
if (!ATTR_TYPES.includes(String(type)))
|
|
247
|
+
throw new RuleCompileError(`roles.attributes.${name}`, `unknown type "${type}" — allowed: ${ATTR_TYPES.join(", ")}`);
|
|
248
|
+
attributes[name] = type;
|
|
249
|
+
}
|
|
165
250
|
for (const name of modelNames) {
|
|
166
251
|
const raw = input.db[name];
|
|
167
252
|
const at = `db.${name}`;
|
|
@@ -254,6 +339,13 @@ export function compileRules(input) {
|
|
|
254
339
|
if (scope === "user" && raw.rules !== undefined) {
|
|
255
340
|
throw new RuleCompileError(`${at}.rules`, "a `scope: user` model needs no rules — its rows are readable and writable only by the account that owns them");
|
|
256
341
|
}
|
|
342
|
+
const filterable = [
|
|
343
|
+
...new Set([
|
|
344
|
+
...PLATFORM_FILTERABLE,
|
|
345
|
+
...unique.flat(),
|
|
346
|
+
...indexes.flatMap((i) => i.fields),
|
|
347
|
+
]),
|
|
348
|
+
].sort();
|
|
257
349
|
const rawRules = (raw.rules ?? {});
|
|
258
350
|
for (const key of Object.keys(rawRules)) {
|
|
259
351
|
if (!["read", "create", "update", "delete", "write"].includes(key)) {
|
|
@@ -261,19 +353,13 @@ export function compileRules(input) {
|
|
|
261
353
|
}
|
|
262
354
|
}
|
|
263
355
|
const write = rawRules.write;
|
|
356
|
+
const pctx = { fields: fieldNames, filterable, attributes };
|
|
264
357
|
const rules = {
|
|
265
|
-
read: parseRule(`${at}.rules.read`, rawRules.read ?? defaultFor(scope, "read", ownerRole), fieldNames, input.vocabulary, "read"),
|
|
266
|
-
create: parseRule(`${at}.rules.create`, rawRules.create ?? write ?? defaultFor(scope, "create", ownerRole), fieldNames, input.vocabulary, "create"),
|
|
267
|
-
update: parseRule(`${at}.rules.update`, rawRules.update ?? write ?? defaultFor(scope, "update", ownerRole), fieldNames, input.vocabulary, "update"),
|
|
268
|
-
delete: parseRule(`${at}.rules.delete`, rawRules.delete ?? write ?? defaultFor(scope, "delete", ownerRole), fieldNames, input.vocabulary, "delete"),
|
|
358
|
+
read: parseRule(`${at}.rules.read`, rawRules.read ?? defaultFor(scope, "read", ownerRole), fieldNames, input.vocabulary, "read", pctx),
|
|
359
|
+
create: parseRule(`${at}.rules.create`, rawRules.create ?? write ?? defaultFor(scope, "create", ownerRole), fieldNames, input.vocabulary, "create", pctx),
|
|
360
|
+
update: parseRule(`${at}.rules.update`, rawRules.update ?? write ?? defaultFor(scope, "update", ownerRole), fieldNames, input.vocabulary, "update", pctx),
|
|
361
|
+
delete: parseRule(`${at}.rules.delete`, rawRules.delete ?? write ?? defaultFor(scope, "delete", ownerRole), fieldNames, input.vocabulary, "delete", pctx),
|
|
269
362
|
};
|
|
270
|
-
const filterable = [
|
|
271
|
-
...new Set([
|
|
272
|
-
...PLATFORM_FILTERABLE,
|
|
273
|
-
...unique.flat(),
|
|
274
|
-
...indexes.flatMap((i) => i.fields),
|
|
275
|
-
]),
|
|
276
|
-
].sort();
|
|
277
363
|
models[name] = {
|
|
278
364
|
name,
|
|
279
365
|
key: camelKey(name),
|
|
@@ -287,7 +373,8 @@ export function compileRules(input) {
|
|
|
287
373
|
rules,
|
|
288
374
|
};
|
|
289
375
|
}
|
|
290
|
-
|
|
376
|
+
const custom = compileEnvelope(input.custom, Object.fromEntries(Object.entries(models).map(([name, m]) => [name, { fields: Object.keys(m.fields), filterable: m.filterable }])), input.surfaces ?? [], attributes);
|
|
377
|
+
return { version: 1, pluginId: input.pluginId, models, custom, attributes };
|
|
291
378
|
}
|
|
292
379
|
/**
|
|
293
380
|
* With nothing declared, only the owner. The safe default is the one that
|
|
@@ -313,6 +400,10 @@ function defaultRoleFor(input) {
|
|
|
313
400
|
return input.ownerRole;
|
|
314
401
|
return input.vocabulary.includes("owner") ? "owner" : "internal";
|
|
315
402
|
}
|
|
403
|
+
/** The attribute values a scoped rule reads: the grant's, or the composed role's (the same grant, carried twice). */
|
|
404
|
+
function attrsOf(v) {
|
|
405
|
+
return v.attrs ?? v.custom?.attrs;
|
|
406
|
+
}
|
|
316
407
|
function principalOutcome(p, v, ctx) {
|
|
317
408
|
switch (p.t) {
|
|
318
409
|
case "anyone":
|
|
@@ -323,8 +414,17 @@ function principalOutcome(p, v, ctx) {
|
|
|
323
414
|
case "creator":
|
|
324
415
|
// Never a check: this becomes `ownerId IN viewerIds` on the query.
|
|
325
416
|
return v.viewerIds.length ? "own-rows" : "no";
|
|
326
|
-
case "role":
|
|
327
|
-
|
|
417
|
+
case "role": {
|
|
418
|
+
if (v.role !== p.role)
|
|
419
|
+
return "no";
|
|
420
|
+
if (!p.where)
|
|
421
|
+
return "grant";
|
|
422
|
+
// Scoped: the rows the where names, with this person's attributes filled in.
|
|
423
|
+
// An attribute they were never given is "no" — this principal grants nothing;
|
|
424
|
+
// another principal (their own rows, say) may still.
|
|
425
|
+
const where = resolveWhere(p.where, attrsOf(v));
|
|
426
|
+
return where === false ? "no" : { scoped: where };
|
|
427
|
+
}
|
|
328
428
|
case "apps":
|
|
329
429
|
return ctx.viaBinding ? "grant" : "no";
|
|
330
430
|
case "internal":
|
|
@@ -337,6 +437,15 @@ function principalOutcome(p, v, ctx) {
|
|
|
337
437
|
}
|
|
338
438
|
}
|
|
339
439
|
}
|
|
440
|
+
const OPEN = { where: null, hide: [], updateFields: null, transitions: null };
|
|
441
|
+
/** The composed role's narrowing of this model — nothing, for a caller who holds none. */
|
|
442
|
+
function narrowOf(model, viewer) {
|
|
443
|
+
return viewer.custom ? narrowingFor(viewer.custom, model.name) : OPEN;
|
|
444
|
+
}
|
|
445
|
+
/** A scoped principal's rows, on top of whatever a composed role already narrowed: both apply. */
|
|
446
|
+
function scopedBy(narrow, where) {
|
|
447
|
+
return { ...narrow, where: mergeWhere(narrow.where, where) };
|
|
448
|
+
}
|
|
340
449
|
/**
|
|
341
450
|
* What this caller may READ. Never refuses — see the header. A caller with no
|
|
342
451
|
* grant gets `visible: false`, which the client turns into an empty result,
|
|
@@ -344,23 +453,44 @@ function principalOutcome(p, v, ctx) {
|
|
|
344
453
|
*/
|
|
345
454
|
export function planRead(model, viewer, ctx = {}) {
|
|
346
455
|
if (viewer.kind === "internal")
|
|
347
|
-
return { visible: true, ownerIn: null };
|
|
456
|
+
return { visible: true, ownerIn: null, narrow: OPEN };
|
|
348
457
|
// A user-scoped model ignores rules: your rows, nobody else's.
|
|
349
458
|
if (model.scope === "user") {
|
|
350
|
-
return viewer.viewerIds.length ? { visible: true, ownerIn: viewer.viewerIds } : { visible: false, ownerIn: null };
|
|
459
|
+
return viewer.viewerIds.length ? { visible: true, ownerIn: viewer.viewerIds, narrow: OPEN } : { visible: false, ownerIn: null, narrow: OPEN };
|
|
351
460
|
}
|
|
461
|
+
// A composed role scoped by an attribute its holder lacks sees nothing at all.
|
|
462
|
+
const narrow = narrowOf(model, viewer);
|
|
463
|
+
if (narrow.where === false)
|
|
464
|
+
return { visible: false, ownerIn: null, narrow };
|
|
352
465
|
let ownRows = false;
|
|
466
|
+
let scoped = null;
|
|
353
467
|
for (const p of model.rules.read.principals) {
|
|
354
468
|
const outcome = principalOutcome(p, viewer, ctx);
|
|
355
469
|
if (outcome === "grant")
|
|
356
|
-
return { visible: true, ownerIn: null };
|
|
470
|
+
return { visible: true, ownerIn: null, narrow };
|
|
357
471
|
if (outcome === "own-rows")
|
|
358
472
|
ownRows = true;
|
|
473
|
+
else if (typeof outcome === "object")
|
|
474
|
+
scoped = outcome.scoped;
|
|
359
475
|
}
|
|
360
476
|
// `creator` only makes sense on a model that records one.
|
|
361
477
|
if (ownRows && model.ownedByCreator)
|
|
362
|
-
return { visible: true, ownerIn: viewer.viewerIds };
|
|
363
|
-
|
|
478
|
+
return { visible: true, ownerIn: viewer.viewerIds, narrow };
|
|
479
|
+
if (scoped) {
|
|
480
|
+
const n = scopedBy(narrow, scoped);
|
|
481
|
+
return n.where === false ? { visible: false, ownerIn: null, narrow: n } : { visible: true, ownerIn: null, narrow: n };
|
|
482
|
+
}
|
|
483
|
+
return { visible: false, ownerIn: null, narrow };
|
|
484
|
+
}
|
|
485
|
+
/** The writable set a composed role leaves: the rule's, intersected with the role's, minus what it hides. */
|
|
486
|
+
function narrowFields(op, fields, narrow, all) {
|
|
487
|
+
let out = fields;
|
|
488
|
+
if (op === "update" && narrow.updateFields) {
|
|
489
|
+
out = (out ?? all).filter((f) => narrow.updateFields.includes(f));
|
|
490
|
+
}
|
|
491
|
+
if (narrow.hide.length)
|
|
492
|
+
out = (out ?? all).filter((f) => !narrow.hide.includes(f));
|
|
493
|
+
return out;
|
|
364
494
|
}
|
|
365
495
|
/**
|
|
366
496
|
* What this caller may CREATE, UPDATE or DELETE. Unlike a read, a write does
|
|
@@ -368,16 +498,22 @@ export function planRead(model, viewer, ctx = {}) {
|
|
|
368
498
|
*/
|
|
369
499
|
export function planWrite(model, op, viewer, ctx = {}) {
|
|
370
500
|
if (viewer.kind === "internal")
|
|
371
|
-
return { ok: true, ownerIn: null, fields: null };
|
|
501
|
+
return { ok: true, ownerIn: null, fields: null, narrow: OPEN };
|
|
372
502
|
if (model.scope === "user") {
|
|
373
503
|
// A row that belongs to an account needs an account to belong to.
|
|
374
504
|
if (!viewer.userId) {
|
|
375
505
|
return { ok: false, code: "login-required", detail: `${model.name} rows belong to an account; this caller has none` };
|
|
376
506
|
}
|
|
377
|
-
return { ok: true, ownerIn: viewer.viewerIds, fields: null };
|
|
507
|
+
return { ok: true, ownerIn: viewer.viewerIds, fields: null, narrow: OPEN };
|
|
378
508
|
}
|
|
509
|
+
const narrow = narrowOf(model, viewer);
|
|
510
|
+
if (narrow.where === false) {
|
|
511
|
+
return { ok: false, code: "forbidden", detail: `${model.name}.${op}: this role is scoped by an attribute its holder was never given` };
|
|
512
|
+
}
|
|
513
|
+
const all = Object.keys(model.fields);
|
|
379
514
|
const rule = model.rules[op];
|
|
380
515
|
let sawNeedsAccount = false;
|
|
516
|
+
let scoped = null;
|
|
381
517
|
for (const p of rule.principals) {
|
|
382
518
|
const outcome = principalOutcome(p, viewer, ctx);
|
|
383
519
|
if (outcome === "grant") {
|
|
@@ -388,7 +524,7 @@ export function planWrite(model, op, viewer, ctx = {}) {
|
|
|
388
524
|
detail: `${model.name}.${op} requires a signed-in account`,
|
|
389
525
|
};
|
|
390
526
|
}
|
|
391
|
-
return { ok: true, ownerIn: null, fields: rule.fields };
|
|
527
|
+
return { ok: true, ownerIn: null, fields: narrowFields(op, rule.fields, narrow, all), narrow };
|
|
392
528
|
}
|
|
393
529
|
if (outcome === "needs-account")
|
|
394
530
|
sawNeedsAccount = true;
|
|
@@ -396,13 +532,25 @@ export function planWrite(model, op, viewer, ctx = {}) {
|
|
|
396
532
|
if (rule.requiresAccount && !viewer.userId) {
|
|
397
533
|
return { ok: false, code: "login-required", detail: `${model.name}.${op} requires a signed-in account` };
|
|
398
534
|
}
|
|
399
|
-
return { ok: true, ownerIn: viewer.viewerIds, fields: rule.fields };
|
|
535
|
+
return { ok: true, ownerIn: viewer.viewerIds, fields: narrowFields(op, rule.fields, narrow, all), narrow };
|
|
400
536
|
}
|
|
537
|
+
if (typeof outcome === "object")
|
|
538
|
+
scoped = outcome.scoped;
|
|
539
|
+
}
|
|
540
|
+
// A scoped role: only the rows its where names (an update or delete outside
|
|
541
|
+
// them finds no row; a create must land inside them — the client fills or refuses).
|
|
542
|
+
if (scoped) {
|
|
543
|
+
if (rule.requiresAccount && !viewer.userId)
|
|
544
|
+
return { ok: false, code: "login-required", detail: `${model.name}.${op} requires a signed-in account` };
|
|
545
|
+
const n = scopedBy(narrow, scoped);
|
|
546
|
+
if (n.where === false)
|
|
547
|
+
return { ok: false, code: "forbidden", detail: `${model.name}.${op}: this role is scoped by an attribute its holder was never given` };
|
|
548
|
+
return { ok: true, ownerIn: null, fields: narrowFields(op, rule.fields, n, all), narrow: n };
|
|
401
549
|
}
|
|
402
550
|
// `creatorMay` is the last door: not your role, but your row, and only the
|
|
403
551
|
// fields the app said a creator may touch (an order's note, never its price).
|
|
404
552
|
if (op === "update" && rule.creatorMay && model.ownedByCreator && viewer.viewerIds.length) {
|
|
405
|
-
return { ok: true, ownerIn: viewer.viewerIds, fields: rule.creatorMay };
|
|
553
|
+
return { ok: true, ownerIn: viewer.viewerIds, fields: narrowFields(op, rule.creatorMay, narrow, all), narrow };
|
|
406
554
|
}
|
|
407
555
|
if (sawNeedsAccount || (rule.requiresAccount && !viewer.userId)) {
|
|
408
556
|
return { ok: false, code: "login-required", detail: `${model.name}.${op} requires a signed-in account` };
|
|
@@ -416,7 +564,7 @@ export function planWrite(model, op, viewer, ctx = {}) {
|
|
|
416
564
|
function describe(principals) {
|
|
417
565
|
if (!principals.length)
|
|
418
566
|
return "nobody";
|
|
419
|
-
return principals.map((p) => (p.t === "role" ? p.role : p.t)).join(", ");
|
|
567
|
+
return principals.map((p) => (p.t === "role" ? (p.where ? `${p.role} (scoped)` : p.role) : p.t)).join(", ");
|
|
420
568
|
}
|
|
421
569
|
/** May this caller see the sealed fields of a row they can otherwise read? */
|
|
422
570
|
export function canUnseal(viewer, row) {
|