@haverstack/core 0.41.0 → 0.43.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 +124 -64
- package/dist/access.d.ts +43 -47
- package/dist/access.d.ts.map +1 -1
- package/dist/access.js +66 -101
- package/dist/access.js.map +1 -1
- package/dist/{combine.d.ts → adapter/combine.d.ts} +8 -1
- package/dist/adapter/combine.d.ts.map +1 -0
- package/dist/{combine.js → adapter/combine.js} +18 -3
- package/dist/adapter/combine.js.map +1 -0
- package/dist/adapter/index.d.ts +19 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/index.js +16 -0
- package/dist/adapter/index.js.map +1 -0
- package/dist/adapter/reach.d.ts +14 -0
- package/dist/adapter/reach.d.ts.map +1 -0
- package/dist/adapter/reach.js +31 -0
- package/dist/adapter/reach.js.map +1 -0
- package/dist/{sort.d.ts → adapter/sort.d.ts} +20 -20
- package/dist/adapter/sort.d.ts.map +1 -0
- package/dist/{sort.js → adapter/sort.js} +37 -27
- package/dist/adapter/sort.js.map +1 -0
- package/dist/associations/identity.d.ts +69 -0
- package/dist/associations/identity.d.ts.map +1 -0
- package/dist/associations/identity.js +174 -0
- package/dist/associations/identity.js.map +1 -0
- package/dist/associations/validation.d.ts +83 -0
- package/dist/associations/validation.d.ts.map +1 -0
- package/dist/associations/validation.js +312 -0
- package/dist/associations/validation.js.map +1 -0
- package/dist/attachment-records.d.ts +48 -0
- package/dist/attachment-records.d.ts.map +1 -0
- package/dist/attachment-records.js +52 -0
- package/dist/attachment-records.js.map +1 -0
- package/dist/collection.d.ts +178 -0
- package/dist/collection.d.ts.map +1 -0
- package/dist/collection.js +397 -0
- package/dist/collection.js.map +1 -0
- package/dist/content-match.d.ts +25 -0
- package/dist/content-match.d.ts.map +1 -0
- package/dist/content-match.js +51 -0
- package/dist/content-match.js.map +1 -0
- package/dist/content-translation.d.ts +42 -0
- package/dist/content-translation.d.ts.map +1 -0
- package/dist/content-translation.js +108 -0
- package/dist/content-translation.js.map +1 -0
- package/dist/did/did.d.ts.map +1 -0
- package/dist/{did.js → did/did.js} +3 -4
- package/dist/did/did.js.map +1 -0
- package/dist/{did-entry.d.ts → did/index.d.ts} +1 -1
- package/dist/did/index.d.ts.map +1 -0
- package/dist/{did-entry.js → did/index.js} +3 -3
- package/dist/did/index.js.map +1 -0
- package/dist/errors.d.ts +54 -66
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +58 -62
- package/dist/errors.js.map +1 -1
- package/dist/grants.d.ts +20 -44
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +69 -151
- package/dist/grants.js.map +1 -1
- package/dist/id.d.ts +13 -31
- package/dist/id.d.ts.map +1 -1
- package/dist/id.js +13 -31
- package/dist/id.js.map +1 -1
- package/dist/index.d.ts +13 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -6
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +3 -6
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +6 -10
- package/dist/install.js.map +1 -1
- package/dist/lazy.d.ts +13 -0
- package/dist/lazy.d.ts.map +1 -0
- package/dist/lazy.js +27 -0
- package/dist/lazy.js.map +1 -0
- package/dist/merge.d.ts +1 -1
- package/dist/merge.d.ts.map +1 -1
- package/dist/merge.js +6 -12
- package/dist/merge.js.map +1 -1
- package/dist/migration-steps.d.ts +102 -0
- package/dist/migration-steps.d.ts.map +1 -0
- package/dist/migration-steps.js +293 -0
- package/dist/migration-steps.js.map +1 -0
- package/dist/query-validation.d.ts +68 -121
- package/dist/query-validation.d.ts.map +1 -1
- package/dist/query-validation.js +214 -380
- package/dist/query-validation.js.map +1 -1
- package/dist/record-changes.d.ts +11 -150
- package/dist/record-changes.d.ts.map +1 -1
- package/dist/record-changes.js +14 -305
- package/dist/record-changes.js.map +1 -1
- package/dist/schema.d.ts +18 -7
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +59 -30
- package/dist/schema.js.map +1 -1
- package/dist/scoped-stack/authority-snapshot.d.ts +37 -0
- package/dist/scoped-stack/authority-snapshot.d.ts.map +1 -0
- package/dist/scoped-stack/authority-snapshot.js +49 -0
- package/dist/scoped-stack/authority-snapshot.js.map +1 -0
- package/dist/scoped-stack/authority.d.ts +169 -0
- package/dist/scoped-stack/authority.d.ts.map +1 -0
- package/dist/scoped-stack/authority.js +408 -0
- package/dist/scoped-stack/authority.js.map +1 -0
- package/dist/scoped-stack/family-policy.d.ts +59 -0
- package/dist/scoped-stack/family-policy.d.ts.map +1 -0
- package/dist/scoped-stack/family-policy.js +68 -0
- package/dist/scoped-stack/family-policy.js.map +1 -0
- package/dist/scoped-stack/scoped-stack.d.ts +318 -0
- package/dist/scoped-stack/scoped-stack.d.ts.map +1 -0
- package/dist/scoped-stack/scoped-stack.js +695 -0
- package/dist/scoped-stack/scoped-stack.js.map +1 -0
- package/dist/scoped-stack/subscription.d.ts +36 -0
- package/dist/scoped-stack/subscription.d.ts.map +1 -0
- package/dist/scoped-stack/subscription.js +69 -0
- package/dist/scoped-stack/subscription.js.map +1 -0
- package/dist/stack/arguments.d.ts +27 -0
- package/dist/stack/arguments.d.ts.map +1 -0
- package/dist/stack/arguments.js +53 -0
- package/dist/stack/arguments.js.map +1 -0
- package/dist/stack/attachments.d.ts +24 -0
- package/dist/stack/attachments.d.ts.map +1 -0
- package/dist/stack/attachments.js +113 -0
- package/dist/stack/attachments.js.map +1 -0
- package/dist/{changes.d.ts → stack/changes.d.ts} +30 -72
- package/dist/stack/changes.d.ts.map +1 -0
- package/dist/{changes.js → stack/changes.js} +40 -74
- package/dist/stack/changes.js.map +1 -0
- package/dist/stack/client.d.ts +247 -0
- package/dist/stack/client.d.ts.map +1 -0
- package/dist/stack/client.js +9 -0
- package/dist/stack/client.js.map +1 -0
- package/dist/stack/identity-bindings.d.ts.map +1 -0
- package/dist/{identity-bindings.js → stack/identity-bindings.js} +5 -14
- package/dist/stack/identity-bindings.js.map +1 -0
- package/dist/stack/install-app.d.ts +22 -0
- package/dist/stack/install-app.d.ts.map +1 -0
- package/dist/stack/install-app.js +262 -0
- package/dist/stack/install-app.js.map +1 -0
- package/dist/stack/integrity.d.ts +94 -0
- package/dist/stack/integrity.d.ts.map +1 -0
- package/dist/stack/integrity.js +268 -0
- package/dist/stack/integrity.js.map +1 -0
- package/dist/{limits.d.ts → stack/limits.d.ts} +2 -5
- package/dist/stack/limits.d.ts.map +1 -0
- package/dist/{limits.js → stack/limits.js} +3 -6
- package/dist/stack/limits.js.map +1 -0
- package/dist/stack/migrations.d.ts +59 -0
- package/dist/stack/migrations.d.ts.map +1 -0
- package/dist/stack/migrations.js +141 -0
- package/dist/stack/migrations.js.map +1 -0
- package/dist/stack/reads.d.ts +69 -0
- package/dist/stack/reads.d.ts.map +1 -0
- package/dist/stack/reads.js +141 -0
- package/dist/stack/reads.js.map +1 -0
- package/dist/stack/record-id.d.ts +35 -0
- package/dist/stack/record-id.d.ts.map +1 -0
- package/dist/stack/record-id.js +82 -0
- package/dist/stack/record-id.js.map +1 -0
- package/dist/stack/stack.d.ts +444 -0
- package/dist/stack/stack.d.ts.map +1 -0
- package/dist/stack/stack.js +1339 -0
- package/dist/stack/stack.js.map +1 -0
- package/dist/stack/system-types.d.ts +16 -0
- package/dist/stack/system-types.d.ts.map +1 -0
- package/dist/stack/system-types.js +111 -0
- package/dist/stack/system-types.js.map +1 -0
- package/dist/stack/type-grants.d.ts +20 -0
- package/dist/stack/type-grants.d.ts.map +1 -0
- package/dist/stack/type-grants.js +69 -0
- package/dist/stack/type-grants.js.map +1 -0
- package/dist/stack/write-expectation.d.ts +79 -0
- package/dist/stack/write-expectation.d.ts.map +1 -0
- package/dist/stack/write-expectation.js +71 -0
- package/dist/stack/write-expectation.js.map +1 -0
- package/dist/stack/write-gate.d.ts +23 -0
- package/dist/stack/write-gate.d.ts.map +1 -0
- package/dist/stack/write-gate.js +17 -0
- package/dist/stack/write-gate.js.map +1 -0
- package/dist/testing.d.ts +44 -33
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +180 -176
- package/dist/testing.js.map +1 -1
- package/dist/type-handle.d.ts +67 -78
- package/dist/type-handle.d.ts.map +1 -1
- package/dist/type-handle.js +44 -111
- package/dist/type-handle.js.map +1 -1
- package/dist/types/adapter.d.ts +282 -0
- package/dist/types/adapter.d.ts.map +1 -0
- package/dist/types/adapter.js +5 -0
- package/dist/types/adapter.js.map +1 -0
- package/dist/types/associations.d.ts +140 -0
- package/dist/types/associations.d.ts.map +1 -0
- package/dist/types/associations.js +6 -0
- package/dist/types/associations.js.map +1 -0
- package/dist/types/capabilities.d.ts +89 -0
- package/dist/types/capabilities.d.ts.map +1 -0
- package/dist/types/capabilities.js +5 -0
- package/dist/types/capabilities.js.map +1 -0
- package/dist/types/events.d.ts +150 -0
- package/dist/types/events.d.ts.map +1 -0
- package/dist/types/events.js +5 -0
- package/dist/types/events.js.map +1 -0
- package/dist/types/ids.d.ts +43 -0
- package/dist/types/ids.d.ts.map +1 -0
- package/dist/types/ids.js +5 -0
- package/dist/types/ids.js.map +1 -0
- package/dist/types/index.d.ts +19 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +19 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/journal.d.ts +47 -0
- package/dist/types/journal.d.ts.map +1 -0
- package/dist/types/journal.js +5 -0
- package/dist/types/journal.js.map +1 -0
- package/dist/types/query.d.ts +185 -0
- package/dist/types/query.d.ts.map +1 -0
- package/dist/types/query.js +10 -0
- package/dist/types/query.js.map +1 -0
- package/dist/types/records.d.ts +76 -0
- package/dist/types/records.d.ts.map +1 -0
- package/dist/types/records.js +5 -0
- package/dist/types/records.js.map +1 -0
- package/dist/types/schema.d.ts +74 -0
- package/dist/types/schema.d.ts.map +1 -0
- package/dist/types/schema.js +5 -0
- package/dist/types/schema.js.map +1 -0
- package/dist/types/system.d.ts +161 -0
- package/dist/types/system.d.ts.map +1 -0
- package/dist/types/system.js +32 -0
- package/dist/types/system.js.map +1 -0
- package/dist/types/tokens.d.ts +39 -0
- package/dist/types/tokens.d.ts.map +1 -0
- package/dist/types/tokens.js +5 -0
- package/dist/types/tokens.js.map +1 -0
- package/dist/validate.d.ts +31 -70
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +52 -92
- package/dist/validate.js.map +1 -1
- package/dist/{attachment-download.d.ts → wire/attachment-download.d.ts} +0 -57
- package/dist/wire/attachment-download.d.ts.map +1 -0
- package/dist/{attachment-download.js → wire/attachment-download.js} +0 -61
- package/dist/wire/attachment-download.js.map +1 -0
- package/dist/{auth.d.ts → wire/auth.d.ts} +5 -8
- package/dist/wire/auth.d.ts.map +1 -0
- package/dist/{auth.js → wire/auth.js} +5 -8
- package/dist/wire/auth.js.map +1 -0
- package/dist/{wire-body.d.ts → wire/body.d.ts} +35 -12
- package/dist/wire/body.d.ts.map +1 -0
- package/dist/{wire-body.js → wire/body.js} +74 -13
- package/dist/wire/body.js.map +1 -0
- package/dist/{wire-entry.d.ts → wire/index.d.ts} +12 -12
- package/dist/wire/index.d.ts.map +1 -0
- package/dist/{wire-entry.js → wire/index.js} +13 -17
- package/dist/wire/index.js.map +1 -0
- package/dist/wire/record.d.ts +31 -0
- package/dist/wire/record.d.ts.map +1 -0
- package/dist/{wire-record.js → wire/record.js} +7 -67
- package/dist/wire/record.js.map +1 -0
- package/dist/{wire-request.d.ts → wire/request.d.ts} +32 -49
- package/dist/wire/request.d.ts.map +1 -0
- package/dist/{wire-request.js → wire/request.js} +113 -83
- package/dist/wire/request.js.map +1 -0
- package/package.json +7 -7
- package/dist/adapter-entry.d.ts +0 -16
- package/dist/adapter-entry.d.ts.map +0 -1
- package/dist/adapter-entry.js +0 -14
- package/dist/adapter-entry.js.map +0 -1
- package/dist/attachment-download.d.ts.map +0 -1
- package/dist/attachment-download.js.map +0 -1
- package/dist/auth.d.ts.map +0 -1
- package/dist/auth.js.map +0 -1
- package/dist/changes.d.ts.map +0 -1
- package/dist/changes.js.map +0 -1
- package/dist/combine.d.ts.map +0 -1
- package/dist/combine.js.map +0 -1
- package/dist/did-entry.d.ts.map +0 -1
- package/dist/did-entry.js.map +0 -1
- package/dist/did.d.ts.map +0 -1
- package/dist/did.js.map +0 -1
- package/dist/identity-bindings.d.ts.map +0 -1
- package/dist/identity-bindings.js.map +0 -1
- package/dist/limits.d.ts.map +0 -1
- package/dist/limits.js.map +0 -1
- package/dist/record-id.d.ts +0 -70
- package/dist/record-id.d.ts.map +0 -1
- package/dist/record-id.js +0 -127
- package/dist/record-id.js.map +0 -1
- package/dist/scoped-stack.d.ts +0 -549
- package/dist/scoped-stack.d.ts.map +0 -1
- package/dist/scoped-stack.js +0 -1301
- package/dist/scoped-stack.js.map +0 -1
- package/dist/sort.d.ts.map +0 -1
- package/dist/sort.js.map +0 -1
- package/dist/stack-reads.d.ts +0 -38
- package/dist/stack-reads.d.ts.map +0 -1
- package/dist/stack-reads.js +0 -79
- package/dist/stack-reads.js.map +0 -1
- package/dist/stack.d.ts +0 -1032
- package/dist/stack.d.ts.map +0 -1
- package/dist/stack.js +0 -2337
- package/dist/stack.js.map +0 -1
- package/dist/types.d.ts +0 -1278
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -51
- package/dist/types.js.map +0 -1
- package/dist/wire-body.d.ts.map +0 -1
- package/dist/wire-body.js.map +0 -1
- package/dist/wire-entry.d.ts.map +0 -1
- package/dist/wire-entry.js.map +0 -1
- package/dist/wire-record.d.ts +0 -50
- package/dist/wire-record.d.ts.map +0 -1
- package/dist/wire-record.js.map +0 -1
- package/dist/wire-request.d.ts.map +0 -1
- package/dist/wire-request.js.map +0 -1
- /package/dist/{did.d.ts → did/did.d.ts} +0 -0
- /package/dist/{identity-bindings.d.ts → stack/identity-bindings.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -46,40 +46,44 @@ const adapter = await LocalAdapter.open({
|
|
|
46
46
|
// safe to keep passing on every open, it's a no-op once the record exists.
|
|
47
47
|
const stack = await Stack.open(adapter, { ownerProfile: { name: 'Jane Smith' } });
|
|
48
48
|
|
|
49
|
-
// Define a type. The handle carries the id and schema, and the compiler
|
|
49
|
+
// Define a type. The handle carries the id, name and schema, and the compiler
|
|
50
50
|
// derives the content type from it — no separate interface to keep in step.
|
|
51
|
-
const Note = typeHandle(
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
const Note = typeHandle({
|
|
52
|
+
id: 'com.example.myapp/note@1',
|
|
53
|
+
name: 'Note',
|
|
54
|
+
schema: {
|
|
55
|
+
text: { kind: 'text', required: true },
|
|
56
|
+
title: { kind: 'string' },
|
|
57
|
+
},
|
|
54
58
|
});
|
|
55
|
-
await stack.defineType(
|
|
59
|
+
await stack.defineType(Note);
|
|
60
|
+
|
|
61
|
+
// Typed access to the Note family
|
|
62
|
+
const notes = stack.collection(Note);
|
|
56
63
|
|
|
57
64
|
// Create a record
|
|
58
|
-
const note = await
|
|
65
|
+
const note = await notes.create({
|
|
59
66
|
text: 'Hello, Haverstack!',
|
|
60
67
|
title: 'My first note',
|
|
61
68
|
});
|
|
62
69
|
|
|
63
70
|
// Update its content (partial merge — only changed fields needed)
|
|
64
|
-
await
|
|
71
|
+
await notes.patchContent(note.id, { title: 'Updated title' });
|
|
65
72
|
|
|
66
73
|
// Read it back, typed: `content.text` is a string
|
|
67
|
-
const same = await
|
|
74
|
+
const same = await notes.get(note.id);
|
|
68
75
|
|
|
69
76
|
// Tag it
|
|
70
|
-
await
|
|
77
|
+
await notes.editAssociations(note.id, [
|
|
78
|
+
{ op: 'add', association: { kind: 'tag', label: 'favourite' } },
|
|
79
|
+
]);
|
|
71
80
|
|
|
72
|
-
//
|
|
73
|
-
await
|
|
74
|
-
contentPatch: { title: 'Final title' },
|
|
75
|
-
permissions: [{ kind: 'anyone', label: 'read' }],
|
|
76
|
-
unlisted: false,
|
|
77
|
-
});
|
|
81
|
+
// Share it with everyone — each aspect of a record has its own verb
|
|
82
|
+
await notes.setAccess(note.id, [{ kind: 'anyone', label: 'read' }]);
|
|
78
83
|
|
|
79
|
-
// Query
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
filter: { typeId: 'com.example.myapp/note@1', tags: ['favourite'] },
|
|
84
|
+
// Query the family. stack.query() reads across every type you can see instead.
|
|
85
|
+
const { records: favourites } = await notes.query({
|
|
86
|
+
filter: { tags: ['favourite'] },
|
|
83
87
|
sort: { field: 'createdAt', direction: 'desc' },
|
|
84
88
|
});
|
|
85
89
|
|
|
@@ -92,60 +96,117 @@ await stack.close();
|
|
|
92
96
|
An app has two layers, because the stack draws a line between them:
|
|
93
97
|
|
|
94
98
|
- **A data layer that takes a `StackClient`** — the record API `Stack` and `ScopedStack` both implement. The same code then runs embedded as the owner, or behind a server as a requester who reaches only what they were granted.
|
|
95
|
-
- **An install
|
|
99
|
+
- **An install that the owner approves.** Defining types, `migrateAll()` and `grantType()` change the whole stack rather than one record, so they live on `Stack` alone and are absent from `StackClient`.
|
|
96
100
|
|
|
97
|
-
|
|
101
|
+
Migrations belong to neither. They are passed to `Stack.open()` on **every startup**, so the chain is complete before the first read.
|
|
98
102
|
|
|
99
103
|
```ts
|
|
100
|
-
import { Stack,
|
|
104
|
+
import { Stack, map, migration, typeHandle } from '@haverstack/core';
|
|
105
|
+
import type { AppManifest, Collection, ContentOf, StackClient } from '@haverstack/core';
|
|
101
106
|
|
|
102
|
-
const
|
|
103
|
-
|
|
107
|
+
export const Shelf = typeHandle({
|
|
108
|
+
id: 'com.example.reading/shelf@1',
|
|
109
|
+
name: 'Shelf',
|
|
110
|
+
schema: { name: { kind: 'string', required: true } },
|
|
104
111
|
});
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
const BookV1 = typeHandle({
|
|
113
|
+
id: 'com.example.reading/book@1',
|
|
114
|
+
name: 'Book',
|
|
115
|
+
schema: {
|
|
116
|
+
title: { kind: 'string', required: true },
|
|
117
|
+
status: { kind: 'enum', values: ['reading', 'done'], required: true },
|
|
118
|
+
},
|
|
108
119
|
});
|
|
120
|
+
export const Book = typeHandle({
|
|
121
|
+
id: 'com.example.reading/book@2',
|
|
122
|
+
name: 'Book',
|
|
123
|
+
migratesFrom: BookV1,
|
|
124
|
+
schema: {
|
|
125
|
+
title: { kind: 'string', required: true },
|
|
126
|
+
status: { kind: 'enum', values: ['reading', 'finished'], required: true },
|
|
127
|
+
shelfId: { kind: 'record-ref' },
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
export const Review = typeHandle({
|
|
131
|
+
id: 'com.example.reading/review@1',
|
|
132
|
+
name: 'Review',
|
|
133
|
+
schema: {
|
|
134
|
+
bookId: { kind: 'record-ref', required: true },
|
|
135
|
+
rating: { kind: 'number', required: true },
|
|
136
|
+
text: { kind: 'text' },
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
export interface ShelfContent extends ContentOf<typeof Shelf.schema> {}
|
|
141
|
+
export interface BookContent extends ContentOf<typeof Book.schema> {}
|
|
142
|
+
export interface ReviewContent extends ContentOf<typeof Review.schema> {}
|
|
143
|
+
|
|
144
|
+
// Startup: pass to every Stack.open(). Each step is checked against both
|
|
145
|
+
// handles' schemas when migration() runs.
|
|
146
|
+
export const migrations = [migration(BookV1, Book, [map('status', { done: 'finished' })])];
|
|
109
147
|
|
|
110
|
-
// Data layer:
|
|
111
|
-
export class
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
148
|
+
// Data layer: one collection per type, and typed records from every verb.
|
|
149
|
+
export class Library {
|
|
150
|
+
readonly shelves: Collection<ShelfContent>;
|
|
151
|
+
readonly books: Collection<BookContent>;
|
|
152
|
+
readonly reviews: Collection<ReviewContent>;
|
|
153
|
+
|
|
154
|
+
constructor(client: StackClient) {
|
|
155
|
+
this.shelves = client.collection(Shelf);
|
|
156
|
+
this.books = client.collection(Book);
|
|
157
|
+
this.reviews = client.collection(Review);
|
|
115
158
|
}
|
|
116
|
-
|
|
117
|
-
|
|
159
|
+
|
|
160
|
+
addBook(title: string, shelfId: string) {
|
|
161
|
+
return this.books.create({ title, status: 'reading', shelfId });
|
|
118
162
|
}
|
|
119
|
-
|
|
120
|
-
|
|
163
|
+
|
|
164
|
+
finish(id: string) {
|
|
165
|
+
return this.books.patchContent(id, { status: 'finished' });
|
|
121
166
|
}
|
|
122
|
-
}
|
|
123
167
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
from: NoteV1.id,
|
|
128
|
-
to: Note.id,
|
|
129
|
-
migrate: (content) => ({ ...content, pinned: false }),
|
|
130
|
-
});
|
|
131
|
-
}
|
|
168
|
+
review(bookId: string, rating: number, text?: string) {
|
|
169
|
+
return this.reviews.create({ bookId, rating, ...(text && { text }) });
|
|
170
|
+
}
|
|
132
171
|
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (appDid) {
|
|
140
|
-
await stack.grantType(Note.baseId, {
|
|
141
|
-
actions: ['create', 'read-own', 'update-own', 'delete-own'],
|
|
142
|
-
grantee: { kind: 'entity', entityId: appDid },
|
|
172
|
+
// Records at @1 come back migrated to @2. One written by a newer release
|
|
173
|
+
// of the app can't be migrated down, so it is reported, not thrown.
|
|
174
|
+
async onShelf(shelfId: string) {
|
|
175
|
+
const page = await this.books.query({
|
|
176
|
+
filter: { content: { shelfId } },
|
|
177
|
+
sort: { contentField: 'title' },
|
|
143
178
|
});
|
|
179
|
+
if (page.misfits.length > 0) {
|
|
180
|
+
console.warn(`${page.misfits.length} books need a newer version of this app`);
|
|
181
|
+
}
|
|
182
|
+
return page.records;
|
|
144
183
|
}
|
|
145
184
|
}
|
|
185
|
+
|
|
186
|
+
// Install, when the owner runs the app. defineType() is a no-op for a
|
|
187
|
+
// schema already stored, so re-running after an upgrade is safe.
|
|
188
|
+
export async function install(stack: Stack) {
|
|
189
|
+
for (const handle of [Shelf, BookV1, Book, Review]) await stack.defineType(handle);
|
|
190
|
+
await stack.migrateAll(Book.baseId);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Install, when the owner doesn't: the same handles, as a request.
|
|
194
|
+
export const manifest: AppManifest = {
|
|
195
|
+
appId: 'com.example.reading',
|
|
196
|
+
name: 'Reading',
|
|
197
|
+
types: [Shelf, BookV1, Book, Review],
|
|
198
|
+
requests: [Shelf, Book, Review].map((handle) => ({
|
|
199
|
+
baseId: handle.baseId,
|
|
200
|
+
actions: ['create', 'read-own', 'update-own', 'delete-own'],
|
|
201
|
+
})),
|
|
202
|
+
};
|
|
146
203
|
```
|
|
147
204
|
|
|
148
|
-
|
|
205
|
+
**When the owner runs the app**, it opens with `Stack.open(adapter, { migrations })`, calls `install(stack)`, then `new Library(stack)`. A server hands each requester `new Library(stack.asActor(session))`.
|
|
206
|
+
|
|
207
|
+
**When the app runs elsewhere** — holding its own key and reaching the stack through `APIAdapter` — it can't define types, so it asks: `adapter.requestInstall(manifest)` resolves to `pending` until the owner approves. The owner reviews `stack.planInstall(manifest, { did })` and applies it with `stack.installApp(plan)`, which defines the types, registers the app's key and grants it the requests. The app still opens with its `migrations`, then builds `new Library(stack)` over its own connection. See [App installs](https://github.com/haverstack/core/blob/main/docs/spec/apps.md).
|
|
208
|
+
|
|
209
|
+
**Name content with an interface**, as above. `ContentOf` flattens its result, so a `type` alias shows the expanded object in editor hovers, while an interface keeps its name. typescript-eslint's recommended `no-empty-object-type` rule rejects an empty interface; its `allowInterfaces: 'with-single-extends'` option admits this one.
|
|
149
210
|
|
|
150
211
|
## Core concepts
|
|
151
212
|
|
|
@@ -186,24 +247,23 @@ Tags, attachments, and relationships are unified under a single model:
|
|
|
186
247
|
|
|
187
248
|
### Migrations
|
|
188
249
|
|
|
189
|
-
Types can evolve over time.
|
|
250
|
+
Types can evolve over time. A new version names the one it migrates from, and `migration()` builds the step between two handles from declared steps — `rename`, `map`, `add`, `drop` and `derive` — checked against both schemas when it runs. Pass every step to `Stack.open()`; the library composes adjacent steps into chains:
|
|
190
251
|
|
|
191
252
|
```ts
|
|
192
|
-
|
|
253
|
+
const NoteV2 = typeHandle({
|
|
193
254
|
id: 'com.example.myapp/note@2',
|
|
194
255
|
name: 'Note',
|
|
256
|
+
migratesFrom: NoteV1,
|
|
195
257
|
schema: { text: { kind: 'text', required: true }, title: { kind: 'string' } },
|
|
196
|
-
migratesFrom: 'com.example.myapp/note@1',
|
|
197
258
|
});
|
|
198
259
|
|
|
199
|
-
stack.
|
|
200
|
-
|
|
201
|
-
to: 'com.example.myapp/note@2',
|
|
202
|
-
migrate: (content) => ({ ...content, title: '' }),
|
|
260
|
+
const stack = await Stack.open(adapter, {
|
|
261
|
+
migrations: [migration(NoteV1, NoteV2, [add('title', '')])],
|
|
203
262
|
});
|
|
263
|
+
await stack.defineType(NoteV2);
|
|
204
264
|
```
|
|
205
265
|
|
|
206
|
-
Records stay at the version they were written at: `get()` and `query()` return them as stored, `presentAt: 'latest'` migrates them in memory for one read, and `stack.migrateAll()` commits a family to disk.
|
|
266
|
+
Records stay at the version they were written at: `get()` and `query()` return them as stored, `presentAt: 'latest'` migrates them in memory for one read, and `stack.migrateAll()` commits a family to disk. Because the steps are declared rather than a function, a collection carries a content filter or sort across the versions they translate to — a filter on `title` reaches records still at `@1`. Pass migrations at every startup — see [Writing an app](#writing-an-app).
|
|
207
267
|
|
|
208
268
|
## License
|
|
209
269
|
|
package/dist/access.d.ts
CHANGED
|
@@ -1,78 +1,74 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stack — Permission Resolution
|
|
3
3
|
* -------------------------------------------------------
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* The record-level permission rules, with no dependency on a transport
|
|
5
|
+
* layer or storage backend: how a grantee reaches an entity, the
|
|
6
|
+
* write-implies-read invariant, and the `_group` roster rules both layers
|
|
7
|
+
* resolve against, plus the owner-acting-alone tier. What a requester may
|
|
8
|
+
* do is a reach per verb, which scoped-stack/authority.ts builds from these.
|
|
9
9
|
*
|
|
10
10
|
* A permission set is read as data throughout, never as the type: it can
|
|
11
11
|
* arrive from an import or a foreign server, where no compiler has seen
|
|
12
12
|
* it. Every tier is affirmative, so an element read past its own kind —
|
|
13
13
|
* or carrying no kind at all — confers nothing.
|
|
14
14
|
*/
|
|
15
|
-
import type {
|
|
15
|
+
import type { AuthorityAssociation, DataAssociation, EntityId, GroupRole, Grantee, RecordId, StackRecord } from './types/index.js';
|
|
16
16
|
import type { ValidationError } from './validate.js';
|
|
17
|
-
|
|
17
|
+
/** Resolves a Record by ID, for walks that need no full adapter. */
|
|
18
|
+
export type RecordResolver = (id: RecordId) => Promise<StackRecord | null>;
|
|
18
19
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
20
|
+
* The roles one entity holds on live `_group` rosters, keyed by group id,
|
|
21
|
+
* loaded on first use and at most once per operation, so no resolved role
|
|
22
|
+
* outlives the operation that read it — removal from a group must never go
|
|
23
|
+
* stale. See loadRosterRoles().
|
|
21
24
|
*/
|
|
22
|
-
export type
|
|
25
|
+
export type RosterRoles = () => Promise<ReadonlyMap<RecordId, GroupRole>>;
|
|
23
26
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* A delegated app's own authority is a separate question, asked of the
|
|
29
|
-
* principal. See docs/spec/access-control.md § Record-level permissions and
|
|
30
|
-
* § Delegation: principal and subject.
|
|
27
|
+
* The elements beside which a `write` naming `grantee` carries its bit: its
|
|
28
|
+
* own `read`, or an `anyone`, which reaches the grantee already. The one
|
|
29
|
+
* statement of write-implies-read — validation and every reach ask it.
|
|
30
|
+
* See docs/spec/access-control.md § Write implies read.
|
|
31
31
|
*/
|
|
32
|
-
export declare function
|
|
32
|
+
export declare function readCompanions(grantee: Grantee): AuthorityAssociation[];
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* Whether a grantee reaches `entityId`, for both layers alike. Read as data,
|
|
35
|
+
* so an arm the write would refuse confers nothing; `member` is the wider
|
|
36
|
+
* role. See docs/spec/access-control.md § Refused at the write, and again at evaluation.
|
|
37
|
+
*/
|
|
38
|
+
export declare function granteeReaches(grantee: unknown, entityId: EntityId, opts: {
|
|
39
|
+
roles: RosterRoles;
|
|
40
|
+
allowGroup?: boolean;
|
|
41
|
+
}): Promise<boolean>;
|
|
42
|
+
/**
|
|
43
|
+
* Rejects a permission set where some `write` stands without a
|
|
44
|
+
* readCompanions() element, which no reach would honor. A cross-element
|
|
36
45
|
* invariant, so it is asked of the set the write would *produce*, the same
|
|
37
46
|
* way hasGroupAdmin() reads its post-state below.
|
|
38
47
|
* See docs/spec/access-control.md § Write implies read.
|
|
39
48
|
*/
|
|
40
49
|
export declare function validatePermissions(permissions: AuthorityAssociation[] | undefined, path?: string): ValidationError[];
|
|
50
|
+
/** Whether a held role meets a grantee's: an admin is also a member. */
|
|
51
|
+
export declare function roleSatisfies(held: GroupRole | null, required: GroupRole): boolean;
|
|
41
52
|
/**
|
|
42
|
-
* Whether a Record is a
|
|
43
|
-
*
|
|
44
|
-
* would turn every record it points a permission at into an ACL. The
|
|
45
|
-
* tombstone check: deleting a Group is how a Group is withdrawn. Shared
|
|
46
|
-
* with the grant path, so the two layers cannot disagree about which Groups
|
|
47
|
-
* still reach anyone. See docs/spec/identity.md § Group.
|
|
53
|
+
* Whether a Record is a `_group`, in any of its type versions — the family
|
|
54
|
+
* whose roster rules apply.
|
|
48
55
|
*/
|
|
49
|
-
export declare function
|
|
56
|
+
export declare function isGroupRecord(record: StackRecord): boolean;
|
|
50
57
|
/**
|
|
51
|
-
* An entity's role
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* Reads associations alone and cannot tell whose they are: callers pass
|
|
55
|
-
* carriesRoster() first, or an app's own `member` relationships become a
|
|
56
|
-
* roster.
|
|
58
|
+
* An entity's role on a `_group` roster; a matching `admin` wins regardless
|
|
59
|
+
* of order. Reads associations alone and cannot tell whose they are, so
|
|
60
|
+
* callers ask it of a live `_group` only — see loadRosterRoles().
|
|
57
61
|
*/
|
|
58
62
|
export declare function groupRoleFromAssociations(associations: DataAssociation[] | undefined, entityId: EntityId): GroupRole | null;
|
|
59
63
|
/**
|
|
60
|
-
* Whether a roster carries at least one `admin` — the invariant every
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* removal when they are the last, without either case naming who is going.
|
|
65
|
-
* Reads associations alone, on the same terms as
|
|
66
|
-
* groupRoleFromAssociations() above.
|
|
64
|
+
* Whether a roster carries at least one `admin` — the invariant every
|
|
65
|
+
* `_group` write must leave standing. Asked of the roster the write would
|
|
66
|
+
* *produce*, which lets an admin leave while another remains and refuses
|
|
67
|
+
* the last. Reads associations alone, like groupRoleFromAssociations().
|
|
67
68
|
*/
|
|
68
69
|
export declare function hasGroupAdmin(associations: DataAssociation[] | undefined): boolean;
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
* what makes it one — a `record` target carrying the same label confers
|
|
72
|
-
* nothing, so it must not count toward the invariant either.
|
|
73
|
-
* See docs/spec/data-model.md § Relationship targets.
|
|
74
|
-
*/
|
|
75
|
-
export declare function isGroupAdminAssociation(association: Association): boolean;
|
|
70
|
+
/** Bootstraps a `_group` record's first admin at create time. */
|
|
71
|
+
export declare function stampGroupAdmin(associations: DataAssociation[] | undefined, creator: EntityId): DataAssociation[];
|
|
76
72
|
/**
|
|
77
73
|
* Whether a request is the stack owner acting alone — the tier behind every
|
|
78
74
|
* unconditional owner authority. Being the owner is never sufficient on its
|
package/dist/access.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"access.d.ts","sourceRoot":"","sources":["../src/access.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"access.d.ts","sourceRoot":"","sources":["../src/access.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,KAAK,EAEV,oBAAoB,EACpB,eAAe,EACf,QAAQ,EAER,SAAS,EACT,OAAO,EACP,QAAQ,EAER,WAAW,EACZ,MAAM,kBAAkB,CAAC;AAG1B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAErD,oEAAoE;AACpE,MAAM,MAAM,cAAc,GAAG,CAAC,EAAE,EAAE,QAAQ,KAAK,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;AAE3E;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GAAG,MAAM,OAAO,CAAC,WAAW,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC,CAAC;AAE1E;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,OAAO,GAAG,oBAAoB,EAAE,CAKvE;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CAClC,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,QAAQ,EAClB,IAAI,EAAE;IAAE,KAAK,EAAE,WAAW,CAAC;IAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAAE,GACjD,OAAO,CAAC,OAAO,CAAC,CAelB;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,oBAAoB,EAAE,GAAG,SAAS,EAC/C,IAAI,SAAgB,GACnB,eAAe,EAAE,CAcnB;AAED,wEAAwE;AACxE,wBAAgB,aAAa,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,EAAE,QAAQ,EAAE,SAAS,GAAG,OAAO,CAElF;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAE1D;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CACvC,YAAY,EAAE,eAAe,EAAE,GAAG,SAAS,EAC3C,QAAQ,EAAE,QAAQ,GACjB,SAAS,GAAG,IAAI,CASlB;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,YAAY,EAAE,eAAe,EAAE,GAAG,SAAS,GAAG,OAAO,CAElF;AAkBD,iEAAiE;AACjE,wBAAgB,eAAe,CAC7B,YAAY,EAAE,eAAe,EAAE,GAAG,SAAS,EAC3C,OAAO,EAAE,QAAQ,GAChB,eAAe,EAAE,CAUnB;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE;IAAE,SAAS,EAAE,QAAQ,GAAG,IAAI,CAAC;IAAC,WAAW,CAAC,EAAE,QAAQ,GAAG,IAAI,CAAA;CAAE,EACpE,aAAa,EAAE,QAAQ,GACtB,OAAO,CAGT"}
|
package/dist/access.js
CHANGED
|
@@ -1,137 +1,94 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stack — Permission Resolution
|
|
3
3
|
* -------------------------------------------------------
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* The record-level permission rules, with no dependency on a transport
|
|
5
|
+
* layer or storage backend: how a grantee reaches an entity, the
|
|
6
|
+
* write-implies-read invariant, and the `_group` roster rules both layers
|
|
7
|
+
* resolve against, plus the owner-acting-alone tier. What a requester may
|
|
8
|
+
* do is a reach per verb, which scoped-stack/authority.ts builds from these.
|
|
9
9
|
*
|
|
10
10
|
* A permission set is read as data throughout, never as the type: it can
|
|
11
11
|
* arrive from an import or a foreign server, where no compiler has seen
|
|
12
12
|
* it. Every tier is affirmative, so an element read past its own kind —
|
|
13
13
|
* or carrying no kind at all — confers nothing.
|
|
14
14
|
*/
|
|
15
|
-
import { SYSTEM_TYPES } from './types.js';
|
|
16
|
-
import {
|
|
15
|
+
import { SYSTEM_TYPES } from './types/index.js';
|
|
16
|
+
import { associationKey } from './associations/identity.js';
|
|
17
17
|
import { baseIdOf } from './schema.js';
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* about who data is for, never about the software that carried the request.
|
|
23
|
-
* A delegated app's own authority is a separate question, asked of the
|
|
24
|
-
* principal. See docs/spec/access-control.md § Record-level permissions and
|
|
25
|
-
* § Delegation: principal and subject.
|
|
26
|
-
*/
|
|
27
|
-
export async function checkAccess(record, subjectEntityId, ownerEntityId, mode, resolveRecord) {
|
|
28
|
-
if (subjectEntityId && subjectEntityId === ownerEntityId)
|
|
29
|
-
return true;
|
|
30
|
-
const perms = record.permissions;
|
|
31
|
-
// No permissions = private.
|
|
32
|
-
if (!perms || perms.length === 0)
|
|
33
|
-
return false;
|
|
34
|
-
for (const p of perms) {
|
|
35
|
-
if (p?.kind === 'anyone') {
|
|
36
|
-
if (mode === 'read' && isWorldRead(p))
|
|
37
|
-
return true;
|
|
38
|
-
continue;
|
|
39
|
-
}
|
|
40
|
-
if (p?.kind !== 'permission' || !p.grantee)
|
|
41
|
-
continue;
|
|
42
|
-
if (p.label !== mode)
|
|
43
|
-
continue;
|
|
44
|
-
// The write bit is inert without read alongside it: the mutate surface
|
|
45
|
-
// hands back the record it wrote and opens its whole history, so a
|
|
46
|
-
// write element standing alone would withhold nothing. Refused at the
|
|
47
|
-
// write by validatePermissions() and again here.
|
|
48
|
-
// See docs/spec/access-control.md § Write implies read.
|
|
49
|
-
if (mode === 'write' && !holdsRead(perms, p.grantee))
|
|
50
|
-
continue;
|
|
51
|
-
if (await granteeCovers(p.grantee, subjectEntityId, resolveRecord))
|
|
52
|
-
return true;
|
|
53
|
-
}
|
|
54
|
-
return false;
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
|
-
* Whether the set conveys read to this grantee — a `read` of its own, or
|
|
58
|
-
* an `anyone` that already reaches everyone. Demanding a second, redundant
|
|
59
|
-
* `read` beside the write would refuse a set that withholds nothing;
|
|
60
|
-
* revoking the `anyone` is what the invariant catches instead.
|
|
19
|
+
* The elements beside which a `write` naming `grantee` carries its bit: its
|
|
20
|
+
* own `read`, or an `anyone`, which reaches the grantee already. The one
|
|
21
|
+
* statement of write-implies-read — validation and every reach ask it.
|
|
61
22
|
* See docs/spec/access-control.md § Write implies read.
|
|
62
23
|
*/
|
|
63
|
-
function
|
|
64
|
-
return
|
|
65
|
-
|
|
66
|
-
|
|
24
|
+
export function readCompanions(grantee) {
|
|
25
|
+
return [
|
|
26
|
+
{ kind: 'permission', label: 'read', grantee },
|
|
27
|
+
{ kind: 'anyone', label: 'read' },
|
|
28
|
+
];
|
|
67
29
|
}
|
|
68
30
|
/**
|
|
69
|
-
* Whether
|
|
70
|
-
*
|
|
71
|
-
*
|
|
31
|
+
* Whether a grantee reaches `entityId`, for both layers alike. Read as data,
|
|
32
|
+
* so an arm the write would refuse confers nothing; `member` is the wider
|
|
33
|
+
* role. See docs/spec/access-control.md § Refused at the write, and again at evaluation.
|
|
72
34
|
*/
|
|
73
|
-
function
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Whether a grantee reaches this subject. `role: 'member'` is the wider
|
|
78
|
-
* set — an admin satisfies it — and `role: 'admin'` the narrower.
|
|
79
|
-
*/
|
|
80
|
-
async function granteeCovers(grantee, subjectEntityId, resolveRecord) {
|
|
81
|
-
if (!subjectEntityId)
|
|
35
|
+
export async function granteeReaches(grantee, entityId, opts) {
|
|
36
|
+
if (!grantee || typeof grantee !== 'object')
|
|
82
37
|
return false;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
38
|
+
const g = grantee;
|
|
39
|
+
switch (g.kind) {
|
|
40
|
+
case 'entity':
|
|
41
|
+
return !!g.entityId && g.entityId === entityId;
|
|
42
|
+
case 'group': {
|
|
43
|
+
if (opts.allowGroup === false || !g.groupId)
|
|
44
|
+
return false;
|
|
45
|
+
if (g.role !== 'member' && g.role !== 'admin')
|
|
46
|
+
return false;
|
|
47
|
+
const held = (await opts.roles()).get(g.groupId) ?? null;
|
|
48
|
+
return roleSatisfies(held, g.role);
|
|
49
|
+
}
|
|
50
|
+
default:
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
87
53
|
}
|
|
88
54
|
/**
|
|
89
|
-
* Rejects a permission set where some
|
|
90
|
-
*
|
|
55
|
+
* Rejects a permission set where some `write` stands without a
|
|
56
|
+
* readCompanions() element, which no reach would honor. A cross-element
|
|
91
57
|
* invariant, so it is asked of the set the write would *produce*, the same
|
|
92
58
|
* way hasGroupAdmin() reads its post-state below.
|
|
93
59
|
* See docs/spec/access-control.md § Write implies read.
|
|
94
60
|
*/
|
|
95
61
|
export function validatePermissions(permissions, path = 'permissions') {
|
|
96
62
|
const list = permissions ?? [];
|
|
63
|
+
const held = new Set(list.map(associationKey));
|
|
97
64
|
const errors = [];
|
|
98
65
|
list.forEach((p, i) => {
|
|
99
66
|
if (p?.kind !== 'permission' || p.label !== 'write')
|
|
100
67
|
return;
|
|
101
|
-
if (
|
|
68
|
+
if (readCompanions(p.grantee).some((c) => held.has(associationKey(c))))
|
|
102
69
|
return;
|
|
103
70
|
errors.push({
|
|
104
71
|
path: `${path}[${i}]`,
|
|
105
|
-
message: 'write requires read: a write-holder reaches the record and its history through the mutate surface, so a `write` element with no `read` for the same grantee withholds nothing. Grant `read` and `write` together
|
|
72
|
+
message: 'write requires read: a write-holder reaches the record and its history through the mutate surface, so a `write` element with no `read` for the same grantee withholds nothing. Grant `read` and `write` together in one editAccess() or setAccess() call',
|
|
106
73
|
});
|
|
107
74
|
});
|
|
108
75
|
return errors;
|
|
109
76
|
}
|
|
110
|
-
/**
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (!group || !carriesRoster(group))
|
|
114
|
-
return null;
|
|
115
|
-
return groupRoleFromAssociations(group.associations, entityId);
|
|
77
|
+
/** Whether a held role meets a grantee's: an admin is also a member. */
|
|
78
|
+
export function roleSatisfies(held, required) {
|
|
79
|
+
return required === 'admin' ? held === 'admin' : held !== null;
|
|
116
80
|
}
|
|
117
81
|
/**
|
|
118
|
-
* Whether a Record is a
|
|
119
|
-
*
|
|
120
|
-
* would turn every record it points a permission at into an ACL. The
|
|
121
|
-
* tombstone check: deleting a Group is how a Group is withdrawn. Shared
|
|
122
|
-
* with the grant path, so the two layers cannot disagree about which Groups
|
|
123
|
-
* still reach anyone. See docs/spec/identity.md § Group.
|
|
82
|
+
* Whether a Record is a `_group`, in any of its type versions — the family
|
|
83
|
+
* whose roster rules apply.
|
|
124
84
|
*/
|
|
125
|
-
export function
|
|
126
|
-
return baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP
|
|
85
|
+
export function isGroupRecord(record) {
|
|
86
|
+
return baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP;
|
|
127
87
|
}
|
|
128
88
|
/**
|
|
129
|
-
* An entity's role
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* Reads associations alone and cannot tell whose they are: callers pass
|
|
133
|
-
* carriesRoster() first, or an app's own `member` relationships become a
|
|
134
|
-
* roster.
|
|
89
|
+
* An entity's role on a `_group` roster; a matching `admin` wins regardless
|
|
90
|
+
* of order. Reads associations alone and cannot tell whose they are, so
|
|
91
|
+
* callers ask it of a live `_group` only — see loadRosterRoles().
|
|
135
92
|
*/
|
|
136
93
|
export function groupRoleFromAssociations(associations, entityId) {
|
|
137
94
|
let role = null;
|
|
@@ -146,13 +103,10 @@ export function groupRoleFromAssociations(associations, entityId) {
|
|
|
146
103
|
return role;
|
|
147
104
|
}
|
|
148
105
|
/**
|
|
149
|
-
* Whether a roster carries at least one `admin` — the invariant every
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* removal when they are the last, without either case naming who is going.
|
|
154
|
-
* Reads associations alone, on the same terms as
|
|
155
|
-
* groupRoleFromAssociations() above.
|
|
106
|
+
* Whether a roster carries at least one `admin` — the invariant every
|
|
107
|
+
* `_group` write must leave standing. Asked of the roster the write would
|
|
108
|
+
* *produce*, which lets an admin leave while another remains and refuses
|
|
109
|
+
* the last. Reads associations alone, like groupRoleFromAssociations().
|
|
156
110
|
*/
|
|
157
111
|
export function hasGroupAdmin(associations) {
|
|
158
112
|
return (associations ?? []).some(isGroupAdminAssociation);
|
|
@@ -163,11 +117,22 @@ export function hasGroupAdmin(associations) {
|
|
|
163
117
|
* nothing, so it must not count toward the invariant either.
|
|
164
118
|
* See docs/spec/data-model.md § Relationship targets.
|
|
165
119
|
*/
|
|
166
|
-
|
|
120
|
+
function isGroupAdminAssociation(association) {
|
|
167
121
|
return (association.kind === 'relationship' &&
|
|
168
122
|
association.label === 'admin' &&
|
|
169
123
|
association.target.kind === 'entity');
|
|
170
124
|
}
|
|
125
|
+
/** Bootstraps a `_group` record's first admin at create time. */
|
|
126
|
+
export function stampGroupAdmin(associations, creator) {
|
|
127
|
+
const list = associations ?? [];
|
|
128
|
+
const alreadyAdmin = list.some((a) => isGroupAdminAssociation(a) && a.target.entityId === creator);
|
|
129
|
+
if (alreadyAdmin)
|
|
130
|
+
return list;
|
|
131
|
+
return [
|
|
132
|
+
...list,
|
|
133
|
+
{ kind: 'relationship', label: 'admin', target: { kind: 'entity', entityId: creator } },
|
|
134
|
+
];
|
|
135
|
+
}
|
|
171
136
|
/**
|
|
172
137
|
* Whether a request is the stack owner acting alone — the tier behind every
|
|
173
138
|
* unconditional owner authority. Being the owner is never sufficient on its
|