@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.
@@ -0,0 +1,265 @@
1
+ import { builtinDefinitions, droppedRelations, factKey, formatSubject, holds, parseResource, parseSubject, validateFact, validateSchema, OBJECT_TYPE } from './permissions.js';
2
+ import { invalid, notFound, objectBody, refuse } from './wire.js';
3
+ export const SPACE_OPS = new Set(['schema.register', 'relations.write', 'relations.replace', 'relations.list', 'check', 'doc.write', 'doc.edit', 'doc.delete', 'object.list', 'object.search', 'object.get']);
4
+ const str = (value, field) => { if (typeof value !== 'string')
5
+ throw invalid(`${field} must be a string`, field); return value; };
6
+ const factDto = (f) => ({ resource: `${f.resourceType}:${f.resourceId}`, relation: f.relation, subject: formatSubject(f.subject) });
7
+ export class FakeSpace {
8
+ clientId;
9
+ userId;
10
+ now;
11
+ schemas = new Map();
12
+ facts = new Map();
13
+ docs = new Map();
14
+ serial = 0;
15
+ constructor(clientId, userId, now) {
16
+ this.clientId = clientId;
17
+ this.userId = userId;
18
+ this.now = now;
19
+ }
20
+ clear() { this.schemas.clear(); this.facts.clear(); this.docs.clear(); }
21
+ /** The built-in definitions plus every registered app definition. */
22
+ definitions() {
23
+ const defs = builtinDefinitions();
24
+ for (const { defs: registered } of this.schemas.values())
25
+ for (const d of registered)
26
+ defs.set(d.name, d);
27
+ return defs;
28
+ }
29
+ handle(op, id, query, rawBody) {
30
+ switch (op) {
31
+ case 'schema.register': return { status: 200, body: this.register(objectBody(rawBody)) };
32
+ case 'relations.write': return { status: 200, body: this.change(objectBody(rawBody)) };
33
+ case 'relations.replace': return { status: 200, body: this.replace(objectBody(rawBody)) };
34
+ case 'relations.list': return { status: 200, body: this.list(query.get('resource')) };
35
+ case 'check': return { status: 200, body: this.check(objectBody(rawBody)) };
36
+ case 'doc.write': return { status: 200, body: this.write(objectBody(rawBody)) };
37
+ case 'doc.edit': return { status: 200, body: this.edit(this.byRef(id), objectBody(rawBody)) };
38
+ case 'doc.delete': return { status: 200, body: this.remove(this.byRef(id)) };
39
+ case 'object.list': return { status: 200, body: { items: this.filter(query.get('name'), query.get('schema_type'), query.get('extensions') === null ? undefined : this.json(query.get('extensions')), query.get('kind'), Number(query.get('limit') ?? 50)).map((d) => this.item(d)), next_cursor: null } };
40
+ case 'object.search': return { status: 200, body: this.search(objectBody(rawBody)) };
41
+ case 'object.get': return { status: 200, body: this.read(this.byRef(id)) };
42
+ }
43
+ }
44
+ json(text) { try {
45
+ return objectBody(JSON.parse(text));
46
+ }
47
+ catch {
48
+ throw invalid('extensions must be a JSON object', 'extensions');
49
+ } }
50
+ ownerHandle(doc) { return doc.owner === 'app' ? this.clientId : this.userId.replace(/^user:/, ''); }
51
+ canonical(doc) { return `${this.ownerHandle(doc)}/${doc.name}`; }
52
+ byRef(ref) {
53
+ const doc = [...this.docs.values()].find((d) => d.id === ref || this.canonical(d) === ref);
54
+ if (!doc)
55
+ throw notFound(`no document ${ref}`);
56
+ return doc;
57
+ }
58
+ /** A resource type `<namespace>/<name>` must live in a namespace this app registered. */
59
+ owned(type) {
60
+ if (!type.includes('/') || !this.schemas.has(type.split('/')[0]))
61
+ throw refuse(403, 'not_schema_owner', `${type} is not in a namespace this app registered`, { details: { type } });
62
+ }
63
+ register(b) {
64
+ const namespace = str(b['namespace'], 'namespace');
65
+ const source = str(b['source'], 'source');
66
+ const defs = validateSchema(namespace, source);
67
+ const held = this.schemas.get(namespace);
68
+ if (held?.source === source)
69
+ return { namespace, version: held.version, changed: false };
70
+ if (held !== undefined) {
71
+ for (const { definition, relation } of droppedRelations(held.defs, defs)) {
72
+ const facts = [...this.facts.values()].filter((f) => f.resourceType === definition && f.relation === relation).length;
73
+ if (facts > 0)
74
+ throw refuse(409, 'relation_in_use', `${definition}#${relation} still holds ${facts} facts; delete them first`, { details: { relation: `${definition}#${relation}`, facts } });
75
+ }
76
+ }
77
+ const version = (held?.version ?? 0) + 1;
78
+ this.schemas.set(namespace, { source, version, defs });
79
+ return { namespace, version, changed: true };
80
+ }
81
+ factList(value, field) {
82
+ if (value === undefined)
83
+ return [];
84
+ if (!Array.isArray(value))
85
+ throw invalid(`${field} must be a list`, field);
86
+ return value.map((f) => { const o = objectBody(f); return { resource: str(o['resource'], 'resource'), relation: str(o['relation'], 'relation'), subject: str(o['subject'], 'subject') }; });
87
+ }
88
+ fact(f) {
89
+ const r = parseResource(f.resource);
90
+ this.owned(r.type);
91
+ return { resourceType: r.type, resourceId: r.id, relation: f.relation, subject: parseSubject(f.subject) };
92
+ }
93
+ /** Validate and apply facts to `into`, in order; false when the fact was already there. */
94
+ put(into, fact) {
95
+ validateFact(this.definitions(), fact);
96
+ if (into.has(factKey(fact)))
97
+ return false;
98
+ into.set(factKey(fact), fact);
99
+ return true;
100
+ }
101
+ // A refusal anywhere leaves the facts as they were: the daemon runs each request in one transaction.
102
+ change(b) {
103
+ const write = this.factList(b['write'], 'write');
104
+ const remove = this.factList(b['delete'], 'delete');
105
+ const next = new Map(this.facts);
106
+ let written = 0;
107
+ let deleted = 0;
108
+ for (const f of write)
109
+ if (this.put(next, this.fact(f)))
110
+ written++;
111
+ for (const f of remove)
112
+ if (next.delete(factKey(this.fact(f))))
113
+ deleted++;
114
+ this.facts = next;
115
+ return { written, deleted };
116
+ }
117
+ replace(b) {
118
+ const resource = str(b['resource'], 'resource');
119
+ const relation = str(b['relation'], 'relation');
120
+ const subjects = b['subjects'];
121
+ if (!Array.isArray(subjects))
122
+ throw invalid('subjects must be a list', 'subjects');
123
+ const { type, id } = parseResource(resource);
124
+ this.owned(type);
125
+ const wanted = new Map(subjects.map((s) => parseSubject(str(s, 'subjects'))).map((s) => [formatSubject(s), s]));
126
+ const next = new Map(this.facts);
127
+ let written = 0;
128
+ let deleted = 0;
129
+ for (const [key, f] of this.facts)
130
+ if (f.resourceType === type && f.resourceId === id && f.relation === relation && !wanted.has(formatSubject(f.subject))) {
131
+ next.delete(key);
132
+ deleted++;
133
+ }
134
+ for (const subject of wanted.values())
135
+ if (this.put(next, { resourceType: type, resourceId: id, relation, subject }))
136
+ written++;
137
+ this.facts = next;
138
+ return { written, deleted };
139
+ }
140
+ list(resource) {
141
+ if (resource === null || resource === '')
142
+ throw invalid('`resource` is required', 'resource');
143
+ const { type, id } = parseResource(resource);
144
+ this.owned(type);
145
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
146
+ return { items: [...this.facts.values()].filter((f) => f.resourceType === type && f.resourceId === id)
147
+ .sort((a, b) => cmp(a.relation, b.relation) || cmp(a.subject.type, b.subject.type) || cmp(a.subject.id, b.subject.id)).map(factDto) };
148
+ }
149
+ /** The schema's permission over the facts, for the person `subject`: unknown types and permissions are false, as in the daemon. */
150
+ check(b) {
151
+ const subject = parseSubject(str(b['subject'], 'subject'));
152
+ const permission = str(b['permission'], 'permission');
153
+ const resource = parseResource(str(b['resource'], 'resource'));
154
+ if (subject.type !== 'user' || subject.relation !== '' || subject.id === '*')
155
+ throw invalid('`subject` must be `user:<id>`');
156
+ if (resource.type !== OBJECT_TYPE)
157
+ this.owned(resource.type);
158
+ return { allowed: holds(this.definitions(), this.facts.values(), subject.id, resource.type, resource.id, permission) };
159
+ }
160
+ write(b) {
161
+ const owner = b['owner'] === undefined || b['owner'] === 'app' ? 'app' : b['owner'] === 'user' ? 'user' : (() => { throw invalid('owner must be app or user', 'owner'); })();
162
+ const name = str(b['name'], 'name');
163
+ const schemaType = b['schema_type'] === undefined ? null : str(b['schema_type'], 'schema_type');
164
+ if (schemaType !== null)
165
+ this.owned(schemaType);
166
+ const relations = b['relations'] === undefined ? [] : this.factList(Array.isArray(b['relations']) ? b['relations'].map((r) => ({ resource: '', relation: r['relation'], subject: r['subject'] })) : b['relations'], 'relations');
167
+ if (relations.length > 0 && schemaType === null)
168
+ throw refuse(400, 'schema_type_required', 'relations need a schemaType: they are facts about a typed resource', { details: { field: 'relations' } });
169
+ if ([...this.docs.values()].some((d) => d.owner === owner && d.name === name))
170
+ throw refuse(409, 'name_taken', `${name} is already a document`);
171
+ const doc = {
172
+ id: `doc-${++this.serial}`, owner, name, body: str(b['body'], 'body'), preview: str(b['preview'], 'preview'),
173
+ summary: typeof b['summary'] === 'string' ? b['summary'] : null, kind: typeof b['kind'] === 'string' ? b['kind'] : 'knowledge', privacy: typeof b['privacy'] === 'string' ? b['privacy'] : 'public',
174
+ extensions: b['extensions'] === undefined ? {} : objectBody(b['extensions']), rules: Array.isArray(b['rules']) ? b['rules'] : [],
175
+ schemaType, revision: 1, createdAt: this.now(),
176
+ };
177
+ // A fact the schema refuses refuses the whole write: the document is not kept.
178
+ const next = new Map(this.facts);
179
+ for (const r of relations)
180
+ this.put(next, { resourceType: schemaType, resourceId: doc.id, relation: r.relation, subject: parseSubject(r.subject) });
181
+ this.facts = next;
182
+ this.docs.set(doc.id, doc);
183
+ return { id: doc.id, name: this.canonical(doc) };
184
+ }
185
+ edit(doc, b) {
186
+ str(b['rationale'], 'rationale');
187
+ if (b['body'] !== undefined && b['replace'] !== undefined)
188
+ throw invalid('pass body or replace, not both', 'replace');
189
+ if (b['body'] !== undefined)
190
+ doc.body = str(b['body'], 'body');
191
+ if (Array.isArray(b['replace'])) {
192
+ for (const r of b['replace']) {
193
+ const from = str(r['old'], 'old');
194
+ if (!doc.body.includes(from))
195
+ throw refuse(409, 'replace_not_found', `${JSON.stringify(from)} is not in the document`);
196
+ doc.body = doc.body.replace(from, () => str(r['new'], 'new'));
197
+ }
198
+ }
199
+ for (const field of ['preview', 'summary', 'privacy'])
200
+ if (typeof b[field] === 'string')
201
+ doc[field] = b[field];
202
+ if (b['extensions'] !== undefined)
203
+ doc.extensions = objectBody(b['extensions']);
204
+ if (Array.isArray(b['rules']))
205
+ doc.rules = b['rules'];
206
+ if (Array.isArray(b['unset']))
207
+ for (const field of b['unset']) {
208
+ if (field === 'summary')
209
+ doc.summary = null;
210
+ if (field === 'extensions')
211
+ doc.extensions = {};
212
+ if (field === 'rules')
213
+ doc.rules = [];
214
+ }
215
+ doc.revision++;
216
+ return { id: doc.id, name: this.canonical(doc), revision: doc.revision };
217
+ }
218
+ remove(doc) {
219
+ this.docs.delete(doc.id);
220
+ if (doc.schemaType !== null) {
221
+ for (const [key, f] of this.facts) {
222
+ const asResource = f.resourceType === doc.schemaType && f.resourceId === doc.id;
223
+ const asSubject = f.subject.type === doc.schemaType && f.subject.id === doc.id;
224
+ if (asResource || asSubject)
225
+ this.facts.delete(key);
226
+ }
227
+ }
228
+ return { id: doc.id, name: this.canonical(doc) };
229
+ }
230
+ item(doc) {
231
+ return {
232
+ id: doc.id, type: 'document', kind: doc.kind, owner: { id: doc.owner === 'app' ? `app:${this.clientId}` : this.userId, type: doc.owner === 'app' ? 'app' : 'person', handle: this.ownerHandle(doc) },
233
+ privacy: doc.privacy, name: doc.name, preview: doc.preview, created_at: doc.createdAt, deleted_at: null,
234
+ schema_type: doc.schemaType, summary: doc.summary, rules: doc.rules.length, extensions: doc.extensions,
235
+ };
236
+ }
237
+ filter(name, schemaType, extensions, kind, limit, match) {
238
+ return [...this.docs.values()].reverse()
239
+ .filter((d) => (name === null || d.name.startsWith(name)) && (schemaType === null || d.schemaType === schemaType) && (kind === null || d.kind === kind)
240
+ && Object.entries(extensions ?? {}).every(([ns, fields]) => Object.entries(fields).every(([k, v]) => d.extensions[ns]?.[k] === v))
241
+ && (match === undefined || match(d)))
242
+ .slice(0, limit);
243
+ }
244
+ search(b) {
245
+ const text = str(b['text'], 'text').toLowerCase();
246
+ const grep = typeof b['grep'] === 'string' ? new RegExp(b['grep']) : null;
247
+ const extensions = b['extensions'] === undefined ? undefined : objectBody(b['extensions']);
248
+ const docs = this.filter(null, typeof b['schema_type'] === 'string' ? b['schema_type'] : null, extensions, typeof b['kind'] === 'string' ? b['kind'] : null, typeof b['limit'] === 'number' ? b['limit'] : 50, (d) => grep !== null ? d.body.split('\n').some((l) => grep.test(l)) : b['body'] === true ? d.body.toLowerCase().includes(text) : d.name.toLowerCase().includes(text));
249
+ return {
250
+ items: docs.map((d) => ({
251
+ object: this.item(d),
252
+ ...(grep !== null ? { matches: d.body.split('\n').flatMap((line, i) => (grep.test(line) ? [{ line: i + 1, text: line }] : [])) } : {}),
253
+ })),
254
+ };
255
+ }
256
+ read(doc) {
257
+ return {
258
+ state: 'object', type: 'document', object: this.item(doc), text: doc.body,
259
+ data: {
260
+ summary: doc.summary, revision: doc.revision, heads: [{ rev_id: `r${doc.revision}`, number: doc.revision, author_id: null, created_at: doc.createdAt }],
261
+ bodies: [doc.body], needs_merge: false, fields: { extensions: doc.extensions, rules: doc.rules },
262
+ },
263
+ };
264
+ }
265
+ }
@@ -1,5 +1,6 @@
1
1
  import type { GrantGetDTO, ProviderDependency, SetupState } from '@north-light/api';
2
2
  import type { FakeRun } from './run.js';
3
+ import { FakeSpace } from './space.js';
3
4
  /** The directory's reasons for refusing a refresh token (the SDK's refusal table). */
4
5
  export type FakeRefusalCode = 'grant_removed' | 'grant_suspended' | 'app_in_testing' | 'refresh_token_expired' | 'refresh_token_reused' | 'grant_revoked' | 'runtime_provisioning';
5
6
  /** The identifiers a person's account holds. Only `email` (when `emailVerified`) and `phone` match a tester entry. */
@@ -29,7 +30,7 @@ export type FakeScope = string | {
29
30
  export interface FakeMemoryDoc {
30
31
  name: string;
31
32
  body: string;
32
- privacy?: 'public' | 'friends' | 'private';
33
+ privacy?: 'public' | 'private';
33
34
  kind?: 'knowledge' | 'preference';
34
35
  created_at?: string;
35
36
  updated_at?: string;
@@ -76,6 +77,8 @@ export declare class UserState {
76
77
  responder: Responder | null;
77
78
  readonly memory: Map<string, StoredMemoryDoc>;
78
79
  readonly idempotency: Map<string, IdempotencyEntry>;
80
+ /** The app's documents, schemas and facts in this person's runtime. */
81
+ readonly space: FakeSpace;
79
82
  readonly holds: Set<Hold>;
80
83
  constructor(id: string, clientId: string, onRevoke: (user: UserState) => void, clock: {
81
84
  now(): number;
@@ -1,4 +1,5 @@
1
1
  import { IDEMPOTENCY_WINDOW_MS } from './conformance/keyed-write.js';
2
+ import { FakeSpace } from './space.js';
2
3
  import { grantVersion, nowIso } from './wire.js';
3
4
  export const normalizeScopes = (scopes) => scopes.map((scope) => (typeof scope === 'string' ? { scope, mode: 'always' } : { scope: scope.scope, mode: scope.mode }));
4
5
  /** Internal per-user state. */
@@ -25,6 +26,8 @@ export class UserState {
25
26
  responder = null;
26
27
  memory = new Map();
27
28
  idempotency = new Map();
29
+ /** The app's documents, schemas and facts in this person's runtime. */
30
+ space;
28
31
  holds = new Set();
29
32
  constructor(id, clientId, onRevoke, clock) {
30
33
  this.id = id;
@@ -32,6 +35,7 @@ export class UserState {
32
35
  this.onRevoke = onRevoke;
33
36
  this.clock = clock;
34
37
  this.grantId = `grant-${id.replace(/^user:/, '')}`;
38
+ this.space = new FakeSpace(clientId, id, () => nowIso(this.clock.now()));
35
39
  }
36
40
  dto(exp = Math.floor(this.clock.now() / 1000) + 3600) {
37
41
  if (!this.active)
package/dist/version.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** This SDK's release version. Informational only: compatibility is the contract level, not this number. */
2
- export declare const SDK_VERSION = "0.3.548";
2
+ export declare const SDK_VERSION = "0.3.550";
3
3
  /** The release sent in `Crouter-Minimum-Runtime-Version` on every request. It is frozen, so a runtime from before the
4
4
  * contract level existed (which only knows this header) refuses this SDK, while every later runtime accepts it. */
5
5
  export declare const LEGACY_FLOOR = "0.3.423";
package/dist/version.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // SDK_VERSION is written by scripts/release-version.mjs on every release; do not edit by hand.
2
2
  /** This SDK's release version. Informational only: compatibility is the contract level, not this number. */
3
- export const SDK_VERSION = '0.3.548';
3
+ export const SDK_VERSION = '0.3.550';
4
4
  // LEGACY_FLOOR is written once by `scripts/contract.mjs release`, at the first release that carries the contract level
5
5
  // (the `0.0.0` placeholder is replaced there and never again); do not edit by hand.
6
6
  /** The release sent in `Crouter-Minimum-Runtime-Version` on every request. It is frozen, so a runtime from before the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@north-light/sdk",
3
- "version": "0.3.548",
4
- "crouterContract": "1.30",
3
+ "version": "0.3.550",
4
+ "crouterContract": "1.31",
5
5
  "description": "Typed Node and browser client for running crouter agents through the crtrd /v1 API.",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -44,9 +44,9 @@
44
44
  "prepublishOnly": "node -e \"if (process.env.npm_config_tag !== 'next') { console.error('@north-light/sdk publishes only with --tag next; scripts/promote-sdk.mjs is the only mover of latest.'); process.exit(1); }\""
45
45
  },
46
46
  "dependencies": {
47
- "@north-light/api": "0.3.548",
48
- "@north-light/identity": "0.3.548",
49
- "@north-light/plugin": "0.3.548",
47
+ "@north-light/api": "0.3.550",
48
+ "@north-light/identity": "0.3.550",
49
+ "@north-light/plugin": "0.3.550",
50
50
  "jose": "^6.2.1"
51
51
  },
52
52
  "peerDependencies": {