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.
Files changed (67) hide show
  1. package/README.md +575 -153
  2. package/api-reference.md +3291 -0
  3. package/dist/assets.d.ts +72 -0
  4. package/dist/assets.js +139 -0
  5. package/dist/audience.d.ts +2 -0
  6. package/dist/audience.js +2 -0
  7. package/dist/bindings.d.ts +3 -0
  8. package/dist/bindings.js +3 -0
  9. package/dist/chart-font.d.ts +19 -0
  10. package/dist/chart-font.js +15 -0
  11. package/dist/chart.d.ts +83 -0
  12. package/dist/chart.js +247 -0
  13. package/dist/computer.d.ts +191 -0
  14. package/dist/computer.js +226 -0
  15. package/dist/db/client-core.d.ts +38 -0
  16. package/dist/db/client-core.js +68 -1
  17. package/dist/db/compile-rules.d.ts +60 -2
  18. package/dist/db/compile-rules.js +178 -30
  19. package/dist/db/custom-roles.d.ts +156 -0
  20. package/dist/db/custom-roles.js +280 -0
  21. package/dist/db/memory-client.d.ts +0 -15
  22. package/dist/db/memory-client.js +13 -23
  23. package/dist/db/schema-gen.js +2 -8
  24. package/dist/editor-sync.d.ts +130 -0
  25. package/dist/editor-sync.js +413 -0
  26. package/dist/files.d.ts +70 -0
  27. package/dist/files.js +48 -0
  28. package/dist/helpers.d.ts +10 -0
  29. package/dist/helpers.js +10 -0
  30. package/dist/index.d.ts +24 -0
  31. package/dist/index.js +25 -0
  32. package/dist/labelme.d.ts +84 -0
  33. package/dist/labelme.js +118 -0
  34. package/dist/manifest.d.ts +400 -106
  35. package/dist/manifest.js +59 -5
  36. package/dist/ops.d.ts +115 -0
  37. package/dist/ops.js +120 -0
  38. package/dist/react.d.ts +280 -4
  39. package/dist/react.js +100 -3
  40. package/dist/server.d.ts +268 -10
  41. package/dist/server.js +120 -4
  42. package/dist/testing/db.d.ts +6 -0
  43. package/dist/testing/db.js +3 -8
  44. package/dist/testing/files.d.ts +22 -0
  45. package/dist/testing/files.js +175 -0
  46. package/dist/testing/index.d.ts +2 -0
  47. package/dist/testing/index.js +1 -0
  48. package/dist/types.d.ts +47 -0
  49. package/docs/02-manifest.md +2 -0
  50. package/docs/03-events-and-state.md +8 -0
  51. package/docs/04-tools.md +58 -26
  52. package/docs/05-ui.md +35 -0
  53. package/docs/06-server.md +121 -7
  54. package/docs/07-background-tasks.md +7 -9
  55. package/docs/09-files.md +126 -17
  56. package/docs/10-testing.md +6 -0
  57. package/docs/11-shipping.md +10 -2
  58. package/docs/13-people-and-access.md +180 -0
  59. package/docs/14-database.md +6 -0
  60. package/docs/15-realtime.md +3 -0
  61. package/docs/17-editing-and-merging.md +155 -0
  62. package/llms-full.txt +1296 -214
  63. package/llms.txt +3 -3
  64. package/package.json +9 -4
  65. package/schemas/plugin.schema.json +134 -15
  66. package/scripts/build-api-reference.mjs +104 -0
  67. package/scripts/build-llms.mjs +1 -2
@@ -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
  *
@@ -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", "invalid", detail);
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
- /** A caller holding this role in this app. */
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";
@@ -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
- function parseRule(keyPath, raw, fields, vocabulary, op) {
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}]`, String(p), vocabulary)),
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}]`, String(p), vocabulary));
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
- return { version: 1, pluginId: input.pluginId, models };
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
- return v.role === p.role ? "grant" : "no";
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
- return { visible: false, ownerIn: null };
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) {