@north-light/sdk 0.3.548 → 0.3.550
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/dist/client.d.ts +10 -2
- package/dist/client.js +13 -1
- package/dist/index.d.ts +3 -1
- package/dist/resources/docs.d.ts +19 -3
- package/dist/resources/docs.js +15 -0
- package/dist/resources/memory.d.ts +4 -4
- package/dist/resources/memory.js +2 -2
- package/dist/resources/relations.d.ts +23 -0
- package/dist/resources/relations.js +21 -0
- package/dist/resources/schemas.d.ts +14 -0
- package/dist/resources/schemas.js +13 -0
- package/dist/skill/northlight/SKILL.md +2 -2
- package/dist/testing/cloud.d.ts +4 -3
- package/dist/testing/cloud.js +20 -4
- package/dist/testing/permissions.d.ts +37 -0
- package/dist/testing/permissions.js +261 -0
- package/dist/testing/schema-parser.d.ts +29 -0
- package/dist/testing/schema-parser.js +153 -0
- package/dist/testing/space.d.ts +42 -0
- package/dist/testing/space.js +265 -0
- package/dist/testing/users.d.ts +4 -1
- package/dist/testing/users.js +4 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -5
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// The permission semantics FakeCloud's space shares with the daemon (`src/core/permissions`): the built-in definitions, schema validation, fact
|
|
2
|
+
// validation and an in-memory evaluator over the same parsed schemas. The daemon compiles one permission to SQL (`compile.ts`); this walks the
|
|
3
|
+
// same expressions over facts in memory, so `sdk-fake-conformance.test.ts` can hold both to one set of cases. Refusals carry the daemon's codes.
|
|
4
|
+
import { parseSchema, SchemaParseError } from './schema-parser.js';
|
|
5
|
+
import { refuse } from './wire.js';
|
|
6
|
+
export const BUILTIN_SOURCE = `
|
|
7
|
+
definition user {}
|
|
8
|
+
definition app {}
|
|
9
|
+
definition crtr/group {
|
|
10
|
+
relation member: user | crtr/group#member
|
|
11
|
+
}
|
|
12
|
+
definition crtr/object {
|
|
13
|
+
relation parent: crtr/object
|
|
14
|
+
relation acts_for: user
|
|
15
|
+
relation reader: user | user:* | crtr/group#member
|
|
16
|
+
relation editor: user | crtr/group#member
|
|
17
|
+
relation messenger: user | crtr/group#member
|
|
18
|
+
relation user_of: user | crtr/group#member
|
|
19
|
+
permission read = reader + editor + messenger + user_of + acts_for + parent->read
|
|
20
|
+
permission edit = editor + acts_for + parent->edit
|
|
21
|
+
permission message = messenger + acts_for + parent->message
|
|
22
|
+
permission use = user_of + acts_for + parent->use
|
|
23
|
+
permission share = acts_for + parent->share
|
|
24
|
+
}
|
|
25
|
+
`;
|
|
26
|
+
const BUILTIN_DEFINITIONS = parseSchema(BUILTIN_SOURCE);
|
|
27
|
+
export const OBJECT_TYPE = 'crtr/object';
|
|
28
|
+
const NAMESPACE = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
29
|
+
const MAX_DEPTH = 8;
|
|
30
|
+
const has = (record, key) => Object.hasOwn(record, key);
|
|
31
|
+
export const formatSubject = (s) => `${s.type}:${s.id}${s.relation === '' ? '' : `#${s.relation}`}`;
|
|
32
|
+
export const factKey = (f) => `${f.resourceType}\u0000${f.resourceId}\u0000${f.relation}\u0000${formatSubject(f.subject)}`;
|
|
33
|
+
/** `<type>:<id>[#<relation>]`; the type may contain `/`. */
|
|
34
|
+
export function parseSubject(text) {
|
|
35
|
+
const m = /^([A-Za-z][A-Za-z0-9_/]*):([^#\s]+)(?:#([A-Za-z_][A-Za-z0-9_]*))?$/.exec(text);
|
|
36
|
+
if (m === null)
|
|
37
|
+
throw refuse(400, 'subject_invalid', `${JSON.stringify(text)} is not a subject (<type>:<id>[#<relation>])`);
|
|
38
|
+
return { type: m[1], id: m[2], relation: m[3] ?? '' };
|
|
39
|
+
}
|
|
40
|
+
/** `<type>:<id>` in an app namespace, or a bare crouter object id. */
|
|
41
|
+
export function parseResource(text) {
|
|
42
|
+
const m = /^([A-Za-z][A-Za-z0-9_/]*):(\S+)$/.exec(text);
|
|
43
|
+
return m === null ? { type: OBJECT_TYPE, id: text } : { type: m[1], id: m[2] };
|
|
44
|
+
}
|
|
45
|
+
export const builtinDefinitions = () => new Map(BUILTIN_DEFINITIONS.map((d) => [d.name, d]));
|
|
46
|
+
/** The top-level union terms of permission `name` and, among them, its self-arrows: `x->name` with `x` a relation that can point back at `def`.
|
|
47
|
+
* Those are the one recursion the compiler evaluates exactly, as an ancestor closure at any depth. */
|
|
48
|
+
function selfArrows(def, name, expr) {
|
|
49
|
+
const terms = [];
|
|
50
|
+
const flatten = (e) => { if (e.op === 'union') {
|
|
51
|
+
flatten(e.l);
|
|
52
|
+
flatten(e.r);
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
terms.push(e); };
|
|
56
|
+
flatten(expr);
|
|
57
|
+
const selfs = terms.filter((t) => t.op === 'arrow' && t.right === name && (def.relations[t.left]?.some((s) => s.type === def.name && s.relation === undefined) ?? false));
|
|
58
|
+
return { terms, selfs };
|
|
59
|
+
}
|
|
60
|
+
/** Refuse a schema whose evaluation would recurse past the closure: a permission that reaches itself other than through top-level self-arrows
|
|
61
|
+
* (inside `&`/`-`, through another permission, or through a `type#relation` subject). Such a rule would be cut off at MAX_DEPTH, so its meaning
|
|
62
|
+
* would depend on a hidden depth. `defs` is every definition the schema can reach (the built-ins and the new ones); the error names the line. */
|
|
63
|
+
function assertNoUnsupportedRecursion(defs, own) {
|
|
64
|
+
const byName = new Map(defs.map((d) => [d.name, d]));
|
|
65
|
+
const key = (t, n) => `${t}\u0000${n}`;
|
|
66
|
+
const edges = (def, name) => {
|
|
67
|
+
const out = [];
|
|
68
|
+
if (name in def.relations) {
|
|
69
|
+
for (const s of def.relations[name])
|
|
70
|
+
if (s.relation !== undefined && s.type !== 'user' && !(s.type === 'crtr/group' && s.relation === 'member'))
|
|
71
|
+
out.push([s.type, s.relation]);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
const expr = def.permissions[name];
|
|
75
|
+
if (expr === undefined)
|
|
76
|
+
return out;
|
|
77
|
+
const { selfs } = selfArrows(def, name, expr);
|
|
78
|
+
const walk = (e) => {
|
|
79
|
+
if (e.op === 'ref')
|
|
80
|
+
out.push([def.name, e.name]);
|
|
81
|
+
else if (e.op === 'arrow') {
|
|
82
|
+
const handled = selfs.includes(e);
|
|
83
|
+
for (const s of def.relations[e.left] ?? []) {
|
|
84
|
+
if (s.type === 'user' || s.relation !== undefined || (handled && s.type === def.name))
|
|
85
|
+
continue;
|
|
86
|
+
const target = byName.get(s.type);
|
|
87
|
+
if (target !== undefined && (e.right in target.permissions || e.right in target.relations))
|
|
88
|
+
out.push([s.type, e.right]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
walk(e.l);
|
|
93
|
+
walk(e.r);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
walk(expr);
|
|
97
|
+
return out;
|
|
98
|
+
};
|
|
99
|
+
for (const def of own) {
|
|
100
|
+
for (const name of [...Object.keys(def.relations), ...Object.keys(def.permissions)]) {
|
|
101
|
+
const seen = new Set();
|
|
102
|
+
const stack = edges(def, name);
|
|
103
|
+
while (stack.length > 0) {
|
|
104
|
+
const [t, n] = stack.pop();
|
|
105
|
+
if (t === def.name && n === name)
|
|
106
|
+
throw new SchemaParseError(def.lines?.[name] ?? 1, `${def.name}#${name} reaches itself; only top-level \`relation->${name}\` terms of a permission may recurse`);
|
|
107
|
+
if (seen.has(key(t, n)))
|
|
108
|
+
continue;
|
|
109
|
+
seen.add(key(t, n));
|
|
110
|
+
const d = byName.get(t);
|
|
111
|
+
if (d !== undefined)
|
|
112
|
+
stack.push(...edges(d, n));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Parse `source` as the schema of `namespace`: every definition must live in the namespace and every subject type must be declared. */
|
|
118
|
+
export function validateSchema(namespace, source) {
|
|
119
|
+
if (!NAMESPACE.test(namespace) || namespace === 'crtr')
|
|
120
|
+
throw refuse(403, 'namespace_taken', `namespace ${JSON.stringify(namespace)} is reserved or malformed`);
|
|
121
|
+
let defs;
|
|
122
|
+
try {
|
|
123
|
+
defs = parseSchema(source);
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
if (e instanceof SchemaParseError)
|
|
127
|
+
throw refuse(400, 'schema_invalid', e.message, { details: { line: e.line, message: e.message } });
|
|
128
|
+
throw e;
|
|
129
|
+
}
|
|
130
|
+
const all = [...BUILTIN_DEFINITIONS, ...defs];
|
|
131
|
+
try {
|
|
132
|
+
assertNoUnsupportedRecursion(all, defs);
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
if (e instanceof SchemaParseError)
|
|
136
|
+
throw refuse(400, 'schema_invalid', e.message, { details: { line: e.line, message: e.message } });
|
|
137
|
+
throw e;
|
|
138
|
+
}
|
|
139
|
+
const known = new Set(all.map((d) => d.name));
|
|
140
|
+
const invalidSchema = (message, detail) => refuse(400, 'schema_invalid', message, { details: { line: 1, message: detail } });
|
|
141
|
+
for (const d of defs) {
|
|
142
|
+
if (!d.name.startsWith(`${namespace}/`))
|
|
143
|
+
throw invalidSchema(`definition ${d.name} must be named ${namespace}/<name>`, `definition ${d.name} must be named ${namespace}/<name>`);
|
|
144
|
+
for (const [rel, subjects] of Object.entries(d.relations))
|
|
145
|
+
for (const s of subjects) {
|
|
146
|
+
if (!known.has(s.type))
|
|
147
|
+
throw invalidSchema(`${d.name}#${rel}: unknown subject type ${s.type}`, `unknown subject type ${s.type}`);
|
|
148
|
+
if (s.relation !== undefined) {
|
|
149
|
+
const target = all.find((x) => x.name === s.type);
|
|
150
|
+
if (!(s.relation in target.relations) && !(s.relation in target.permissions))
|
|
151
|
+
throw invalidSchema(`${d.name}#${rel}: ${s.type} has no ${s.relation}`, `${s.type} has no ${s.relation}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return defs;
|
|
156
|
+
}
|
|
157
|
+
/** The relations `previous` declares that `next` drops: `{ name: '<definition>#<relation>' }` for each. */
|
|
158
|
+
export function droppedRelations(previous, next) {
|
|
159
|
+
const kept = new Map(next.map((d) => [d.name, d]));
|
|
160
|
+
return previous.flatMap((old) => Object.keys(old.relations).filter((rel) => kept.get(old.name)?.relations[rel] === undefined).map((relation) => ({ definition: old.name, relation })));
|
|
161
|
+
}
|
|
162
|
+
/** A fact must name a defined type, a relation that type declares, and a subject the relation lists (so `editor @ user:*` fails like any unlisted subject). */
|
|
163
|
+
export function validateFact(defs, fact) {
|
|
164
|
+
const def = defs.get(fact.resourceType);
|
|
165
|
+
if (def === undefined)
|
|
166
|
+
throw refuse(400, 'unknown_type', `no schema defines ${fact.resourceType}`);
|
|
167
|
+
if (fact.resourceType === OBJECT_TYPE && fact.relation === 'parent')
|
|
168
|
+
throw refuse(400, 'unknown_relation', 'parent is the object owner, never stored');
|
|
169
|
+
const listed = has(def.relations, fact.relation) ? def.relations[fact.relation] : undefined;
|
|
170
|
+
if (listed === undefined)
|
|
171
|
+
throw refuse(400, 'unknown_relation', `${fact.resourceType} has no relation ${fact.relation}`, { details: { relation: fact.relation } });
|
|
172
|
+
const s = fact.subject;
|
|
173
|
+
const ok = listed.some((t) => t.type === s.type && (s.id === '*' ? t.wildcard === true : s.relation === '' ? t.relation === undefined && t.wildcard !== true : t.relation === s.relation));
|
|
174
|
+
if (!ok)
|
|
175
|
+
throw refuse(400, 'subject_not_allowed', `${fact.resourceType}#${fact.relation} does not accept ${formatSubject(s)}`, { details: { relation: fact.relation, subject: formatSubject(s) } });
|
|
176
|
+
}
|
|
177
|
+
/** Does `userId` hold `permission` on the resource? An unknown type or permission is false, as in the daemon. A person reaches no `crtr/group`:
|
|
178
|
+
* an app cannot write group facts, so no group is ever reached here. */
|
|
179
|
+
export function holds(defs, facts, userId, resourceType, resourceId, permission) {
|
|
180
|
+
return new Evaluator(defs, [...facts], [userId, '*']).permission(resourceType, permission, resourceId);
|
|
181
|
+
}
|
|
182
|
+
class Evaluator {
|
|
183
|
+
defs;
|
|
184
|
+
facts;
|
|
185
|
+
userIds;
|
|
186
|
+
constructor(defs, facts, userIds) {
|
|
187
|
+
this.defs = defs;
|
|
188
|
+
this.facts = facts;
|
|
189
|
+
this.userIds = userIds;
|
|
190
|
+
}
|
|
191
|
+
subjectsOf(type, id, relation) {
|
|
192
|
+
return this.facts.filter((f) => f.resourceType === type && f.resourceId === id && f.relation === relation).map((f) => f.subject);
|
|
193
|
+
}
|
|
194
|
+
permission(typeName, name, id, depth = 0) {
|
|
195
|
+
const def = this.defs.get(typeName);
|
|
196
|
+
if (def === undefined || depth > MAX_DEPTH)
|
|
197
|
+
return false;
|
|
198
|
+
if (has(def.relations, name))
|
|
199
|
+
return this.relation(def, name, id, depth);
|
|
200
|
+
const expr = has(def.permissions, name) ? def.permissions[name] : undefined;
|
|
201
|
+
if (expr === undefined)
|
|
202
|
+
return false;
|
|
203
|
+
return this.closure(def, name, expr, id, depth) ?? this.expr(def, expr, id, depth);
|
|
204
|
+
}
|
|
205
|
+
/** `x->name` among the top-level union terms of `name` itself, with `x` pointing back at `def`: the other terms hold at some ancestor, however far up. */
|
|
206
|
+
closure(def, name, expr, id, depth) {
|
|
207
|
+
const terms = [];
|
|
208
|
+
const flatten = (e) => { if (e.op === 'union') {
|
|
209
|
+
flatten(e.l);
|
|
210
|
+
flatten(e.r);
|
|
211
|
+
}
|
|
212
|
+
else
|
|
213
|
+
terms.push(e); };
|
|
214
|
+
flatten(expr);
|
|
215
|
+
const self = terms.find((t) => t.op === 'arrow' && t.right === name && def.relations[t.left]?.some((s) => s.type === def.name && s.relation === undefined));
|
|
216
|
+
if (self === undefined || self.op !== 'arrow')
|
|
217
|
+
return null;
|
|
218
|
+
const local = terms.filter((t) => t !== self);
|
|
219
|
+
const seen = new Set();
|
|
220
|
+
for (let queue = [id]; queue.length > 0;) {
|
|
221
|
+
const at = queue.pop();
|
|
222
|
+
if (seen.has(at))
|
|
223
|
+
continue;
|
|
224
|
+
seen.add(at);
|
|
225
|
+
if (local.some((t) => this.expr(def, t, at, depth)))
|
|
226
|
+
return true;
|
|
227
|
+
queue = [...queue, ...this.subjectsOf(def.name, at, self.left).map((s) => s.id)];
|
|
228
|
+
}
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
expr(def, e, id, depth) {
|
|
232
|
+
switch (e.op) {
|
|
233
|
+
case 'ref': return this.permission(def.name, e.name, id, depth + 1);
|
|
234
|
+
case 'arrow': return this.arrow(def, e.left, e.right, id, depth);
|
|
235
|
+
case 'union': return this.expr(def, e.l, id, depth) || this.expr(def, e.r, id, depth);
|
|
236
|
+
case 'inter': return this.expr(def, e.l, id, depth) && this.expr(def, e.r, id, depth);
|
|
237
|
+
case 'excl': return this.expr(def, e.l, id, depth) && !this.expr(def, e.r, id, depth);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
relation(def, name, id, depth) {
|
|
241
|
+
const facts = this.subjectsOf(def.name, id, name);
|
|
242
|
+
return def.relations[name].some((s) => facts.some((f) => {
|
|
243
|
+
if (s.type === 'user')
|
|
244
|
+
return s.relation === undefined && f.type === 'user' && this.userIds.includes(f.id);
|
|
245
|
+
if (s.relation === undefined || s.type === 'crtr/group')
|
|
246
|
+
return false; // a plain object subject is reached only through an arrow
|
|
247
|
+
return f.type === s.type && f.relation === s.relation && this.permission(s.type, s.relation, f.id, depth + 1);
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
arrow(def, left, right, id, depth) {
|
|
251
|
+
const facts = this.subjectsOf(def.name, id, left);
|
|
252
|
+
return (def.relations[left] ?? []).some((s) => {
|
|
253
|
+
if (s.type === 'user' || s.relation !== undefined)
|
|
254
|
+
return false;
|
|
255
|
+
const target = this.defs.get(s.type);
|
|
256
|
+
if (target === undefined || !(right in target.permissions || right in target.relations))
|
|
257
|
+
return false;
|
|
258
|
+
return facts.some((f) => f.type === s.type && this.permission(s.type, right, f.id, depth + 1));
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface SubjectType {
|
|
2
|
+
type: string;
|
|
3
|
+
relation?: string;
|
|
4
|
+
wildcard?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export type Expr = {
|
|
7
|
+
op: 'ref';
|
|
8
|
+
name: string;
|
|
9
|
+
} | {
|
|
10
|
+
op: 'arrow';
|
|
11
|
+
left: string;
|
|
12
|
+
right: string;
|
|
13
|
+
} | {
|
|
14
|
+
op: 'union' | 'inter' | 'excl';
|
|
15
|
+
l: Expr;
|
|
16
|
+
r: Expr;
|
|
17
|
+
};
|
|
18
|
+
export interface Definition {
|
|
19
|
+
name: string;
|
|
20
|
+
relations: Record<string, SubjectType[]>;
|
|
21
|
+
permissions: Record<string, Expr>;
|
|
22
|
+
/** Source line of each relation and permission name (set by `parseSchema`; absent in a definition stored by an older build). */
|
|
23
|
+
lines?: Record<string, number>;
|
|
24
|
+
}
|
|
25
|
+
export declare class SchemaParseError extends Error {
|
|
26
|
+
readonly line: number;
|
|
27
|
+
constructor(line: number, message: string);
|
|
28
|
+
}
|
|
29
|
+
export declare function parseSchema(source: string): Definition[];
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// A copy of the daemon's `src/core/permissions/schema-parser.ts`: the published SDK cannot import daemon source, and FakeCloud must
|
|
2
|
+
// accept and refuse exactly the schemas the real API does. `sdk-fake-conformance.test.ts` runs the same schema cases against both.
|
|
3
|
+
// Parser for the SpiceDB schema subset the permission model uses: `definition`, `relation` (subject types, `type:*`, `type#rel`),
|
|
4
|
+
// `permission` with `+` union, `&` intersection, `-` exclusion, `->` arrow, parentheses. Anything else is a SchemaParseError with the line.
|
|
5
|
+
export class SchemaParseError extends Error {
|
|
6
|
+
line;
|
|
7
|
+
constructor(line, message) {
|
|
8
|
+
super(`line ${line}: ${message}`);
|
|
9
|
+
this.line = line;
|
|
10
|
+
this.name = 'SchemaParseError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function tokenize(source) {
|
|
14
|
+
const tokens = [];
|
|
15
|
+
const lines = source.split('\n');
|
|
16
|
+
const re = /\s*(->|[{}():|+&\-=#*]|[A-Za-z_][A-Za-z0-9_/]*)/y;
|
|
17
|
+
for (let i = 0; i < lines.length; i++) {
|
|
18
|
+
const line = lines[i].replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, '');
|
|
19
|
+
re.lastIndex = 0;
|
|
20
|
+
let pos = 0;
|
|
21
|
+
while (pos < line.length) {
|
|
22
|
+
if (/^\s*$/.test(line.slice(pos)))
|
|
23
|
+
break;
|
|
24
|
+
re.lastIndex = pos;
|
|
25
|
+
const m = re.exec(line);
|
|
26
|
+
if (m === null)
|
|
27
|
+
throw new SchemaParseError(i + 1, `unexpected character ${JSON.stringify(line.slice(pos).trim()[0])}`);
|
|
28
|
+
tokens.push({ text: m[1], line: i + 1 });
|
|
29
|
+
pos = re.lastIndex;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return tokens;
|
|
33
|
+
}
|
|
34
|
+
const IDENT = /^[A-Za-z_][A-Za-z0-9_/]*$/;
|
|
35
|
+
export function parseSchema(source) {
|
|
36
|
+
const tokens = tokenize(source);
|
|
37
|
+
let i = 0;
|
|
38
|
+
const lastLine = () => tokens[Math.min(i, tokens.length - 1)]?.line ?? 1;
|
|
39
|
+
const peek = () => tokens[i]?.text;
|
|
40
|
+
const next = () => {
|
|
41
|
+
const t = tokens[i++];
|
|
42
|
+
if (t === undefined)
|
|
43
|
+
throw new SchemaParseError(lastLine(), 'unexpected end of schema');
|
|
44
|
+
return t;
|
|
45
|
+
};
|
|
46
|
+
const expect = (text) => {
|
|
47
|
+
const t = next();
|
|
48
|
+
if (t.text !== text)
|
|
49
|
+
throw new SchemaParseError(t.line, `expected "${text}", found "${t.text}"`);
|
|
50
|
+
};
|
|
51
|
+
const ident = (what) => {
|
|
52
|
+
const t = next();
|
|
53
|
+
if (!IDENT.test(t.text))
|
|
54
|
+
throw new SchemaParseError(t.line, `expected ${what}, found "${t.text}"`);
|
|
55
|
+
return t.text;
|
|
56
|
+
};
|
|
57
|
+
function primary() {
|
|
58
|
+
if (peek() === '(') {
|
|
59
|
+
next();
|
|
60
|
+
const e = expression();
|
|
61
|
+
expect(')');
|
|
62
|
+
return e;
|
|
63
|
+
}
|
|
64
|
+
const name = ident('a relation or permission name');
|
|
65
|
+
if (peek() === '->') {
|
|
66
|
+
next();
|
|
67
|
+
return { op: 'arrow', left: name, right: ident('a permission name') };
|
|
68
|
+
}
|
|
69
|
+
return { op: 'ref', name };
|
|
70
|
+
}
|
|
71
|
+
function expression() {
|
|
72
|
+
let left = primary();
|
|
73
|
+
for (;;) {
|
|
74
|
+
const op = peek();
|
|
75
|
+
const kind = op === '+' ? 'union' : op === '&' ? 'inter' : op === '-' ? 'excl' : null;
|
|
76
|
+
if (kind === null)
|
|
77
|
+
return left;
|
|
78
|
+
next();
|
|
79
|
+
left = { op: kind, l: left, r: primary() };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const defs = [];
|
|
83
|
+
while (i < tokens.length) {
|
|
84
|
+
const head = next();
|
|
85
|
+
if (head.text !== 'definition')
|
|
86
|
+
throw new SchemaParseError(head.line, `expected "definition", found "${head.text}"`);
|
|
87
|
+
const def = { name: ident('a definition name'), relations: {}, permissions: {}, lines: {} };
|
|
88
|
+
if (defs.some((d) => d.name === def.name))
|
|
89
|
+
throw new SchemaParseError(head.line, `definition ${def.name} is declared twice`);
|
|
90
|
+
expect('{');
|
|
91
|
+
while (peek() !== '}') {
|
|
92
|
+
const t = next();
|
|
93
|
+
if (t.text === 'relation') {
|
|
94
|
+
const name = ident('a relation name');
|
|
95
|
+
if (name in def.relations || name in def.permissions)
|
|
96
|
+
throw new SchemaParseError(t.line, `${name} is declared twice in ${def.name}`);
|
|
97
|
+
expect(':');
|
|
98
|
+
const subjects = [];
|
|
99
|
+
for (;;) {
|
|
100
|
+
const type = ident('a subject type');
|
|
101
|
+
if (peek() === ':') {
|
|
102
|
+
next();
|
|
103
|
+
expect('*');
|
|
104
|
+
subjects.push({ type, wildcard: true });
|
|
105
|
+
}
|
|
106
|
+
else if (peek() === '#') {
|
|
107
|
+
next();
|
|
108
|
+
subjects.push({ type, relation: ident('a relation name') });
|
|
109
|
+
}
|
|
110
|
+
else
|
|
111
|
+
subjects.push({ type });
|
|
112
|
+
if (peek() !== '|')
|
|
113
|
+
break;
|
|
114
|
+
next();
|
|
115
|
+
}
|
|
116
|
+
def.relations[name] = subjects;
|
|
117
|
+
def.lines[name] = t.line;
|
|
118
|
+
}
|
|
119
|
+
else if (t.text === 'permission') {
|
|
120
|
+
const name = ident('a permission name');
|
|
121
|
+
if (name in def.relations || name in def.permissions)
|
|
122
|
+
throw new SchemaParseError(t.line, `${name} is declared twice in ${def.name}`);
|
|
123
|
+
expect('=');
|
|
124
|
+
def.permissions[name] = expression();
|
|
125
|
+
def.lines[name] = t.line;
|
|
126
|
+
}
|
|
127
|
+
else
|
|
128
|
+
throw new SchemaParseError(t.line, `expected "relation", "permission" or "}", found "${t.text}"`);
|
|
129
|
+
}
|
|
130
|
+
next();
|
|
131
|
+
defs.push(def);
|
|
132
|
+
}
|
|
133
|
+
// Every reference must name something declared in its own definition; an arrow's left must be a relation.
|
|
134
|
+
for (const def of defs) {
|
|
135
|
+
const check = (e, line) => {
|
|
136
|
+
if (e.op === 'ref') {
|
|
137
|
+
if (!(e.name in def.relations) && !(e.name in def.permissions))
|
|
138
|
+
throw new SchemaParseError(line, `${def.name} has no relation or permission "${e.name}"`);
|
|
139
|
+
}
|
|
140
|
+
else if (e.op === 'arrow') {
|
|
141
|
+
if (!(e.left in def.relations))
|
|
142
|
+
throw new SchemaParseError(line, `${def.name} has no relation "${e.left}" to follow with ->`);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
check(e.l, line);
|
|
146
|
+
check(e.r, line);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
for (const e of Object.values(def.permissions))
|
|
150
|
+
check(e, tokens.find((t) => t.text === def.name)?.line ?? 1);
|
|
151
|
+
}
|
|
152
|
+
return defs;
|
|
153
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type SpaceOp = 'schema.register' | 'relations.write' | 'relations.replace' | 'relations.list' | 'check' | 'doc.write' | 'doc.edit' | 'doc.delete' | 'object.list' | 'object.search' | 'object.get';
|
|
2
|
+
export declare const SPACE_OPS: ReadonlySet<string>;
|
|
3
|
+
export declare class FakeSpace {
|
|
4
|
+
private readonly clientId;
|
|
5
|
+
private readonly userId;
|
|
6
|
+
private readonly now;
|
|
7
|
+
private readonly schemas;
|
|
8
|
+
private facts;
|
|
9
|
+
private readonly docs;
|
|
10
|
+
private serial;
|
|
11
|
+
constructor(clientId: string, userId: string, now: () => string);
|
|
12
|
+
clear(): void;
|
|
13
|
+
/** The built-in definitions plus every registered app definition. */
|
|
14
|
+
private definitions;
|
|
15
|
+
handle(op: SpaceOp, id: string | undefined, query: URLSearchParams, rawBody: unknown): {
|
|
16
|
+
status: number;
|
|
17
|
+
body: unknown;
|
|
18
|
+
};
|
|
19
|
+
private json;
|
|
20
|
+
private ownerHandle;
|
|
21
|
+
private canonical;
|
|
22
|
+
private byRef;
|
|
23
|
+
/** A resource type `<namespace>/<name>` must live in a namespace this app registered. */
|
|
24
|
+
private owned;
|
|
25
|
+
private register;
|
|
26
|
+
private factList;
|
|
27
|
+
private fact;
|
|
28
|
+
/** Validate and apply facts to `into`, in order; false when the fact was already there. */
|
|
29
|
+
private put;
|
|
30
|
+
private change;
|
|
31
|
+
private replace;
|
|
32
|
+
private list;
|
|
33
|
+
/** The schema's permission over the facts, for the person `subject`: unknown types and permissions are false, as in the daemon. */
|
|
34
|
+
private check;
|
|
35
|
+
private write;
|
|
36
|
+
private edit;
|
|
37
|
+
private remove;
|
|
38
|
+
private item;
|
|
39
|
+
private filter;
|
|
40
|
+
private search;
|
|
41
|
+
private read;
|
|
42
|
+
}
|