esoul-sdk 0.3.0 → 0.6.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 +135 -24
- package/dist/audience.d.ts +103 -0
- package/dist/audience.js +142 -0
- package/dist/bindings.d.ts +164 -0
- package/dist/bindings.js +163 -0
- package/dist/db/client-core.d.ts +154 -0
- package/dist/db/client-core.js +274 -0
- package/dist/db/compile-rules.d.ts +199 -0
- package/dist/db/compile-rules.js +390 -0
- package/dist/db/memory-client.d.ts +136 -0
- package/dist/db/memory-client.js +323 -0
- package/dist/db/schema-gen.d.ts +103 -0
- package/dist/db/schema-gen.js +329 -0
- package/dist/helpers.d.ts +67 -0
- package/dist/helpers.js +125 -8
- package/dist/index.d.ts +24 -0
- package/dist/index.js +22 -0
- package/dist/manifest.d.ts +445 -13
- package/dist/manifest.js +211 -5
- package/dist/react.d.ts +29 -0
- package/dist/react.js +10 -0
- package/dist/roles.d.ts +43 -0
- package/dist/roles.js +56 -0
- package/dist/server.d.ts +165 -0
- package/dist/server.js +80 -0
- package/dist/testing/db.d.ts +69 -0
- package/dist/testing/db.js +94 -0
- package/dist/testing/index.d.ts +14 -0
- package/dist/testing/index.js +9 -0
- package/dist/testing/ops.d.ts +84 -0
- package/dist/testing/ops.js +76 -0
- package/dist/types.d.ts +22 -1
- package/docs/04-tools.md +5 -2
- package/docs/05-ui.md +30 -0
- package/docs/06-server.md +49 -0
- package/docs/07-background-tasks.md +29 -3
- package/docs/10-testing.md +18 -0
- package/docs/12-rules.md +3 -2
- package/docs/13-people-and-access.md +148 -0
- package/docs/14-database.md +115 -0
- package/docs/15-realtime.md +88 -0
- package/docs/16-bindings.md +79 -0
- package/llms-full.txt +715 -31
- package/llms.txt +4 -0
- package/package.json +7 -3
- package/schemas/plugin.schema.json +323 -9
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
export interface CompiledModel {
|
|
98
|
+
/** As declared: `Order`. */
|
|
99
|
+
name: string;
|
|
100
|
+
/** As called on the client: `order`. */
|
|
101
|
+
key: string;
|
|
102
|
+
scope: Scope;
|
|
103
|
+
/** `owner: "creator"` — rows belong to whoever created them. */
|
|
104
|
+
ownedByCreator: boolean;
|
|
105
|
+
fields: Record<string, CompiledField>;
|
|
106
|
+
sealed: string[];
|
|
107
|
+
unique: string[][];
|
|
108
|
+
indexes: string[][];
|
|
109
|
+
/**
|
|
110
|
+
* Every field a `where` or `orderBy` may name. An unindexed filter is a
|
|
111
|
+
* table scan waiting to happen at a customer's scale, so it is refused with
|
|
112
|
+
* the index to add — the rule lands in the author's editor (the generated
|
|
113
|
+
* types key `orderBy` by exactly this list) and again at runtime.
|
|
114
|
+
*/
|
|
115
|
+
filterable: string[];
|
|
116
|
+
rules: Record<RuleOp, CompiledRule>;
|
|
117
|
+
}
|
|
118
|
+
export interface CompiledRules {
|
|
119
|
+
version: 1;
|
|
120
|
+
pluginId: string;
|
|
121
|
+
models: Record<string, CompiledModel>;
|
|
122
|
+
}
|
|
123
|
+
/** Stamped by the platform on every row. A manifest may not declare these. */
|
|
124
|
+
export declare const PLATFORM_COLUMNS: readonly ["id", "workspaceId", "nodeId", "ownerId", "createdBy", "createdAt", "updatedAt", "deletedAt"];
|
|
125
|
+
/** Platform columns a caller may filter or sort by (each one is indexed). */
|
|
126
|
+
export declare const PLATFORM_FILTERABLE: readonly ["id", "createdAt", "ownerId"];
|
|
127
|
+
/** Scope keys an app may never supply itself — the platform injects them. */
|
|
128
|
+
export declare const INJECTED_COLUMNS: readonly ["workspaceId", "nodeId", "ownerId", "createdBy"];
|
|
129
|
+
/** Raw `db` block as it appears in plugin.json. */
|
|
130
|
+
export interface ManifestDbModel {
|
|
131
|
+
scope?: string;
|
|
132
|
+
owner?: string;
|
|
133
|
+
fields: Record<string, string>;
|
|
134
|
+
sealed?: string[];
|
|
135
|
+
unique?: string[][];
|
|
136
|
+
indexes?: string[][];
|
|
137
|
+
rules?: Record<string, unknown>;
|
|
138
|
+
}
|
|
139
|
+
export interface CompileInput {
|
|
140
|
+
pluginId: string;
|
|
141
|
+
db: Record<string, ManifestDbModel>;
|
|
142
|
+
/** From `roles.vocabulary`; a rule may only name a role this app declares. */
|
|
143
|
+
vocabulary?: string[];
|
|
144
|
+
/**
|
|
145
|
+
* From `roles.default.owner` — this app's own word for the workspace owner.
|
|
146
|
+
* It is what a model with NO rules falls back to, so an app whose vocabulary
|
|
147
|
+
* has no word spelled "owner" still gets the safe default rather than a
|
|
148
|
+
* compile error about a rule its author never wrote.
|
|
149
|
+
*/
|
|
150
|
+
ownerRole?: string;
|
|
151
|
+
}
|
|
152
|
+
/** An authoring mistake. Thrown at build time, never at request time. */
|
|
153
|
+
export declare class RuleCompileError extends Error {
|
|
154
|
+
readonly keyPath: string;
|
|
155
|
+
constructor(keyPath: string, message: string);
|
|
156
|
+
}
|
|
157
|
+
export declare function camelKey(model: string): string;
|
|
158
|
+
/**
|
|
159
|
+
* Compile a manifest's `db` block. Throws `RuleCompileError` with the key path
|
|
160
|
+
* and the allowed values for anything an author got wrong — this runs at build
|
|
161
|
+
* and in the workbench, where a precise message is the whole product.
|
|
162
|
+
*/
|
|
163
|
+
export declare function compileRules(input: CompileInput): CompiledRules;
|
|
164
|
+
export interface PlanContext {
|
|
165
|
+
/** True when this app is being read through a binding another app holds. */
|
|
166
|
+
viaBinding?: boolean;
|
|
167
|
+
}
|
|
168
|
+
export interface ReadPlan {
|
|
169
|
+
/** False ⇒ the caller sees nothing: a filter that matches no row. */
|
|
170
|
+
visible: boolean;
|
|
171
|
+
/** When set, only rows whose `ownerId` is in this list. */
|
|
172
|
+
ownerIn: string[] | null;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* What this caller may READ. Never refuses — see the header. A caller with no
|
|
176
|
+
* grant gets `visible: false`, which the client turns into an empty result,
|
|
177
|
+
* so an id they may not see is indistinguishable from an id that never was.
|
|
178
|
+
*/
|
|
179
|
+
export declare function planRead(model: CompiledModel, viewer: RuleViewer, ctx?: PlanContext): ReadPlan;
|
|
180
|
+
export type WritePlan = {
|
|
181
|
+
ok: true;
|
|
182
|
+
/** When set, only rows this caller owns. */
|
|
183
|
+
ownerIn: string[] | null;
|
|
184
|
+
/** When set, the only fields this caller may write. */
|
|
185
|
+
fields: string[] | null;
|
|
186
|
+
} | {
|
|
187
|
+
ok: false;
|
|
188
|
+
code: "forbidden" | "login-required";
|
|
189
|
+
detail: string;
|
|
190
|
+
};
|
|
191
|
+
/**
|
|
192
|
+
* What this caller may CREATE, UPDATE or DELETE. Unlike a read, a write does
|
|
193
|
+
* refuse — and distinguishes "sign in first" from "never".
|
|
194
|
+
*/
|
|
195
|
+
export declare function planWrite(model: CompiledModel, op: Exclude<RuleOp, "read">, viewer: RuleViewer, ctx?: PlanContext): WritePlan;
|
|
196
|
+
/** May this caller see the sealed fields of a row they can otherwise read? */
|
|
197
|
+
export declare function canUnseal(viewer: RuleViewer, row: {
|
|
198
|
+
ownerId?: string | null;
|
|
199
|
+
}): boolean;
|
|
@@ -0,0 +1,390 @@
|
|
|
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
|
+
const indexes = checkColumns(raw.indexes, "indexes");
|
|
209
|
+
const sealed = raw.sealed ?? [];
|
|
210
|
+
for (const f of sealed) {
|
|
211
|
+
if (!fieldNames.includes(f)) {
|
|
212
|
+
throw new RuleCompileError(`${at}.sealed`, `"${f}" is not a declared field — declared: ${fieldNames.join(", ")}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// A user-scoped model belongs to ONE account and nobody else reads it. A
|
|
216
|
+
// rule there could only ever widen that, so it is refused rather than
|
|
217
|
+
// quietly ignored — an author who wrote one believed something false.
|
|
218
|
+
if (scope === "user" && raw.rules !== undefined) {
|
|
219
|
+
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");
|
|
220
|
+
}
|
|
221
|
+
const rawRules = (raw.rules ?? {});
|
|
222
|
+
for (const key of Object.keys(rawRules)) {
|
|
223
|
+
if (!["read", "create", "update", "delete", "write"].includes(key)) {
|
|
224
|
+
throw new RuleCompileError(`${at}.rules.${key}`, "allowed: read, create, update, delete, write");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const write = rawRules.write;
|
|
228
|
+
const rules = {
|
|
229
|
+
read: parseRule(`${at}.rules.read`, rawRules.read ?? defaultFor(scope, "read", ownerRole), fieldNames, input.vocabulary, "read"),
|
|
230
|
+
create: parseRule(`${at}.rules.create`, rawRules.create ?? write ?? defaultFor(scope, "create", ownerRole), fieldNames, input.vocabulary, "create"),
|
|
231
|
+
update: parseRule(`${at}.rules.update`, rawRules.update ?? write ?? defaultFor(scope, "update", ownerRole), fieldNames, input.vocabulary, "update"),
|
|
232
|
+
delete: parseRule(`${at}.rules.delete`, rawRules.delete ?? write ?? defaultFor(scope, "delete", ownerRole), fieldNames, input.vocabulary, "delete"),
|
|
233
|
+
};
|
|
234
|
+
const filterable = [
|
|
235
|
+
...new Set([
|
|
236
|
+
...PLATFORM_FILTERABLE,
|
|
237
|
+
...unique.flat(),
|
|
238
|
+
...indexes.flat(),
|
|
239
|
+
]),
|
|
240
|
+
].sort();
|
|
241
|
+
models[name] = {
|
|
242
|
+
name,
|
|
243
|
+
key: camelKey(name),
|
|
244
|
+
scope,
|
|
245
|
+
ownedByCreator: raw.owner === "creator",
|
|
246
|
+
fields,
|
|
247
|
+
sealed: [...sealed],
|
|
248
|
+
unique,
|
|
249
|
+
indexes,
|
|
250
|
+
filterable,
|
|
251
|
+
rules,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return { version: 1, pluginId: input.pluginId, models };
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* With nothing declared, only the owner. The safe default is the one that
|
|
258
|
+
* cannot leak: an author who forgets a rule gets an app that works for them
|
|
259
|
+
* and refuses everyone else, which they notice immediately.
|
|
260
|
+
*/
|
|
261
|
+
function defaultFor(scope, _op, ownerRole) {
|
|
262
|
+
return scope === "user" ? undefined : [ownerRole];
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* WHOSE word the missing rule names. An app that declares no vocabulary means
|
|
266
|
+
* the plain word "owner". An app that declares one means ITS word for the
|
|
267
|
+
* workspace owner, which is why `roles.default.owner` is read here — otherwise
|
|
268
|
+
* an app whose people are called "requester" and "agent" would fail to compile
|
|
269
|
+
* on a rule its author never wrote. An app that gives the owner no word at all
|
|
270
|
+
* (`"none"`) gets the only default that cannot leak and cannot crash: its own
|
|
271
|
+
* server code, and nobody else, until the author writes a rule.
|
|
272
|
+
*/
|
|
273
|
+
function defaultRoleFor(input) {
|
|
274
|
+
if (!input.vocabulary?.length)
|
|
275
|
+
return "owner";
|
|
276
|
+
if (input.ownerRole && input.vocabulary.includes(input.ownerRole))
|
|
277
|
+
return input.ownerRole;
|
|
278
|
+
return input.vocabulary.includes("owner") ? "owner" : "internal";
|
|
279
|
+
}
|
|
280
|
+
function principalOutcome(p, v, ctx) {
|
|
281
|
+
switch (p.t) {
|
|
282
|
+
case "anyone":
|
|
283
|
+
return "grant";
|
|
284
|
+
case "account":
|
|
285
|
+
// An agent acting for a signed-in person IS that account.
|
|
286
|
+
return v.userId ? "grant" : "needs-account";
|
|
287
|
+
case "creator":
|
|
288
|
+
// Never a check: this becomes `ownerId IN viewerIds` on the query.
|
|
289
|
+
return v.viewerIds.length ? "own-rows" : "no";
|
|
290
|
+
case "role":
|
|
291
|
+
return v.role === p.role ? "grant" : "no";
|
|
292
|
+
case "apps":
|
|
293
|
+
return ctx.viaBinding ? "grant" : "no";
|
|
294
|
+
case "internal":
|
|
295
|
+
return v.kind === "internal" ? "grant" : "no";
|
|
296
|
+
default: {
|
|
297
|
+
// Adding a principal without handling it here is a COMPILE error.
|
|
298
|
+
const exhaustive = p;
|
|
299
|
+
void exhaustive;
|
|
300
|
+
return "no";
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* What this caller may READ. Never refuses — see the header. A caller with no
|
|
306
|
+
* grant gets `visible: false`, which the client turns into an empty result,
|
|
307
|
+
* so an id they may not see is indistinguishable from an id that never was.
|
|
308
|
+
*/
|
|
309
|
+
export function planRead(model, viewer, ctx = {}) {
|
|
310
|
+
if (viewer.kind === "internal")
|
|
311
|
+
return { visible: true, ownerIn: null };
|
|
312
|
+
// A user-scoped model ignores rules: your rows, nobody else's.
|
|
313
|
+
if (model.scope === "user") {
|
|
314
|
+
return viewer.viewerIds.length ? { visible: true, ownerIn: viewer.viewerIds } : { visible: false, ownerIn: null };
|
|
315
|
+
}
|
|
316
|
+
let ownRows = false;
|
|
317
|
+
for (const p of model.rules.read.principals) {
|
|
318
|
+
const outcome = principalOutcome(p, viewer, ctx);
|
|
319
|
+
if (outcome === "grant")
|
|
320
|
+
return { visible: true, ownerIn: null };
|
|
321
|
+
if (outcome === "own-rows")
|
|
322
|
+
ownRows = true;
|
|
323
|
+
}
|
|
324
|
+
// `creator` only makes sense on a model that records one.
|
|
325
|
+
if (ownRows && model.ownedByCreator)
|
|
326
|
+
return { visible: true, ownerIn: viewer.viewerIds };
|
|
327
|
+
return { visible: false, ownerIn: null };
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* What this caller may CREATE, UPDATE or DELETE. Unlike a read, a write does
|
|
331
|
+
* refuse — and distinguishes "sign in first" from "never".
|
|
332
|
+
*/
|
|
333
|
+
export function planWrite(model, op, viewer, ctx = {}) {
|
|
334
|
+
if (viewer.kind === "internal")
|
|
335
|
+
return { ok: true, ownerIn: null, fields: null };
|
|
336
|
+
if (model.scope === "user") {
|
|
337
|
+
// A row that belongs to an account needs an account to belong to.
|
|
338
|
+
if (!viewer.userId) {
|
|
339
|
+
return { ok: false, code: "login-required", detail: `${model.name} rows belong to an account; this caller has none` };
|
|
340
|
+
}
|
|
341
|
+
return { ok: true, ownerIn: viewer.viewerIds, fields: null };
|
|
342
|
+
}
|
|
343
|
+
const rule = model.rules[op];
|
|
344
|
+
let sawNeedsAccount = false;
|
|
345
|
+
for (const p of rule.principals) {
|
|
346
|
+
const outcome = principalOutcome(p, viewer, ctx);
|
|
347
|
+
if (outcome === "grant") {
|
|
348
|
+
if (rule.requiresAccount && !viewer.userId) {
|
|
349
|
+
return {
|
|
350
|
+
ok: false,
|
|
351
|
+
code: "login-required",
|
|
352
|
+
detail: `${model.name}.${op} requires a signed-in account`,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
return { ok: true, ownerIn: null, fields: rule.fields };
|
|
356
|
+
}
|
|
357
|
+
if (outcome === "needs-account")
|
|
358
|
+
sawNeedsAccount = true;
|
|
359
|
+
if (outcome === "own-rows" && model.ownedByCreator) {
|
|
360
|
+
if (rule.requiresAccount && !viewer.userId) {
|
|
361
|
+
return { ok: false, code: "login-required", detail: `${model.name}.${op} requires a signed-in account` };
|
|
362
|
+
}
|
|
363
|
+
return { ok: true, ownerIn: viewer.viewerIds, fields: rule.fields };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// `creatorMay` is the last door: not your role, but your row, and only the
|
|
367
|
+
// fields the app said a creator may touch (an order's note, never its price).
|
|
368
|
+
if (op === "update" && rule.creatorMay && model.ownedByCreator && viewer.viewerIds.length) {
|
|
369
|
+
return { ok: true, ownerIn: viewer.viewerIds, fields: rule.creatorMay };
|
|
370
|
+
}
|
|
371
|
+
if (sawNeedsAccount || (rule.requiresAccount && !viewer.userId)) {
|
|
372
|
+
return { ok: false, code: "login-required", detail: `${model.name}.${op} requires a signed-in account` };
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
ok: false,
|
|
376
|
+
code: "forbidden",
|
|
377
|
+
detail: `${model.name}.${op} allows ${describe(rule.principals)}; this caller is ${viewer.kind}/${viewer.role}`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
function describe(principals) {
|
|
381
|
+
if (!principals.length)
|
|
382
|
+
return "nobody";
|
|
383
|
+
return principals.map((p) => (p.t === "role" ? p.role : p.t)).join(", ");
|
|
384
|
+
}
|
|
385
|
+
/** May this caller see the sealed fields of a row they can otherwise read? */
|
|
386
|
+
export function canUnseal(viewer, row) {
|
|
387
|
+
if (viewer.kind === "internal")
|
|
388
|
+
return true;
|
|
389
|
+
return !!row.ownerId && viewer.viewerIds.includes(row.ownerId);
|
|
390
|
+
}
|