esoul-sdk 0.4.0 → 0.7.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 (43) hide show
  1. package/README.md +146 -24
  2. package/dist/audience.d.ts +103 -0
  3. package/dist/audience.js +142 -0
  4. package/dist/bindings.d.ts +164 -0
  5. package/dist/bindings.js +163 -0
  6. package/dist/db/client-core.d.ts +169 -0
  7. package/dist/db/client-core.js +316 -0
  8. package/dist/db/compile-rules.d.ts +229 -0
  9. package/dist/db/compile-rules.js +426 -0
  10. package/dist/db/memory-client.d.ts +136 -0
  11. package/dist/db/memory-client.js +332 -0
  12. package/dist/db/schema-gen.d.ts +109 -0
  13. package/dist/db/schema-gen.js +363 -0
  14. package/dist/helpers.d.ts +52 -0
  15. package/dist/helpers.js +106 -10
  16. package/dist/index.d.ts +5 -0
  17. package/dist/index.js +5 -0
  18. package/dist/manifest.d.ts +466 -13
  19. package/dist/manifest.js +218 -5
  20. package/dist/roles.d.ts +43 -0
  21. package/dist/roles.js +56 -0
  22. package/dist/server.d.ts +182 -0
  23. package/dist/server.js +80 -0
  24. package/dist/testing/db.d.ts +71 -0
  25. package/dist/testing/db.js +103 -0
  26. package/dist/testing/index.d.ts +14 -0
  27. package/dist/testing/index.js +9 -0
  28. package/dist/testing/ops.d.ts +84 -0
  29. package/dist/testing/ops.js +76 -0
  30. package/dist/types.d.ts +22 -1
  31. package/docs/04-tools.md +5 -2
  32. package/docs/06-server.md +78 -0
  33. package/docs/07-background-tasks.md +23 -0
  34. package/docs/10-testing.md +18 -0
  35. package/docs/12-rules.md +3 -2
  36. package/docs/13-people-and-access.md +152 -0
  37. package/docs/14-database.md +221 -0
  38. package/docs/15-realtime.md +88 -0
  39. package/docs/16-bindings.md +79 -0
  40. package/llms-full.txt +829 -28
  41. package/llms.txt +4 -0
  42. package/package.json +7 -3
  43. package/schemas/plugin.schema.json +351 -9
@@ -0,0 +1,229 @@
1
+ /**
2
+ * THE RULE ENGINE — a manifest's `db` block turned into data, and decisions
3
+ * taken from that data.
4
+ *
5
+ * This module is the heart of the whole access story, and the reason it is
6
+ * pure, dependency-free and returns DATA is that three different things must
7
+ * agree about who may see what: the in-memory client an author writes unit
8
+ * tests against, the Prisma extension production runs, and the workbench box.
9
+ * If each re-derived the rules, "my tests pass" would mean nothing about
10
+ * production. So the rules compile ONCE, to a JSON artefact
11
+ * (`.esoul/rules.json`), and every client reads that same artefact.
12
+ *
13
+ * Two decisions are load-bearing and easy to get wrong:
14
+ *
15
+ * 1. **A read never refuses.** A row the caller may not see must read as
16
+ * ABSENT — `null`, `0`, missing from the list — never as a refusal. A
17
+ * refusal is an answer: "forbidden" on someone else's order id confirms
18
+ * that the id exists. So read rules compile to a WHERE conjunct and the
19
+ * worst case is a filter that matches nothing.
20
+ *
21
+ * 2. **"Not signed in" is not "not allowed."** A rule that needs an account
22
+ * answers `login-required` to an anonymous caller, so the app can render a
23
+ * sign-in wall. Answering `forbidden` there would tell a customer they may
24
+ * never order, when the truth is they may, after signing in.
25
+ */
26
+ /**
27
+ * What the engine needs to know about a caller — deliberately the narrowest
28
+ * shape that answers every principal, so this module couples to nothing. The
29
+ * platform's `PluginViewer` satisfies it structurally; so does a fake one in
30
+ * an author's unit test.
31
+ */
32
+ export interface RuleViewer {
33
+ kind: "owner" | "member" | "visitor" | "anonymous" | "agent" | "internal";
34
+ /** The esoul account, when there is one. An agent carries its person's. */
35
+ userId: string | null;
36
+ /** Every id this caller has acted under (guest cookie ∪ account ∪ linked). */
37
+ viewerIds: string[];
38
+ /** The app's own word for this caller. */
39
+ role: string;
40
+ }
41
+ /**
42
+ * The closed list. A `default: never` in `principalOutcome` makes adding a
43
+ * variant here a COMPILE error until it is handled, and the test matrix is
44
+ * enumerated from `PRINCIPAL_KINDS` so it is also a test failure until it is
45
+ * proven. Both, because a principal that silently grants nothing looks exactly
46
+ * like a principal that works.
47
+ */
48
+ export type Principal =
49
+ /** Anyone at all, signed in or not. */
50
+ {
51
+ t: "anyone";
52
+ }
53
+ /** Any signed-in esoul account. */
54
+ | {
55
+ t: "account";
56
+ }
57
+ /** The account that created the row. Compiles to a filter, never a check. */
58
+ | {
59
+ t: "creator";
60
+ }
61
+ /** A caller holding this role in this app. */
62
+ | {
63
+ t: "role";
64
+ role: string;
65
+ }
66
+ /** Another app reading through a binding it was granted (`uses`/`provides`). */
67
+ | {
68
+ t: "apps";
69
+ }
70
+ /** This app's own server code — a task, a sweep, one op calling another. */
71
+ | {
72
+ t: "internal";
73
+ };
74
+ export declare const PRINCIPAL_KINDS: readonly ["anyone", "account", "creator", "role", "apps", "internal"];
75
+ export type PrincipalKind = (typeof PRINCIPAL_KINDS)[number];
76
+ export type Scope = "instance" | "workspace" | "user";
77
+ export type RuleOp = "read" | "create" | "update" | "delete";
78
+ export declare const RULE_OPS: readonly RuleOp[];
79
+ export type FieldType = "string" | "text" | "int" | "float" | "boolean" | "datetime" | "json" | "ref";
80
+ export interface CompiledField {
81
+ type: FieldType;
82
+ /** Set when `type === "ref"`: the model this points at (always this app's). */
83
+ ref?: string;
84
+ optional: boolean;
85
+ list: boolean;
86
+ default?: string | number | boolean;
87
+ }
88
+ export interface CompiledRule {
89
+ principals: Principal[];
90
+ /** `requires: "account"` — an anonymous caller gets `login-required`. */
91
+ requiresAccount: boolean;
92
+ /** `update` only: fields the row's creator may write even without a role. */
93
+ creatorMay: string[] | null;
94
+ /** When set, the only fields this rule permits writing at all. */
95
+ fields: string[] | null;
96
+ }
97
+ /**
98
+ * HOW an index is built, named by what the author wants to DO with it rather
99
+ * than by the mechanism:
100
+ *
101
+ * - `btree` (a plain `["a","b"]` group) — equality, ranges, ordering. Led by
102
+ * the scope column, because every query the platform issues narrows to an
103
+ * instance first.
104
+ * - `contains` — a LIST field, so `where: { tags: { has: "gifts" } }` is an
105
+ * index lookup instead of a scan. Becomes a GIN index.
106
+ * - `text` — a string or text field, so `where: { name: { contains: "grey" } }`
107
+ * is an index lookup. Becomes a GIN trigram index, which is the only kind
108
+ * that can serve a substring search; a btree cannot.
109
+ *
110
+ * The last two are single-column and NOT scope-led: without `btree_gin` a GIN
111
+ * index cannot lead with the scope column, so Postgres combines it with the
112
+ * scope index instead. That is the right plan and it is worth knowing.
113
+ */
114
+ export type IndexKind = "btree" | "contains" | "text";
115
+ export interface CompiledIndex {
116
+ fields: string[];
117
+ kind: IndexKind;
118
+ }
119
+ export interface CompiledModel {
120
+ /** As declared: `Order`. */
121
+ name: string;
122
+ /** As called on the client: `order`. */
123
+ key: string;
124
+ scope: Scope;
125
+ /** `owner: "creator"` — rows belong to whoever created them. */
126
+ ownedByCreator: boolean;
127
+ fields: Record<string, CompiledField>;
128
+ sealed: string[];
129
+ unique: string[][];
130
+ indexes: CompiledIndex[];
131
+ /**
132
+ * Every field a `where` or `orderBy` may name. An unindexed filter is a
133
+ * table scan waiting to happen at a customer's scale, so it is refused with
134
+ * the index to add — the rule lands in the author's editor (the generated
135
+ * types key `orderBy` by exactly this list) and again at runtime.
136
+ */
137
+ filterable: string[];
138
+ rules: Record<RuleOp, CompiledRule>;
139
+ }
140
+ export interface CompiledRules {
141
+ version: 1;
142
+ pluginId: string;
143
+ models: Record<string, CompiledModel>;
144
+ }
145
+ /** Stamped by the platform on every row. A manifest may not declare these. */
146
+ export declare const PLATFORM_COLUMNS: readonly ["id", "workspaceId", "nodeId", "ownerId", "createdBy", "createdAt", "updatedAt", "deletedAt"];
147
+ /** Platform columns a caller may filter or sort by (each one is indexed). */
148
+ export declare const PLATFORM_FILTERABLE: readonly ["id", "createdAt", "ownerId"];
149
+ /** Scope keys an app may never supply itself — the platform injects them. */
150
+ export declare const INJECTED_COLUMNS: readonly ["workspaceId", "nodeId", "ownerId", "createdBy"];
151
+ /** Raw `db` block as it appears in plugin.json. */
152
+ export interface ManifestDbModel {
153
+ scope?: string;
154
+ owner?: string;
155
+ fields: Record<string, string>;
156
+ sealed?: string[];
157
+ unique?: string[][];
158
+ /**
159
+ * A plain group (`["active","createdAt"]`) is a btree. An object names a
160
+ * kind: `{ fields: ["tags"], kind: "contains" }` for a list, or
161
+ * `{ fields: ["title"], kind: "text" }` for substring search.
162
+ */
163
+ indexes?: (string[] | {
164
+ fields: string[];
165
+ kind?: "btree" | "contains" | "text";
166
+ })[];
167
+ rules?: Record<string, unknown>;
168
+ }
169
+ export interface CompileInput {
170
+ pluginId: string;
171
+ db: Record<string, ManifestDbModel>;
172
+ /** From `roles.vocabulary`; a rule may only name a role this app declares. */
173
+ vocabulary?: string[];
174
+ /**
175
+ * From `roles.default.owner` — this app's own word for the workspace owner.
176
+ * It is what a model with NO rules falls back to, so an app whose vocabulary
177
+ * has no word spelled "owner" still gets the safe default rather than a
178
+ * compile error about a rule its author never wrote.
179
+ */
180
+ ownerRole?: string;
181
+ }
182
+ /** An authoring mistake. Thrown at build time, never at request time. */
183
+ export declare class RuleCompileError extends Error {
184
+ readonly keyPath: string;
185
+ constructor(keyPath: string, message: string);
186
+ }
187
+ export declare function camelKey(model: string): string;
188
+ /**
189
+ * Compile a manifest's `db` block. Throws `RuleCompileError` with the key path
190
+ * and the allowed values for anything an author got wrong — this runs at build
191
+ * and in the workbench, where a precise message is the whole product.
192
+ */
193
+ export declare function compileRules(input: CompileInput): CompiledRules;
194
+ export interface PlanContext {
195
+ /** True when this app is being read through a binding another app holds. */
196
+ viaBinding?: boolean;
197
+ }
198
+ export interface ReadPlan {
199
+ /** False ⇒ the caller sees nothing: a filter that matches no row. */
200
+ visible: boolean;
201
+ /** When set, only rows whose `ownerId` is in this list. */
202
+ ownerIn: string[] | null;
203
+ }
204
+ /**
205
+ * What this caller may READ. Never refuses — see the header. A caller with no
206
+ * grant gets `visible: false`, which the client turns into an empty result,
207
+ * so an id they may not see is indistinguishable from an id that never was.
208
+ */
209
+ export declare function planRead(model: CompiledModel, viewer: RuleViewer, ctx?: PlanContext): ReadPlan;
210
+ export type WritePlan = {
211
+ ok: true;
212
+ /** When set, only rows this caller owns. */
213
+ ownerIn: string[] | null;
214
+ /** When set, the only fields this caller may write. */
215
+ fields: string[] | null;
216
+ } | {
217
+ ok: false;
218
+ code: "forbidden" | "login-required";
219
+ detail: string;
220
+ };
221
+ /**
222
+ * What this caller may CREATE, UPDATE or DELETE. Unlike a read, a write does
223
+ * refuse — and distinguishes "sign in first" from "never".
224
+ */
225
+ export declare function planWrite(model: CompiledModel, op: Exclude<RuleOp, "read">, viewer: RuleViewer, ctx?: PlanContext): WritePlan;
226
+ /** May this caller see the sealed fields of a row they can otherwise read? */
227
+ export declare function canUnseal(viewer: RuleViewer, row: {
228
+ ownerId?: string | null;
229
+ }): boolean;
@@ -0,0 +1,426 @@
1
+ /**
2
+ * THE RULE ENGINE — a manifest's `db` block turned into data, and decisions
3
+ * taken from that data.
4
+ *
5
+ * This module is the heart of the whole access story, and the reason it is
6
+ * pure, dependency-free and returns DATA is that three different things must
7
+ * agree about who may see what: the in-memory client an author writes unit
8
+ * tests against, the Prisma extension production runs, and the workbench box.
9
+ * If each re-derived the rules, "my tests pass" would mean nothing about
10
+ * production. So the rules compile ONCE, to a JSON artefact
11
+ * (`.esoul/rules.json`), and every client reads that same artefact.
12
+ *
13
+ * Two decisions are load-bearing and easy to get wrong:
14
+ *
15
+ * 1. **A read never refuses.** A row the caller may not see must read as
16
+ * ABSENT — `null`, `0`, missing from the list — never as a refusal. A
17
+ * refusal is an answer: "forbidden" on someone else's order id confirms
18
+ * that the id exists. So read rules compile to a WHERE conjunct and the
19
+ * worst case is a filter that matches nothing.
20
+ *
21
+ * 2. **"Not signed in" is not "not allowed."** A rule that needs an account
22
+ * answers `login-required` to an anonymous caller, so the app can render a
23
+ * sign-in wall. Answering `forbidden` there would tell a customer they may
24
+ * never order, when the truth is they may, after signing in.
25
+ */
26
+ export const PRINCIPAL_KINDS = ["anyone", "account", "creator", "role", "apps", "internal"];
27
+ export const RULE_OPS = ["read", "create", "update", "delete"];
28
+ /* ───────────────────── columns the platform owns ──────────────────────── */
29
+ /** Stamped by the platform on every row. A manifest may not declare these. */
30
+ export const PLATFORM_COLUMNS = [
31
+ "id",
32
+ "workspaceId",
33
+ "nodeId",
34
+ "ownerId",
35
+ "createdBy",
36
+ "createdAt",
37
+ "updatedAt",
38
+ "deletedAt",
39
+ ];
40
+ /** Platform columns a caller may filter or sort by (each one is indexed). */
41
+ export const PLATFORM_FILTERABLE = ["id", "createdAt", "ownerId"];
42
+ /** Scope keys an app may never supply itself — the platform injects them. */
43
+ export const INJECTED_COLUMNS = ["workspaceId", "nodeId", "ownerId", "createdBy"];
44
+ /** An authoring mistake. Thrown at build time, never at request time. */
45
+ export class RuleCompileError extends Error {
46
+ keyPath;
47
+ constructor(keyPath, message) {
48
+ super(`${keyPath}: ${message}`);
49
+ this.keyPath = keyPath;
50
+ this.name = "RuleCompileError";
51
+ }
52
+ }
53
+ const FIELD_TYPES = ["string", "text", "int", "float", "boolean", "datetime", "json"];
54
+ // Positional groups, not named ones: the repo-wide tsc targets below ES2018.
55
+ // [1] base type, [2] "[]" for a list, [3] "?" for optional, [4] the default.
56
+ const FIELD_SPEC = /^([a-z]+|ref:[A-Za-z][A-Za-z0-9_]*)(\[\])?(\?)?(?:=(.*))?$/;
57
+ export function camelKey(model) {
58
+ return model.charAt(0).toLowerCase() + model.slice(1);
59
+ }
60
+ function parseField(keyPath, spec, models) {
61
+ const m = FIELD_SPEC.exec(spec);
62
+ if (!m) {
63
+ throw new RuleCompileError(keyPath, `unreadable field spec "${spec}" — allowed: ${FIELD_TYPES.join(", ")}, ref:<Model>; suffix ? for optional, [] for a list, =value for a default`);
64
+ }
65
+ const [, base, list, opt, def] = m;
66
+ const isRef = base.startsWith("ref:");
67
+ const type = isRef ? "ref" : base;
68
+ if (!isRef && !FIELD_TYPES.includes(type)) {
69
+ throw new RuleCompileError(keyPath, `unknown type "${base}" — allowed: ${FIELD_TYPES.join(", ")}, ref:<Model>`);
70
+ }
71
+ const ref = isRef ? base.slice(4) : undefined;
72
+ if (ref && !models.includes(ref)) {
73
+ throw new RuleCompileError(keyPath, `ref:${ref} names no model of this app — declared: ${models.join(", ") || "(none)"}. A relation to a platform table is never allowed.`);
74
+ }
75
+ const field = { type, optional: !!opt, list: !!list };
76
+ if (ref)
77
+ field.ref = ref;
78
+ if (def !== undefined) {
79
+ field.default =
80
+ type === "int" || type === "float"
81
+ ? Number(def)
82
+ : type === "boolean"
83
+ ? def === "true"
84
+ : def;
85
+ if ((type === "int" || type === "float") && Number.isNaN(field.default)) {
86
+ throw new RuleCompileError(keyPath, `default "${def}" is not a number`);
87
+ }
88
+ }
89
+ return field;
90
+ }
91
+ function parsePrincipal(keyPath, raw, vocabulary) {
92
+ switch (raw) {
93
+ case "anyone":
94
+ return { t: "anyone" };
95
+ case "account":
96
+ return { t: "account" };
97
+ case "creator":
98
+ return { t: "creator" };
99
+ case "apps":
100
+ return { t: "apps" };
101
+ case "internal":
102
+ return { t: "internal" };
103
+ default:
104
+ break;
105
+ }
106
+ if (vocabulary && !vocabulary.includes(raw)) {
107
+ throw new RuleCompileError(keyPath, `"${raw}" is neither a principal nor a role this app declares — principals: anyone, account, creator, apps, internal; roles: ${vocabulary.join(", ") || "(none declared)"}`);
108
+ }
109
+ return { t: "role", role: raw };
110
+ }
111
+ function parseRule(keyPath, raw, fields, vocabulary, op) {
112
+ const empty = { principals: [], requiresAccount: false, creatorMay: null, fields: null };
113
+ if (raw === undefined)
114
+ return empty;
115
+ if (typeof raw === "string") {
116
+ return { ...empty, principals: [parsePrincipal(keyPath, raw, vocabulary)] };
117
+ }
118
+ if (Array.isArray(raw)) {
119
+ return {
120
+ ...empty,
121
+ principals: raw.map((p, i) => parsePrincipal(`${keyPath}[${i}]`, String(p), vocabulary)),
122
+ };
123
+ }
124
+ if (typeof raw !== "object" || raw === null) {
125
+ throw new RuleCompileError(keyPath, `a rule is "anyone", a list of principals, or an object — got ${typeof raw}`);
126
+ }
127
+ const o = raw;
128
+ const roles = Array.isArray(o.roles) ? o.roles : [];
129
+ const principals = roles.map((p, i) => parsePrincipal(`${keyPath}.roles[${i}]`, String(p), vocabulary));
130
+ const checkFields = (list, label) => {
131
+ if (list === undefined)
132
+ return null;
133
+ if (!Array.isArray(list))
134
+ throw new RuleCompileError(`${keyPath}.${label}`, "must be a list of field names");
135
+ for (const f of list) {
136
+ if (!fields.includes(String(f))) {
137
+ throw new RuleCompileError(`${keyPath}.${label}`, `"${f}" is not a declared field — declared: ${fields.join(", ")}`);
138
+ }
139
+ }
140
+ return list.map(String);
141
+ };
142
+ const creatorMay = checkFields(o.creatorMay, "creatorMay");
143
+ if (creatorMay && op !== "update") {
144
+ throw new RuleCompileError(`${keyPath}.creatorMay`, "only an `update` rule may narrow what a creator writes");
145
+ }
146
+ if (o.requires !== undefined && o.requires !== "account") {
147
+ throw new RuleCompileError(`${keyPath}.requires`, `only "account" is a requirement — got "${String(o.requires)}"`);
148
+ }
149
+ return {
150
+ principals,
151
+ requiresAccount: o.requires === "account",
152
+ creatorMay,
153
+ fields: checkFields(o.fields, "fields"),
154
+ };
155
+ }
156
+ /**
157
+ * Compile a manifest's `db` block. Throws `RuleCompileError` with the key path
158
+ * and the allowed values for anything an author got wrong — this runs at build
159
+ * and in the workbench, where a precise message is the whole product.
160
+ */
161
+ export function compileRules(input) {
162
+ const modelNames = Object.keys(input.db ?? {});
163
+ const ownerRole = defaultRoleFor(input);
164
+ const models = {};
165
+ for (const name of modelNames) {
166
+ const raw = input.db[name];
167
+ const at = `db.${name}`;
168
+ if (!raw || typeof raw !== "object")
169
+ throw new RuleCompileError(at, "must be an object");
170
+ if (!/^[A-Z][A-Za-z0-9]{0,40}$/.test(name)) {
171
+ throw new RuleCompileError(at, "a model name is CamelCase, starts with a capital, at most 41 characters");
172
+ }
173
+ const scope = (raw.scope ?? "instance");
174
+ if (!["instance", "workspace", "user"].includes(scope)) {
175
+ throw new RuleCompileError(`${at}.scope`, `unknown scope "${raw.scope}" — allowed: instance, workspace, user`);
176
+ }
177
+ if (raw.owner !== undefined && raw.owner !== "creator") {
178
+ throw new RuleCompileError(`${at}.owner`, `only "creator" is an owner — got "${raw.owner}"`);
179
+ }
180
+ const fieldNames = Object.keys(raw.fields ?? {});
181
+ if (!fieldNames.length)
182
+ throw new RuleCompileError(`${at}.fields`, "a model declares at least one field");
183
+ const fields = {};
184
+ for (const f of fieldNames) {
185
+ if (PLATFORM_COLUMNS.includes(f)) {
186
+ throw new RuleCompileError(`${at}.fields.${f}`, `"${f}" is a column the platform owns and stamps — it may not be declared. Platform columns: ${PLATFORM_COLUMNS.join(", ")}`);
187
+ }
188
+ fields[f] = parseField(`${at}.fields.${f}`, String(raw.fields[f]), modelNames);
189
+ }
190
+ // An index may also name a platform column that exists on every row —
191
+ // `createdAt` above all, because paging a customer's orders newest-first is
192
+ // the first thing every app does. The scope columns are indexed by the
193
+ // platform already and are not an app's to declare.
194
+ const INDEXABLE_PLATFORM = ["id", "createdAt", "updatedAt", "ownerId"];
195
+ const checkColumns = (groups, label) => {
196
+ if (!groups)
197
+ return [];
198
+ for (const group of groups) {
199
+ for (const col of group) {
200
+ if (!fieldNames.includes(col) && !INDEXABLE_PLATFORM.includes(col)) {
201
+ throw new RuleCompileError(`${at}.${label}`, `"${col}" is neither a declared field nor an indexable platform column — declared: ${fieldNames.join(", ")}; platform: ${INDEXABLE_PLATFORM.join(", ")}`);
202
+ }
203
+ }
204
+ }
205
+ return groups.map((g) => [...g]);
206
+ };
207
+ const unique = checkColumns(raw.unique, "unique");
208
+ /**
209
+ * `indexes` takes a plain group (`["active","createdAt"]`) or a kind
210
+ * (`{ "fields": ["tags"], "kind": "contains" }`). A kind is refused when
211
+ * the field cannot support it, by name — an author who indexed a list for
212
+ * text search would otherwise get a working build and a sequential scan
213
+ * at their customers' expense.
214
+ */
215
+ const indexes = (raw.indexes ?? []).map((entry, i) => {
216
+ const where = `${at}.indexes[${i}]`;
217
+ if (Array.isArray(entry))
218
+ return { fields: checkColumns([entry], "indexes")[0], kind: "btree" };
219
+ if (!entry || typeof entry !== "object") {
220
+ throw new RuleCompileError(where, 'an index is a list of columns (["a","b"]) or { "fields": [...], "kind": "contains" | "text" }');
221
+ }
222
+ const e = entry;
223
+ if (!Array.isArray(e.fields) || !e.fields.length)
224
+ throw new RuleCompileError(`${where}.fields`, "name at least one field");
225
+ const flds = checkColumns([e.fields.map(String)], "indexes")[0];
226
+ const kind = e.kind === undefined ? "btree" : String(e.kind);
227
+ if (kind !== "btree" && kind !== "contains" && kind !== "text") {
228
+ throw new RuleCompileError(`${where}.kind`, `unknown kind "${kind}" — allowed: btree, contains, text`);
229
+ }
230
+ if (kind !== "btree") {
231
+ if (flds.length !== 1)
232
+ throw new RuleCompileError(`${where}.fields`, `a "${kind}" index covers exactly one field`);
233
+ const f = fields[flds[0]];
234
+ if (!f)
235
+ throw new RuleCompileError(`${where}.fields`, `"${flds[0]}" is a platform column; a "${kind}" index covers one of your own fields`);
236
+ if (kind === "contains" && !f.list) {
237
+ throw new RuleCompileError(`${where}`, `"${flds[0]}" is not a list — a "contains" index is for a list field (e.g. "tags": "string[]"). For substring search on text use "kind": "text".`);
238
+ }
239
+ if (kind === "text" && (f.list || (f.type !== "string" && f.type !== "text"))) {
240
+ throw new RuleCompileError(`${where}`, `"${flds[0]}" is ${f.list ? "a list" : `a ${f.type}`} — a "text" index is for a single string or text field.`);
241
+ }
242
+ }
243
+ return { fields: flds, kind };
244
+ });
245
+ const sealed = raw.sealed ?? [];
246
+ for (const f of sealed) {
247
+ if (!fieldNames.includes(f)) {
248
+ throw new RuleCompileError(`${at}.sealed`, `"${f}" is not a declared field — declared: ${fieldNames.join(", ")}`);
249
+ }
250
+ }
251
+ // A user-scoped model belongs to ONE account and nobody else reads it. A
252
+ // rule there could only ever widen that, so it is refused rather than
253
+ // quietly ignored — an author who wrote one believed something false.
254
+ if (scope === "user" && raw.rules !== undefined) {
255
+ 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
+ }
257
+ const rawRules = (raw.rules ?? {});
258
+ for (const key of Object.keys(rawRules)) {
259
+ if (!["read", "create", "update", "delete", "write"].includes(key)) {
260
+ throw new RuleCompileError(`${at}.rules.${key}`, "allowed: read, create, update, delete, write");
261
+ }
262
+ }
263
+ const write = rawRules.write;
264
+ 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"),
269
+ };
270
+ const filterable = [
271
+ ...new Set([
272
+ ...PLATFORM_FILTERABLE,
273
+ ...unique.flat(),
274
+ ...indexes.flatMap((i) => i.fields),
275
+ ]),
276
+ ].sort();
277
+ models[name] = {
278
+ name,
279
+ key: camelKey(name),
280
+ scope,
281
+ ownedByCreator: raw.owner === "creator",
282
+ fields,
283
+ sealed: [...sealed],
284
+ unique,
285
+ indexes,
286
+ filterable,
287
+ rules,
288
+ };
289
+ }
290
+ return { version: 1, pluginId: input.pluginId, models };
291
+ }
292
+ /**
293
+ * With nothing declared, only the owner. The safe default is the one that
294
+ * cannot leak: an author who forgets a rule gets an app that works for them
295
+ * and refuses everyone else, which they notice immediately.
296
+ */
297
+ function defaultFor(scope, _op, ownerRole) {
298
+ return scope === "user" ? undefined : [ownerRole];
299
+ }
300
+ /**
301
+ * WHOSE word the missing rule names. An app that declares no vocabulary means
302
+ * the plain word "owner". An app that declares one means ITS word for the
303
+ * workspace owner, which is why `roles.default.owner` is read here — otherwise
304
+ * an app whose people are called "requester" and "agent" would fail to compile
305
+ * on a rule its author never wrote. An app that gives the owner no word at all
306
+ * (`"none"`) gets the only default that cannot leak and cannot crash: its own
307
+ * server code, and nobody else, until the author writes a rule.
308
+ */
309
+ function defaultRoleFor(input) {
310
+ if (!input.vocabulary?.length)
311
+ return "owner";
312
+ if (input.ownerRole && input.vocabulary.includes(input.ownerRole))
313
+ return input.ownerRole;
314
+ return input.vocabulary.includes("owner") ? "owner" : "internal";
315
+ }
316
+ function principalOutcome(p, v, ctx) {
317
+ switch (p.t) {
318
+ case "anyone":
319
+ return "grant";
320
+ case "account":
321
+ // An agent acting for a signed-in person IS that account.
322
+ return v.userId ? "grant" : "needs-account";
323
+ case "creator":
324
+ // Never a check: this becomes `ownerId IN viewerIds` on the query.
325
+ return v.viewerIds.length ? "own-rows" : "no";
326
+ case "role":
327
+ return v.role === p.role ? "grant" : "no";
328
+ case "apps":
329
+ return ctx.viaBinding ? "grant" : "no";
330
+ case "internal":
331
+ return v.kind === "internal" ? "grant" : "no";
332
+ default: {
333
+ // Adding a principal without handling it here is a COMPILE error.
334
+ const exhaustive = p;
335
+ void exhaustive;
336
+ return "no";
337
+ }
338
+ }
339
+ }
340
+ /**
341
+ * What this caller may READ. Never refuses — see the header. A caller with no
342
+ * grant gets `visible: false`, which the client turns into an empty result,
343
+ * so an id they may not see is indistinguishable from an id that never was.
344
+ */
345
+ export function planRead(model, viewer, ctx = {}) {
346
+ if (viewer.kind === "internal")
347
+ return { visible: true, ownerIn: null };
348
+ // A user-scoped model ignores rules: your rows, nobody else's.
349
+ if (model.scope === "user") {
350
+ return viewer.viewerIds.length ? { visible: true, ownerIn: viewer.viewerIds } : { visible: false, ownerIn: null };
351
+ }
352
+ let ownRows = false;
353
+ for (const p of model.rules.read.principals) {
354
+ const outcome = principalOutcome(p, viewer, ctx);
355
+ if (outcome === "grant")
356
+ return { visible: true, ownerIn: null };
357
+ if (outcome === "own-rows")
358
+ ownRows = true;
359
+ }
360
+ // `creator` only makes sense on a model that records one.
361
+ if (ownRows && model.ownedByCreator)
362
+ return { visible: true, ownerIn: viewer.viewerIds };
363
+ return { visible: false, ownerIn: null };
364
+ }
365
+ /**
366
+ * What this caller may CREATE, UPDATE or DELETE. Unlike a read, a write does
367
+ * refuse — and distinguishes "sign in first" from "never".
368
+ */
369
+ export function planWrite(model, op, viewer, ctx = {}) {
370
+ if (viewer.kind === "internal")
371
+ return { ok: true, ownerIn: null, fields: null };
372
+ if (model.scope === "user") {
373
+ // A row that belongs to an account needs an account to belong to.
374
+ if (!viewer.userId) {
375
+ return { ok: false, code: "login-required", detail: `${model.name} rows belong to an account; this caller has none` };
376
+ }
377
+ return { ok: true, ownerIn: viewer.viewerIds, fields: null };
378
+ }
379
+ const rule = model.rules[op];
380
+ let sawNeedsAccount = false;
381
+ for (const p of rule.principals) {
382
+ const outcome = principalOutcome(p, viewer, ctx);
383
+ if (outcome === "grant") {
384
+ if (rule.requiresAccount && !viewer.userId) {
385
+ return {
386
+ ok: false,
387
+ code: "login-required",
388
+ detail: `${model.name}.${op} requires a signed-in account`,
389
+ };
390
+ }
391
+ return { ok: true, ownerIn: null, fields: rule.fields };
392
+ }
393
+ if (outcome === "needs-account")
394
+ sawNeedsAccount = true;
395
+ if (outcome === "own-rows" && model.ownedByCreator) {
396
+ if (rule.requiresAccount && !viewer.userId) {
397
+ return { ok: false, code: "login-required", detail: `${model.name}.${op} requires a signed-in account` };
398
+ }
399
+ return { ok: true, ownerIn: viewer.viewerIds, fields: rule.fields };
400
+ }
401
+ }
402
+ // `creatorMay` is the last door: not your role, but your row, and only the
403
+ // fields the app said a creator may touch (an order's note, never its price).
404
+ if (op === "update" && rule.creatorMay && model.ownedByCreator && viewer.viewerIds.length) {
405
+ return { ok: true, ownerIn: viewer.viewerIds, fields: rule.creatorMay };
406
+ }
407
+ if (sawNeedsAccount || (rule.requiresAccount && !viewer.userId)) {
408
+ return { ok: false, code: "login-required", detail: `${model.name}.${op} requires a signed-in account` };
409
+ }
410
+ return {
411
+ ok: false,
412
+ code: "forbidden",
413
+ detail: `${model.name}.${op} allows ${describe(rule.principals)}; this caller is ${viewer.kind}/${viewer.role}`,
414
+ };
415
+ }
416
+ function describe(principals) {
417
+ if (!principals.length)
418
+ return "nobody";
419
+ return principals.map((p) => (p.t === "role" ? p.role : p.t)).join(", ");
420
+ }
421
+ /** May this caller see the sealed fields of a row they can otherwise read? */
422
+ export function canUnseal(viewer, row) {
423
+ if (viewer.kind === "internal")
424
+ return true;
425
+ return !!row.ownerId && viewer.viewerIds.includes(row.ownerId);
426
+ }