@githolon/dsl 0.7.0 → 0.8.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/package.json +1 -1
- package/src/aggregate.ts +19 -16
- package/src/build_package.ts +7 -0
- package/src/codegen_dart.ts +9 -0
- package/src/codegen_ts.ts +12 -10
- package/src/compile_package_main.ts +10 -0
- package/src/directive.ts +15 -18
- package/src/engine_entry.ts +8 -2
- package/src/framework/encryption_identity.ts +3 -3
- package/src/framework/genesis.ts +88 -0
- package/src/framework/impure_capability.ts +3 -3
- package/src/framework/relations.ts +28 -4
- package/src/framework/signing_identity.ts +97 -0
- package/src/framework/workspace_invariant.ts +5 -3
- package/src/framework/workspaces.ts +158 -103
- package/src/index.ts +72 -18
- package/src/manifest.ts +0 -6
- package/src/predicate.ts +4 -3
- package/src/relation_schema.ts +298 -0
- package/src/relation_sugar.ts +324 -0
- package/src/usd.ts +142 -7
- package/src/usd_layers.ts +53 -0
- package/src/framework/rbac.ts +0 -418
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
2
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
3
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
|
|
4
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* relation_schema — THE NATIVE ZANZIBAR RELATION SCHEMA (the userset-rewrite law).
|
|
8
|
+
*
|
|
9
|
+
* This is layer 2 of the three-layer authz design (see `architecture/zanzibar_native_plan.md`):
|
|
10
|
+
* 1. TUPLES = ledger events (`relateSubject`/`RelationTuple`, framework/relations.ts).
|
|
11
|
+
* 2. SCHEMA = THIS FILE — per-type relation REWRITES authored as PURE DATA, lowered to the
|
|
12
|
+
* `nomosRelations` USD era key. NO JavaScript on the schema path: a `.relation(...)` produces
|
|
13
|
+
* a tagged-union AST node, never a closure. The engine READS this AST; the JS lump never runs it.
|
|
14
|
+
* 3. EVALUATOR = the Rust `RewriteEngine` (Check/Expand) — the ONE non-Nomos surface (a recursive
|
|
15
|
+
* typed store-walk that must be Nomos-OWNED uniform law + a PURE RECOMPUTE, never a captured read).
|
|
16
|
+
*
|
|
17
|
+
* THE ALGEBRA (100% of the Zanzibar paper — not a subset): `direct` (_this) + `computed` (sibling
|
|
18
|
+
* relation, same object) + `from` (tuple_to_userset, the two-hop) + `union`/`intersection`/`exclusion`.
|
|
19
|
+
*
|
|
20
|
+
* DETERMINISM + FAIL-CLOSED (the two non-negotiables; everything else is the domain dev's call):
|
|
21
|
+
* - The depth/fanout CAPS are SCHEMA-DECLARED LAW (the gas-limit analogy — what matters is that the
|
|
22
|
+
* bound is content-addressed law, identical on every peer, and exceeding it fail-closes to Denied;
|
|
23
|
+
* where you set it is a perf-vs-reach tradeoff). Defaults: depth 50, fanout 1000.
|
|
24
|
+
* - TYPE RESTRICTIONS are FAIL-CLOSED: a relation stores ONLY the subject classes it `allow`s. A
|
|
25
|
+
* relation with no `allow` stores NO direct tuples (purely computed). Wildcard (`anyUser`) is
|
|
26
|
+
* `user`-type ONLY — `key:*`/`<type>:*` is a forge surface (any key claiming a relation) and is
|
|
27
|
+
* unrepresentable here. Control-plane relations (admin/creator/…) `allow(user, key)` ONLY — never
|
|
28
|
+
* a userset/wildcard — so `workspace:self#admin@<group>#member` can never be stored.
|
|
29
|
+
*
|
|
30
|
+
* ERA-RULED: the compiler emits `nomosRelations` IFF a package declares ≥1 `relationType` — a
|
|
31
|
+
* pre-feature chain has no key and the relation gate is inert (byte-identical replay).
|
|
32
|
+
*
|
|
33
|
+
* NOTE (P1): relation references in the AST are NAMES here; the #58 stable-id swap (names → sids,
|
|
34
|
+
* for rename-safety + the evolve gate) is the localized follow-up — a value substitution at emit
|
|
35
|
+
* time, zero structural change to this AST.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
// ─────────────────────────────── the rewrite AST (pure data) ───────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A subject CLASS a relation may store (the OpenFGA type-restriction; fail-closed). A `direct` subject
|
|
42
|
+
* is any `<type>:<id>` reference — it unifies leaf principals (`user:anne`, `key:<hash>`) AND object
|
|
43
|
+
* pointers (`folder:eng`, the subject a `parent` tupleset relation stores for a `from(...)` two-hop).
|
|
44
|
+
*/
|
|
45
|
+
export type AllowedSubject =
|
|
46
|
+
/** `<type>:<id>` — a leaf principal (`user`/`key`) OR an object pointer (a TTU tupleset target). */
|
|
47
|
+
| { readonly kind: "direct"; readonly type: string }
|
|
48
|
+
/** `user:*` — public. `user`-TYPE ONLY (a `key:*`/`<type>:*` forge is unrepresentable; v1). */
|
|
49
|
+
| { readonly kind: "wildcard" }
|
|
50
|
+
/** `<type>:<id>#<relation>` — a USERSET subject (group membership / indirection). */
|
|
51
|
+
| { readonly kind: "userset"; readonly type: string; readonly relation: string };
|
|
52
|
+
|
|
53
|
+
/** The userset-rewrite expression — a tagged union, ONE recursive enum, pure data. */
|
|
54
|
+
export type Rewrite =
|
|
55
|
+
/** `_this`: the directly-stored tuples on (object, thisRelation), recursing into userset subjects. */
|
|
56
|
+
| { readonly kind: "this" }
|
|
57
|
+
/** `computed_userset`: a SIBLING relation on the SAME object (e.g. every `editor` is a `viewer`). */
|
|
58
|
+
| { readonly kind: "computed"; readonly relation: string }
|
|
59
|
+
/**
|
|
60
|
+
* `tuple_to_userset` (the two-hop): follow `tupleset` from this object to other OBJECTS, then check
|
|
61
|
+
* `computed` on each FOUND OBJECT (never the subject's relation). `all:false` = paper TTU (∪ over the
|
|
62
|
+
* found objects); `all:true` = the SpiceDB `.all` (∩ — every found object must grant it).
|
|
63
|
+
*/
|
|
64
|
+
| { readonly kind: "ttu"; readonly tupleset: string; readonly computed: string; readonly all: boolean }
|
|
65
|
+
| { readonly kind: "union"; readonly children: readonly Rewrite[] }
|
|
66
|
+
| { readonly kind: "intersection"; readonly children: readonly Rewrite[] }
|
|
67
|
+
| { readonly kind: "exclusion"; readonly base: Rewrite; readonly subtract: Rewrite };
|
|
68
|
+
|
|
69
|
+
/** One relation's definition: its rewrite + the subject classes it may directly store. */
|
|
70
|
+
export interface RelationDefAst {
|
|
71
|
+
/** `null` ⇒ pure direct `this` (the rewrite is just the stored tuples). */
|
|
72
|
+
readonly rewrite: Rewrite | null;
|
|
73
|
+
/** Subject classes storable on this relation's direct tuples — FAIL-CLOSED (empty ⇒ none). */
|
|
74
|
+
readonly allowed: readonly AllowedSubject[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Schema-declared evaluation caps — LAW (content-addressed, peer-identical, fail-closed). */
|
|
78
|
+
export interface RelationCaps {
|
|
79
|
+
readonly maxDepth: number;
|
|
80
|
+
readonly maxFanout: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The default caps (the gas-limit floor) — a domain raises/lowers them per its hierarchy depth. */
|
|
84
|
+
export const DEFAULT_RELATION_CAPS: RelationCaps = { maxDepth: 50, maxFanout: 1000 };
|
|
85
|
+
|
|
86
|
+
/** A complete, registered relation-type declaration (the chainable builder's value). */
|
|
87
|
+
export interface RelationTypeDecl {
|
|
88
|
+
/** The object TYPE this configures (the `<type>` in `<type>:<id>`). */
|
|
89
|
+
readonly type: string;
|
|
90
|
+
/** relationName → its def. Insertion order is irrelevant — the compiler sorts for byte-determinism. */
|
|
91
|
+
readonly relations: ReadonlyMap<string, RelationDefAst>;
|
|
92
|
+
/** Per-type cap override; absent ⇒ the schema/default caps apply. */
|
|
93
|
+
readonly caps?: RelationCaps;
|
|
94
|
+
/** Brand so a plain object is never mistaken for a declaration (mirrors workspaceInvariant). */
|
|
95
|
+
readonly __isRelationType: true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function isRelationTypeDecl(v: unknown): v is RelationTypeDecl {
|
|
99
|
+
return typeof v === "object" && v !== null && (v as { __isRelationType?: unknown }).__isRelationType === true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─────────────────────────────── the rewrite combinators ───────────────────────────────
|
|
103
|
+
|
|
104
|
+
/** `_this` — the directly-stored tuples (and their recursive userset subjects). */
|
|
105
|
+
export function self(): Rewrite {
|
|
106
|
+
return { kind: "this" };
|
|
107
|
+
}
|
|
108
|
+
/** `computed_userset` — a sibling relation on the SAME object (relation inheritance). */
|
|
109
|
+
export function computed(relation: string): Rewrite {
|
|
110
|
+
return { kind: "computed", relation: requireName(relation, "computed") };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* `tuple_to_userset` — "`computed` VIA `tupleset`": follow `tupleset` from this object to other
|
|
114
|
+
* OBJECTS, then check `computed` on each found object (the two-hop; `viewer via parent` = a doc's
|
|
115
|
+
* viewers include its parent folder's viewers). (Named `via`, not `from` — `from` is the report
|
|
116
|
+
* builder's export; the AST tag stays the canonical `ttu`.)
|
|
117
|
+
*/
|
|
118
|
+
export function via(tupleset: string, computedRel: string): Rewrite {
|
|
119
|
+
return { kind: "ttu", tupleset: requireName(tupleset, "via.tupleset"), computed: requireName(computedRel, "via.computed"), all: false };
|
|
120
|
+
}
|
|
121
|
+
/** `tuple_to_userset` with ∩ semantics (SpiceDB `.all`): EVERY found object must grant `computed`. */
|
|
122
|
+
export function viaAll(tupleset: string, computedRel: string): Rewrite {
|
|
123
|
+
return { kind: "ttu", tupleset: requireName(tupleset, "viaAll.tupleset"), computed: requireName(computedRel, "viaAll.computed"), all: true };
|
|
124
|
+
}
|
|
125
|
+
/** Union (∪) — n-ary, ≥2 operands; Allowed-absorbing. */
|
|
126
|
+
export function union(...children: Rewrite[]): Rewrite {
|
|
127
|
+
if (children.length < 2) throw new Error("union(...) needs ≥2 operands");
|
|
128
|
+
return { kind: "union", children };
|
|
129
|
+
}
|
|
130
|
+
/** Intersection (∩) — n-ary, ≥2 operands; Denied-absorbing. */
|
|
131
|
+
export function intersection(...children: Rewrite[]): Rewrite {
|
|
132
|
+
if (children.length < 2) throw new Error("intersection(...) needs ≥2 operands");
|
|
133
|
+
return { kind: "intersection", children };
|
|
134
|
+
}
|
|
135
|
+
/** Exclusion (−) — `base` MINUS `subtract` ("permitted unless"); separation-of-duties. */
|
|
136
|
+
export function exclusion(base: Rewrite, subtract: Rewrite): Rewrite {
|
|
137
|
+
return { kind: "exclusion", base, subtract };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─────────────────────────────── the allowed-subject helpers ───────────────────────────────
|
|
141
|
+
|
|
142
|
+
/** `<type>:<id>` subjects of `type` — a leaf principal OR an object pointer (the general form). */
|
|
143
|
+
export function object(type: string): AllowedSubject {
|
|
144
|
+
return { kind: "direct", type: requireName(type, "object-type") };
|
|
145
|
+
}
|
|
146
|
+
/** `user:<uid>` leaf subjects (sugar for `object("user")`). */
|
|
147
|
+
export function user(): AllowedSubject {
|
|
148
|
+
return { kind: "direct", type: "user" };
|
|
149
|
+
}
|
|
150
|
+
/** `key:<keyHash>` leaf subjects — the control-plane authority subject (sugar for `object("key")`). */
|
|
151
|
+
export function key(): AllowedSubject {
|
|
152
|
+
return { kind: "direct", type: "key" };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* `user:*` — type-scoped PUBLIC. `user`-namespace ONLY by construction: a `key:*` / `<type>:*` would
|
|
156
|
+
* let any key/object of a class claim the relation (the admin forge). There is no parameter.
|
|
157
|
+
*/
|
|
158
|
+
export function anyUser(): AllowedSubject {
|
|
159
|
+
return { kind: "wildcard" };
|
|
160
|
+
}
|
|
161
|
+
/** A USERSET subject class: `<type>:<id>#<relation>` (group membership). */
|
|
162
|
+
export function group(type: string, relation: string): AllowedSubject {
|
|
163
|
+
return { kind: "userset", type: requireName(type, "group-type"), relation: requireName(relation, "group-relation") };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─────────────────────────────── the relationType builder ───────────────────────────────
|
|
167
|
+
|
|
168
|
+
/** The fluent builder returned by `relationType(...)` — `.relation(...)` chains, terminal value is the decl. */
|
|
169
|
+
export interface RelationTypeBuilder extends RelationTypeDecl {
|
|
170
|
+
/**
|
|
171
|
+
* Declare one relation. `opts.allow` is the FAIL-CLOSED type restriction (subject classes storable on
|
|
172
|
+
* its direct tuples); `opts.rewrite` is the userset-rewrite algebra (omit ⇒ pure direct `this`).
|
|
173
|
+
* A relation whose rewrite references `this` (directly or via `self()`) but `allow`s nothing stores no
|
|
174
|
+
* tuples — legal but inert; a purely-`computed`/`from` relation needs no `allow`.
|
|
175
|
+
*/
|
|
176
|
+
relation(name: string, opts?: { rewrite?: Rewrite; allow?: AllowedSubject[] }): RelationTypeBuilder;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Author a relation type (a Zanzibar namespace): `relationType("Doc").relation("viewer", {...})`.
|
|
181
|
+
* `caps` overrides the default depth/fanout for THIS type (the schema-declared, fail-closed bound).
|
|
182
|
+
*/
|
|
183
|
+
export function relationType(type: string, caps?: Partial<RelationCaps>): RelationTypeBuilder {
|
|
184
|
+
requireName(type, "relationType");
|
|
185
|
+
const relations = new Map<string, RelationDefAst>();
|
|
186
|
+
const resolvedCaps: RelationCaps | undefined = caps
|
|
187
|
+
? {
|
|
188
|
+
maxDepth: caps.maxDepth ?? DEFAULT_RELATION_CAPS.maxDepth,
|
|
189
|
+
maxFanout: caps.maxFanout ?? DEFAULT_RELATION_CAPS.maxFanout,
|
|
190
|
+
}
|
|
191
|
+
: undefined;
|
|
192
|
+
if (resolvedCaps) {
|
|
193
|
+
if (!Number.isInteger(resolvedCaps.maxDepth) || resolvedCaps.maxDepth < 1) throw new Error(`relationType("${type}"): maxDepth must be a positive integer`);
|
|
194
|
+
if (!Number.isInteger(resolvedCaps.maxFanout) || resolvedCaps.maxFanout < 1) throw new Error(`relationType("${type}"): maxFanout must be a positive integer`);
|
|
195
|
+
}
|
|
196
|
+
const builder: RelationTypeBuilder = {
|
|
197
|
+
type,
|
|
198
|
+
relations,
|
|
199
|
+
...(resolvedCaps ? { caps: resolvedCaps } : {}),
|
|
200
|
+
__isRelationType: true,
|
|
201
|
+
relation(name: string, opts?: { rewrite?: Rewrite; allow?: AllowedSubject[] }): RelationTypeBuilder {
|
|
202
|
+
requireName(name, "relation");
|
|
203
|
+
if (relations.has(name)) throw new Error(`relationType("${type}"): relation "${name}" declared twice`);
|
|
204
|
+
const allowed = opts?.allow ?? [];
|
|
205
|
+
validateAllowed(type, name, allowed);
|
|
206
|
+
relations.set(name, { rewrite: opts?.rewrite ?? null, allowed });
|
|
207
|
+
return builder;
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
return builder;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─────────────────────────────── DX fast-fail validation (the trust boundary is Rust) ───────────────────────────────
|
|
214
|
+
// These checks are an AUTHOR-TIME convenience that mirror what Rust `ResolvedSchema::resolve` re-checks
|
|
215
|
+
// fail-closed for an untrusted uploaded chain (you cannot trust a pushed repo's compiler). They catch the
|
|
216
|
+
// obvious mistakes early; the Rust resolver is the AUTHORITATIVE gate.
|
|
217
|
+
|
|
218
|
+
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
|
219
|
+
function requireName(s: string, what: string): string {
|
|
220
|
+
if (typeof s !== "string" || !NAME_RE.test(s)) throw new Error(`${what}: invalid name ${JSON.stringify(s)} (expected ${NAME_RE})`);
|
|
221
|
+
return s;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A `key:*` / non-user wildcard is the admin forge — unrepresentable. Usersets must name a type+relation. */
|
|
225
|
+
function validateAllowed(type: string, relation: string, allowed: readonly AllowedSubject[]): void {
|
|
226
|
+
for (const a of allowed) {
|
|
227
|
+
if (a.kind === "direct") requireName(a.type, "allow.direct.type");
|
|
228
|
+
else if (a.kind === "userset") {
|
|
229
|
+
requireName(a.type, "allow.userset.type");
|
|
230
|
+
requireName(a.relation, "allow.userset.relation");
|
|
231
|
+
}
|
|
232
|
+
// wildcard carries no type — it is structurally user:* only (see anyUser()).
|
|
233
|
+
}
|
|
234
|
+
// Reject a duplicate allowed class (byte-stable + author error).
|
|
235
|
+
const seen = new Set<string>();
|
|
236
|
+
for (const a of allowed) {
|
|
237
|
+
const k = a.kind === "direct" ? `direct:${a.type}` : a.kind === "wildcard" ? "wildcard" : `userset:${a.type}#${a.relation}`;
|
|
238
|
+
if (seen.has(k)) throw new Error(`relationType("${type}").relation("${relation}"): duplicate allowed subject ${k}`);
|
|
239
|
+
seen.add(k);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Validate that every relation a rewrite REFERENCES (computed/ttu.computed) exists in the SAME type's
|
|
245
|
+
* relation set, set-ops have ≥2 operands, and there is no same-object computed-userset CYCLE. A
|
|
246
|
+
* DX fast-fail — the Rust resolver is authoritative. Returns the type's relation names for reuse.
|
|
247
|
+
* (TTU `tupleset` references THIS type's relations; `computed` is resolved on the POINTED type at
|
|
248
|
+
* eval time, so cross-type computed targets are NOT checkable here — only the Rust resolver, with the
|
|
249
|
+
* whole schema, can; we check what is locally knowable.)
|
|
250
|
+
*/
|
|
251
|
+
export function validateRelationType(decl: RelationTypeDecl): void {
|
|
252
|
+
const names = new Set(decl.relations.keys());
|
|
253
|
+
// computed-userset cycle detection within this type (a SCHEMA cycle, distinct from a tuple cycle).
|
|
254
|
+
const computedEdges = new Map<string, Set<string>>();
|
|
255
|
+
for (const [rel, def] of decl.relations) {
|
|
256
|
+
const targets = new Set<string>();
|
|
257
|
+
walkRewrite(decl.type, rel, def.rewrite, names, targets);
|
|
258
|
+
computedEdges.set(rel, targets);
|
|
259
|
+
}
|
|
260
|
+
// DFS cycle check over computed_userset edges (same-object).
|
|
261
|
+
const WHITE = 0, GREY = 1, BLACK = 2;
|
|
262
|
+
const color = new Map<string, number>([...names].map((n) => [n, WHITE]));
|
|
263
|
+
const dfs = (n: string): void => {
|
|
264
|
+
color.set(n, GREY);
|
|
265
|
+
for (const t of computedEdges.get(n) ?? []) {
|
|
266
|
+
const c = color.get(t);
|
|
267
|
+
if (c === GREY) throw new Error(`relationType("${decl.type}"): computed_userset cycle through "${n}" → "${t}"`);
|
|
268
|
+
if (c === WHITE) dfs(t);
|
|
269
|
+
}
|
|
270
|
+
color.set(n, BLACK);
|
|
271
|
+
};
|
|
272
|
+
for (const n of names) if (color.get(n) === WHITE) dfs(n);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function walkRewrite(type: string, rel: string, r: Rewrite | null, names: ReadonlySet<string>, computedTargets: Set<string>): void {
|
|
276
|
+
if (r === null) return;
|
|
277
|
+
switch (r.kind) {
|
|
278
|
+
case "this":
|
|
279
|
+
return;
|
|
280
|
+
case "computed":
|
|
281
|
+
if (!names.has(r.relation)) throw new Error(`relationType("${type}").relation("${rel}"): computed("${r.relation}") references an undeclared relation`);
|
|
282
|
+
computedTargets.add(r.relation);
|
|
283
|
+
return;
|
|
284
|
+
case "ttu":
|
|
285
|
+
if (!names.has(r.tupleset)) throw new Error(`relationType("${type}").relation("${rel}"): via("${r.tupleset}", …) references an undeclared tupleset relation`);
|
|
286
|
+
// `computed` is resolved on the POINTED object's type — not checkable locally; the Rust resolver checks it.
|
|
287
|
+
return;
|
|
288
|
+
case "union":
|
|
289
|
+
case "intersection":
|
|
290
|
+
if (r.children.length < 2) throw new Error(`relationType("${type}").relation("${rel}"): ${r.kind} needs ≥2 operands`);
|
|
291
|
+
for (const c of r.children) walkRewrite(type, rel, c, names, computedTargets);
|
|
292
|
+
return;
|
|
293
|
+
case "exclusion":
|
|
294
|
+
walkRewrite(type, rel, r.base, names, computedTargets);
|
|
295
|
+
walkRewrite(type, rel, r.subtract, names, computedTargets);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
2
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
3
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
|
|
4
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* relation_sugar — CONVENIENT, FOOTGUN-PROOF SUGAR over the native Zanzibar relation schema.
|
|
8
|
+
*
|
|
9
|
+
* Layer 2 (`relation_schema.ts`) is powerful but DENSE: `union(self(), computed("owner"),
|
|
10
|
+
* via("parent", "viewer"))` reads like a paper, and it's easy to (a) forget `allow` (the
|
|
11
|
+
* fail-closed type restriction) or (b) reach for the wrong `set()`-vs-`addToSet()`-style primitive.
|
|
12
|
+
* This module is PURE SUGAR: every function here BUILDS the SAME `Rewrite` / `RelationDef` /
|
|
13
|
+
* `RelationTypeDecl` AST the primitives build — NO JavaScript on the schema path (sugar returns
|
|
14
|
+
* DATA, never a closure), NO new wire shape, NO change to the `nomosRelations` emit. The Rust
|
|
15
|
+
* evaluator never knows sugar existed.
|
|
16
|
+
*
|
|
17
|
+
* THREE TIERS, lowest-jargon first:
|
|
18
|
+
*
|
|
19
|
+
* 1. PLAIN-ENGLISH ALIASES (OpenFGA-shaped). The combinators renamed to read like the OpenFGA
|
|
20
|
+
* modelling language `define viewer: [user] or owner or viewer from parent`:
|
|
21
|
+
* or(...) ≡ union(...) but collapses a single operand (no ≥2 ceremony)
|
|
22
|
+
* and(...) ≡ intersection(...)
|
|
23
|
+
* butNot(base, sub) ≡ exclusion(base, sub) ("permitted UNLESS")
|
|
24
|
+
* directly() ≡ self() (the stored tuples)
|
|
25
|
+
* inheritedFrom(rel) ≡ computed(rel) (every `rel` is also this)
|
|
26
|
+
* fromParent(ts, r) ≡ via(ts, r) (the two-hop, named for its #1 use)
|
|
27
|
+
* directly(user(), key()) → { rewrite: this, allow:[...] } — the this+allow PAIR so the
|
|
28
|
+
* type restriction CAN'T be forgotten (the #1 footgun).
|
|
29
|
+
*
|
|
30
|
+
* 2. SECURE-BY-DEFAULT AUDIENCES (the PRIMARY surface; `zanzibar_native_plan.md` F2). Builder
|
|
31
|
+
* methods that lower to a relation (or a small set of relations) with the SAFE default baked in:
|
|
32
|
+
* .public(rel) → rel = directly(anyUser()) (user:* ONLY — never key:*)
|
|
33
|
+
* .owned(rel?) → owner = directly(user()); rel = directly(self) ∪ computed(owner)
|
|
34
|
+
* .readableBy(rel, cap) → rel inherits a control-plane `cap` relation (leaf-only authority)
|
|
35
|
+
*
|
|
36
|
+
* 3. ONE-SHOT PATTERNS:
|
|
37
|
+
* roleHierarchy({ owner: [], editor:["owner"], viewer:["editor"] }) — the single most common
|
|
38
|
+
* model: each role is `directly(user()) ∪ computed(every stronger role)`, expanded to the
|
|
39
|
+
* exact union/computed chain. (`viewer` ⊇ `editor` ⊇ `owner`.)
|
|
40
|
+
* docSharingModel(...) — the canonical Doc/Folder sharing preset (owner/editor/viewer +
|
|
41
|
+
* `parent` inheritance), one call, fully validated.
|
|
42
|
+
*
|
|
43
|
+
* ─────────────────────────────── BEFORE / AFTER ───────────────────────────────
|
|
44
|
+
*
|
|
45
|
+
* (A) "anyone can view" — the public audience:
|
|
46
|
+
* PRIMITIVE relationType("Post").relation("viewer", { rewrite: self(), allow: [anyUser()] })
|
|
47
|
+
* SUGAR relationType("Post").public("viewer")
|
|
48
|
+
*
|
|
49
|
+
* (B) "owner, and the owner can also view/edit" — the owned audience:
|
|
50
|
+
* PRIMITIVE relationType("Note")
|
|
51
|
+
* .relation("owner", { rewrite: self(), allow: [user()] })
|
|
52
|
+
* .relation("reader", { rewrite: union(self(), computed("owner")), allow: [user()] })
|
|
53
|
+
* SUGAR relationType("Note").owned().relation("reader", roles("owner")) // or .owned("reader")
|
|
54
|
+
*
|
|
55
|
+
* (C) the classic role hierarchy (viewer ⊇ editor ⊇ owner):
|
|
56
|
+
* PRIMITIVE relationType("Doc")
|
|
57
|
+
* .relation("owner", { rewrite: self(), allow: [user()] })
|
|
58
|
+
* .relation("editor", { rewrite: union(self(), computed("owner")), allow: [user()] })
|
|
59
|
+
* .relation("viewer", { rewrite: union(self(), computed("editor")), allow: [user()] })
|
|
60
|
+
* SUGAR roleHierarchy("Doc", { owner: [], editor: ["owner"], viewer: ["editor"] })
|
|
61
|
+
*
|
|
62
|
+
* (D) the OpenFGA-shaped rewrite, transliterated:
|
|
63
|
+
* OpenFGA define viewer: [user] or owner or viewer from parent
|
|
64
|
+
* PRIMITIVE .relation("viewer", { rewrite: union(self(), computed("owner"), via("parent","viewer")), allow:[user()] })
|
|
65
|
+
* SUGAR .relation("viewer", { rewrite: or(directly(), inheritedFrom("owner"), fromParent("parent","viewer")), allow:[user()] })
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
import {
|
|
69
|
+
type Rewrite,
|
|
70
|
+
type AllowedSubject,
|
|
71
|
+
type RelationDefAst,
|
|
72
|
+
type RelationTypeBuilder,
|
|
73
|
+
type RelationCaps,
|
|
74
|
+
self,
|
|
75
|
+
computed,
|
|
76
|
+
via,
|
|
77
|
+
viaAll,
|
|
78
|
+
union,
|
|
79
|
+
intersection,
|
|
80
|
+
exclusion,
|
|
81
|
+
user,
|
|
82
|
+
key,
|
|
83
|
+
object,
|
|
84
|
+
anyUser,
|
|
85
|
+
relationType,
|
|
86
|
+
validateRelationType,
|
|
87
|
+
} from "./relation_schema.js";
|
|
88
|
+
|
|
89
|
+
// ─────────────────────────────── tier 1: plain-English combinator aliases ───────────────────────────────
|
|
90
|
+
// Every alias returns the SAME `Rewrite` data the primitive returns. `or`/`and` additionally COLLAPSE a
|
|
91
|
+
// single operand (a normal dev writes `or(a)` and means `a`) so they never throw the ≥2-operand error mid-edit.
|
|
92
|
+
|
|
93
|
+
/** `_this` — the directly-stored tuples on this relation. Alias of {@link self}. */
|
|
94
|
+
export function directly(): Rewrite {
|
|
95
|
+
return self();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* "every `relation` is also this" — a SIBLING relation on the SAME object (relation inheritance).
|
|
100
|
+
* Alias of {@link computed}. Reads like OpenFGA's bare `owner` inside a `viewer` definition.
|
|
101
|
+
*/
|
|
102
|
+
export function inheritedFrom(relation: string): Rewrite {
|
|
103
|
+
return computed(relation);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* "`computed` FROM the object(s) pointed at by `tupleset`" — the two-hop (tuple_to_userset), named for
|
|
108
|
+
* its overwhelming #1 use (`viewer fromParent("parent", "viewer")`). Alias of {@link via} (∪ semantics).
|
|
109
|
+
*/
|
|
110
|
+
export function fromParent(tupleset: string, computedRel: string): Rewrite {
|
|
111
|
+
return via(tupleset, computedRel);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The ∩-semantics two-hop (EVERY found object must grant it). Alias of {@link viaAll}. */
|
|
115
|
+
export function fromEveryParent(tupleset: string, computedRel: string): Rewrite {
|
|
116
|
+
return viaAll(tupleset, computedRel);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `a OR b OR …` — union. Alias of {@link union}, but a SINGLE operand collapses to itself (so an
|
|
121
|
+
* incremental edit `or(directly())` is legal, not a ≥2-operand throw). Zero operands is still an error.
|
|
122
|
+
*/
|
|
123
|
+
export function or(...children: Rewrite[]): Rewrite {
|
|
124
|
+
if (children.length === 0) throw new Error("or(...) needs ≥1 operand");
|
|
125
|
+
if (children.length === 1) return children[0]!;
|
|
126
|
+
return union(...children);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** `a AND b AND …` — intersection. Alias of {@link intersection}; a single operand collapses to itself. */
|
|
130
|
+
export function and(...children: Rewrite[]): Rewrite {
|
|
131
|
+
if (children.length === 0) throw new Error("and(...) needs ≥1 operand");
|
|
132
|
+
if (children.length === 1) return children[0]!;
|
|
133
|
+
return intersection(...children);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `base BUT NOT subtract` — "permitted unless" (separation-of-duties). Alias of {@link exclusion}. */
|
|
137
|
+
export function butNot(base: Rewrite, subtract: Rewrite): Rewrite {
|
|
138
|
+
return exclusion(base, subtract);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─────────────────────────────── tier 1.5: the `directly`+`allow` pairing helpers ───────────────────────────────
|
|
142
|
+
// The #1 footgun is declaring a STORABLE relation and forgetting `allow` (the fail-closed type restriction),
|
|
143
|
+
// leaving an inert relation that stores nothing. These return a complete `{ rewrite, allow }` opts object so
|
|
144
|
+
// the type restriction travels WITH the rewrite — you cannot author the stored half without the allow half.
|
|
145
|
+
|
|
146
|
+
/** opts for `.relation(name, …)`: the rewrite + its fail-closed allowed-subject classes, as one value. */
|
|
147
|
+
export interface RelationOpts {
|
|
148
|
+
rewrite?: Rewrite;
|
|
149
|
+
allow?: AllowedSubject[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* "stores tuples of THESE subject classes" — `{ rewrite: directly(), allow: [...subjects] }`. The pairing
|
|
154
|
+
* that makes the type restriction un-forgettable. `storedFor(user())` is the everyday "users hold this
|
|
155
|
+
* relation directly"; `storedFor(user(), group("team","member"))` adds group membership.
|
|
156
|
+
*/
|
|
157
|
+
export function storedFor(...subjects: AllowedSubject[]): RelationOpts {
|
|
158
|
+
if (subjects.length === 0) throw new Error("storedFor(...) needs ≥1 subject class");
|
|
159
|
+
return { rewrite: directly(), allow: [...subjects] };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* "this role plus every STRONGER role" — `directly(user()) ∪ computed(each stronger role)`. The everyday
|
|
164
|
+
* shape inside a hierarchy: `roles("editor", "owner")` on `viewer` = a viewer is anyone directly granted
|
|
165
|
+
* viewer, OR an editor, OR an owner. With no stronger roles it's just `storedFor(user())`.
|
|
166
|
+
*/
|
|
167
|
+
export function roles(...strongerRoles: string[]): RelationOpts {
|
|
168
|
+
const rewrite = strongerRoles.length === 0 ? directly() : or(directly(), ...strongerRoles.map((r) => computed(r)));
|
|
169
|
+
return { rewrite, allow: [user()] };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─────────────────────────────── tier 2: secure-by-default audiences (builder methods) ───────────────────────────────
|
|
173
|
+
// These wrap a `RelationTypeBuilder` and ADD relations with the SAFE default baked in. They return the
|
|
174
|
+
// SAME builder (chainable). Because they delegate to `.relation(...)`, every relation they create is
|
|
175
|
+
// validated by the existing `validateAllowed` (the wildcard stays user-only; no key:* can be authored).
|
|
176
|
+
|
|
177
|
+
/** The fluent builder, widened with the secure-by-default audience methods. */
|
|
178
|
+
export interface SugaredRelationTypeBuilder extends RelationTypeBuilder {
|
|
179
|
+
/** Declare one relation (as the raw builder), but keep the audience methods on the returned chain. */
|
|
180
|
+
relation(name: string, opts?: { rewrite?: Rewrite; allow?: AllowedSubject[] }): SugaredRelationTypeBuilder;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* "ANYONE can hold `relation`" — the PUBLIC audience: `{ rewrite: directly(), allow: [anyUser()] }`.
|
|
184
|
+
* The wildcard is `user:*` ONLY (structurally — `anyUser()` has no type param), so this can NEVER open
|
|
185
|
+
* a `key:*` forge. Default relation name is `viewer`.
|
|
186
|
+
*/
|
|
187
|
+
public(relation?: string): SugaredRelationTypeBuilder;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The OWNED audience: an `owner` relation (`directly(user())`) PLUS `relation` (default `viewer`) that
|
|
191
|
+
* the owner automatically holds (`directly(user()) ∪ computed("owner")`). The everyday "I made it, so I
|
|
192
|
+
* can read it, and I can grant others." Pass `{ owner }` to rename the owner relation; pass a string for
|
|
193
|
+
* the downstream relation name.
|
|
194
|
+
*/
|
|
195
|
+
owned(relation?: string, opts?: { owner?: string }): SugaredRelationTypeBuilder;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* "`relation` is readable by anyone holding the control-plane `cap`" — adds a LEAF-ONLY `cap` relation
|
|
199
|
+
* (`allow(user(), key())` — never a userset/wildcard, the control-plane carve-out) and `relation`
|
|
200
|
+
* (default `reader`) that inherits it (`directly(user()) ∪ computed(cap)`). The cap is the authority
|
|
201
|
+
* grant; the relation is the audience that inherits it.
|
|
202
|
+
*/
|
|
203
|
+
readableBy(cap: string, relation?: string): SugaredRelationTypeBuilder;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Wrap a `relationType(...)` builder so the secure-by-default audience methods (`.public`/`.owned`/
|
|
208
|
+
* `.readableBy`) are available alongside the raw `.relation(...)`. Calling any audience method returns
|
|
209
|
+
* the SAME sugared builder (chainable). NOTE: the underlying `.relation(...)` already returns the raw
|
|
210
|
+
* builder; we re-wrap on every chain step so the audience methods survive interleaving with `.relation`.
|
|
211
|
+
*/
|
|
212
|
+
export function withAudiences(builder: RelationTypeBuilder): SugaredRelationTypeBuilder {
|
|
213
|
+
// The builder object is mutated in place by `.relation(...)` (it returns itself), so we can safely
|
|
214
|
+
// attach methods that delegate to it and re-wrap. We attach to a fresh proxy-like object that forwards
|
|
215
|
+
// the decl fields by reference (the Map is shared, so reads stay live).
|
|
216
|
+
const sugared = Object.create(builder) as SugaredRelationTypeBuilder;
|
|
217
|
+
|
|
218
|
+
sugared.relation = function (name, opts) {
|
|
219
|
+
builder.relation(name, opts);
|
|
220
|
+
return sugared; // keep the audience methods on the chain
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
sugared.public = function (relation = "viewer") {
|
|
224
|
+
builder.relation(relation, { rewrite: directly(), allow: [anyUser()] });
|
|
225
|
+
return sugared;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
sugared.owned = function (relation = "viewer", opts) {
|
|
229
|
+
const ownerRel = opts?.owner ?? "owner";
|
|
230
|
+
if (!builder.relations.has(ownerRel)) {
|
|
231
|
+
builder.relation(ownerRel, { rewrite: directly(), allow: [user()] });
|
|
232
|
+
}
|
|
233
|
+
builder.relation(relation, { rewrite: or(directly(), computed(ownerRel)), allow: [user()] });
|
|
234
|
+
return sugared;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
sugared.readableBy = function (cap, relation = "reader") {
|
|
238
|
+
if (!builder.relations.has(cap)) {
|
|
239
|
+
// control-plane authority: leaf user+key ONLY — never a userset/wildcard (the carve-out).
|
|
240
|
+
builder.relation(cap, { rewrite: directly(), allow: [user(), key()] });
|
|
241
|
+
}
|
|
242
|
+
builder.relation(relation, { rewrite: or(directly(), computed(cap)), allow: [user()] });
|
|
243
|
+
return sugared;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
return sugared;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* `relationType(...)` with the secure-by-default audience methods pre-attached — the one-call entry for
|
|
251
|
+
* the average dev. Identical to `withAudiences(relationType(type, caps))`.
|
|
252
|
+
*/
|
|
253
|
+
export function secureType(type: string, caps?: Partial<RelationCaps>): SugaredRelationTypeBuilder {
|
|
254
|
+
return withAudiences(relationType(type, caps));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ─────────────────────────────── tier 3: one-shot patterns ───────────────────────────────
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The single most common model: a ROLE HIERARCHY. Each role lists the STRONGER roles that automatically
|
|
261
|
+
* grant it; the expander lowers each to `directly(user()) ∪ computed(each stronger role)`. Strength flows
|
|
262
|
+
* UPWARD — `{ owner: [], editor: ["owner"], viewer: ["editor"] }` means every owner is an editor and every
|
|
263
|
+
* editor (hence every owner) is a viewer.
|
|
264
|
+
*
|
|
265
|
+
* Validated end-to-end: the generated schema is run through `validateRelationType`, so a typo in a stronger
|
|
266
|
+
* role (an undeclared relation) or a cycle fails AT AUTHOR TIME with the same error the primitives give.
|
|
267
|
+
*
|
|
268
|
+
* @param type the object type (the Zanzibar namespace).
|
|
269
|
+
* @param spec role → the stronger roles that grant it. Insertion order is irrelevant.
|
|
270
|
+
* @param caps optional per-type depth/fanout override.
|
|
271
|
+
*/
|
|
272
|
+
export function roleHierarchy(
|
|
273
|
+
type: string,
|
|
274
|
+
spec: Record<string, readonly string[]>,
|
|
275
|
+
caps?: Partial<RelationCaps>,
|
|
276
|
+
): SugaredRelationTypeBuilder {
|
|
277
|
+
const builder = withAudiences(relationType(type, caps));
|
|
278
|
+
const roleNames = Object.keys(spec);
|
|
279
|
+
// Validate references up front for a crisp message (validateRelationType would also catch it).
|
|
280
|
+
for (const [role, stronger] of Object.entries(spec)) {
|
|
281
|
+
for (const s of stronger) {
|
|
282
|
+
if (!(s in spec)) {
|
|
283
|
+
throw new Error(`roleHierarchy("${type}"): role "${role}" inherits from "${s}", which is not a declared role`);
|
|
284
|
+
}
|
|
285
|
+
if (s === role) {
|
|
286
|
+
throw new Error(`roleHierarchy("${type}"): role "${role}" cannot inherit from itself`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Declare in stable (sorted) order — the compiler re-sorts anyway, but deterministic author order is nice.
|
|
291
|
+
for (const role of [...roleNames].sort()) {
|
|
292
|
+
builder.relation(role, roles(...spec[role]!));
|
|
293
|
+
}
|
|
294
|
+
validateRelationType(builder);
|
|
295
|
+
return builder;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* THE CANONICAL DOC/FOLDER SHARING PRESET — the model 90% of apps actually want, in one call:
|
|
300
|
+
* - `owner` (directly user) — the role hierarchy root.
|
|
301
|
+
* - `editor` = directly ∪ owner ∪ (editor from parent).
|
|
302
|
+
* - `viewer` = directly ∪ editor ∪ (viewer from parent).
|
|
303
|
+
* - `parent` — the tupleset pointer relation (stores `<type>:<id>@<parentType>:<id>`).
|
|
304
|
+
* So a folder's viewers are inherited by every doc inside it, and the role hierarchy holds within a doc.
|
|
305
|
+
*
|
|
306
|
+
* @param type the document object type (e.g. "Doc").
|
|
307
|
+
* @param parentType the parent object type the `parent` relation points at (e.g. "Folder"). Defaults to
|
|
308
|
+
* `type` itself (a folder-in-folder tree). Pass it explicitly for Doc-in-Folder.
|
|
309
|
+
* @param caps optional per-type cap override (deep folder trees may want a higher maxDepth).
|
|
310
|
+
*/
|
|
311
|
+
export function docSharingModel(
|
|
312
|
+
type: string,
|
|
313
|
+
parentType: string = type,
|
|
314
|
+
caps?: Partial<RelationCaps>,
|
|
315
|
+
): SugaredRelationTypeBuilder {
|
|
316
|
+
const builder = withAudiences(relationType(type, caps));
|
|
317
|
+
builder
|
|
318
|
+
.relation("parent", { allow: [object(parentType)] })
|
|
319
|
+
.relation("owner", { rewrite: directly(), allow: [user()] })
|
|
320
|
+
.relation("editor", { rewrite: or(directly(), computed("owner"), fromParent("parent", "editor")), allow: [user()] })
|
|
321
|
+
.relation("viewer", { rewrite: or(directly(), computed("editor"), fromParent("parent", "viewer")), allow: [user()] });
|
|
322
|
+
validateRelationType(builder);
|
|
323
|
+
return builder;
|
|
324
|
+
}
|