@haverstack/core 0.34.0 → 0.36.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 +1 -1
- package/dist/access.d.ts +39 -34
- package/dist/access.d.ts.map +1 -1
- package/dist/access.js +94 -68
- package/dist/access.js.map +1 -1
- package/dist/changes.d.ts +22 -33
- package/dist/changes.d.ts.map +1 -1
- package/dist/changes.js +34 -48
- package/dist/changes.js.map +1 -1
- package/dist/combine.d.ts.map +1 -1
- package/dist/combine.js +7 -0
- package/dist/combine.js.map +1 -1
- package/dist/grants.d.ts +78 -33
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +164 -72
- package/dist/grants.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/query-validation.d.ts +20 -1
- package/dist/query-validation.d.ts.map +1 -1
- package/dist/query-validation.js +107 -3
- package/dist/query-validation.js.map +1 -1
- package/dist/record-changes.d.ts +71 -63
- package/dist/record-changes.d.ts.map +1 -1
- package/dist/record-changes.js +129 -85
- package/dist/record-changes.js.map +1 -1
- package/dist/scoped-stack.d.ts +118 -116
- package/dist/scoped-stack.d.ts.map +1 -1
- package/dist/scoped-stack.js +210 -188
- package/dist/scoped-stack.js.map +1 -1
- package/dist/stack.d.ts +143 -160
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +271 -225
- package/dist/stack.js.map +1 -1
- package/dist/testing.d.ts +3 -8
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +42 -49
- package/dist/testing.js.map +1 -1
- package/dist/types.d.ts +128 -101
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +0 -2
- package/dist/validate.js.map +1 -1
- package/dist/wire-record.d.ts.map +1 -1
- package/dist/wire-record.js.map +1 -1
- package/package.json +1 -1
package/dist/scoped-stack.js
CHANGED
|
@@ -23,13 +23,13 @@ import { checkAccess, groupRoleFromAssociations, isOwnerActingAlone } from './ac
|
|
|
23
23
|
import { ChangeEmitter, Subscription, matchesFilter, passesUnlistedBoundary, assertSinceUsable, } from './changes.js';
|
|
24
24
|
import { SYSTEM_TYPES } from './types.js';
|
|
25
25
|
import { StackConflictError, StackError, StackNotFoundError, StackPermissionError, StackRelayScopeError, StackValidationError, } from './errors.js';
|
|
26
|
-
import { assertSortCapability, assertValidRelatedTo, assertValidSort, filtersContent, } from './query-validation.js';
|
|
27
|
-
import { grantConveys, grantCoversGrantee, loadGrantRecords, UNGRANTABLE_SYSTEM_TYPES, } from './grants.js';
|
|
26
|
+
import { assertAuthorityAssociations, assertDataAssociations, assertSortCapability, assertValidRelatedTo, assertValidSort, filtersContent, } from './query-validation.js';
|
|
27
|
+
import { grantConveys, grantCoversGrantee, grantReach, loadGrantRecords, UNGRANTABLE_SYSTEM_TYPES, } from './grants.js';
|
|
28
28
|
import { bindingFieldsOf } from './identity-bindings.js';
|
|
29
29
|
import { assertAttachmentSize } from './limits.js';
|
|
30
30
|
import { validateIdTimestampSkew, validateRecordId } from './record-id.js';
|
|
31
31
|
import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT, findFirstMatch, lookupEntityByDid, queryAllPages, } from './stack-reads.js';
|
|
32
|
-
import { associationEqual, isGroupRecord, presentDeleted,
|
|
32
|
+
import { associationDelta, associationEqual, isGroupRecord, presentDeleted, withoutAuthorityChanges, assertNonEmptyChangeSet, } from './record-changes.js';
|
|
33
33
|
/**
|
|
34
34
|
* The authority lookups canRead needs, held for the life of one
|
|
35
35
|
* subscription. A subscription is long-lived where a query is not, so the
|
|
@@ -73,17 +73,11 @@ class FeedAuthorityCache {
|
|
|
73
73
|
* lookups it needs cached for the life of the subscription and dropped the
|
|
74
74
|
* moment anything that feeds them changes.
|
|
75
75
|
*
|
|
76
|
-
* Two properties do the work, and neither is optional
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* feed makes. Every emission is appended to a chain instead.
|
|
82
|
-
*
|
|
83
|
-
* **The filter fails closed.** A permission check that throws drops the
|
|
84
|
-
* event and reports the error; it never delivers on the assumption that a
|
|
85
|
-
* failed check would have passed.
|
|
86
|
-
*
|
|
76
|
+
* Two properties do the work, and neither is optional. **Deliveries are
|
|
77
|
+
* serialized**, because the permission decision is asynchronous and two
|
|
78
|
+
* changes to one record could otherwise resolve out of order, breaking the
|
|
79
|
+
* feed's one ordering guarantee. And **the filter fails closed**: a check
|
|
80
|
+
* that throws drops the event and reports the error.
|
|
87
81
|
* See docs/spec/events.md § Permission scoping.
|
|
88
82
|
*/
|
|
89
83
|
class ScopedSubscription extends Subscription {
|
|
@@ -145,21 +139,16 @@ class ScopedSubscription extends Subscription {
|
|
|
145
139
|
* cannot read answers exactly as a missing one does — null on reads,
|
|
146
140
|
* StackNotFoundError on the verbs that name one — so only a requester who
|
|
147
141
|
* could have read it is told a refusal was about access.
|
|
148
|
-
* See docs/spec/
|
|
142
|
+
* See docs/spec/disclosure.md § Which refusal a Record answers with.
|
|
149
143
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* identity: it answers *what data is reachable* for the subject (an owner
|
|
159
|
-
* subject resolves past every permission check) and *who may exercise a
|
|
160
|
-
* privileged verb* for the principal (an owner app is not bounded by
|
|
161
|
-
* grants). Under delegation both halves apply, and a mistake on the
|
|
162
|
-
* authority side is an escalation rather than a preference.
|
|
144
|
+
* Which identity each gate keys on follows the module comment's rule.
|
|
145
|
+
* Grant lookup and the privilege-bearing gates no grant reaches
|
|
146
|
+
* (resharing, group management, hard delete, widening access at create
|
|
147
|
+
* time) key on `principalEntityId`; authorship, `-own` matching,
|
|
148
|
+
* record-level permission resolution and "files I uploaded" lookups key on
|
|
149
|
+
* `subjectEntityId`. Unconditional owner access splits the same way: an
|
|
150
|
+
* owner subject resolves past every permission check, an owner principal
|
|
151
|
+
* is not bounded by grants, and under delegation both halves apply.
|
|
163
152
|
*/
|
|
164
153
|
export class ScopedStack {
|
|
165
154
|
stack;
|
|
@@ -195,6 +184,16 @@ export class ScopedStack {
|
|
|
195
184
|
get delegated() {
|
|
196
185
|
return this.subjectEntityId !== this.principalEntityId;
|
|
197
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Whether any grant could name this request — the one question every
|
|
189
|
+
* prefetch asks before scanning the `_grant` family, since an anonymous
|
|
190
|
+
* requester is reached by none. Both identities are read: `asEntity()`
|
|
191
|
+
* refuses an anonymous principal acting for a subject, so they are null
|
|
192
|
+
* together, and saying so costs nothing.
|
|
193
|
+
*/
|
|
194
|
+
get identified() {
|
|
195
|
+
return this.principalEntityId !== null || this.subjectEntityId !== null;
|
|
196
|
+
}
|
|
198
197
|
/**
|
|
199
198
|
* Who this request is, for stamping onto whatever it mutates. Attribution
|
|
200
199
|
* follows record-level authorship: the subject is the actor, and the
|
|
@@ -221,11 +220,7 @@ export class ScopedStack {
|
|
|
221
220
|
get ownerActingAlone() {
|
|
222
221
|
return isOwnerActingAlone({ principalId: this.principalEntityId, subjectId: this.subjectEntityId }, this.stack.ownerEntityId);
|
|
223
222
|
}
|
|
224
|
-
/**
|
|
225
|
-
* Refuse anything but the owner acting as itself. The verbs resting on
|
|
226
|
-
* this tier are irreversible or disclose the sharing graph, so delegation
|
|
227
|
-
* never carries one to a subject — see ownerActingAlone.
|
|
228
|
-
*/
|
|
223
|
+
/** Refuse anything but the owner acting as itself — see ownerActingAlone. */
|
|
229
224
|
requireOwnerActingAlone(message) {
|
|
230
225
|
if (!this.ownerActingAlone)
|
|
231
226
|
throw new StackPermissionError(message);
|
|
@@ -241,13 +236,12 @@ export class ScopedStack {
|
|
|
241
236
|
* type's family (grants match by baseId, so a version bump never orphans
|
|
242
237
|
* one). -own actions additionally require record.entityId === grantee,
|
|
243
238
|
* unless `matchOwn` is false — on the principal side of a delegated
|
|
244
|
-
* request the suffix
|
|
239
|
+
* request the suffix reads as the bare verb, since which records are
|
|
245
240
|
* reachable is the subject's business. `allowDefault` decides whether a
|
|
246
|
-
* grant naming nobody counts.
|
|
241
|
+
* grant naming nobody counts.
|
|
247
242
|
*
|
|
248
243
|
* Reached only through subjectAllows()/principalAllows(), which fix those
|
|
249
|
-
* two flags per side
|
|
250
|
-
* See docs/spec/access-control.md § Type-level grants.
|
|
244
|
+
* two flags per side. Call one of those instead.
|
|
251
245
|
*/
|
|
252
246
|
async hasGrant(typeId, actions, opts) {
|
|
253
247
|
const { grantee, record, prefetchedGrants, matchOwn = true, allowDefault = true, allowGroup = true, } = opts;
|
|
@@ -276,7 +270,8 @@ export class ScopedStack {
|
|
|
276
270
|
}
|
|
277
271
|
for (const r of grantRecords) {
|
|
278
272
|
const c = r.content;
|
|
279
|
-
|
|
273
|
+
const reach = grantReach(c);
|
|
274
|
+
if (!reach || reach.familyId !== familyId)
|
|
280
275
|
continue;
|
|
281
276
|
const covers = await grantCoversGrantee(c, grantee, {
|
|
282
277
|
allowDefault,
|
|
@@ -287,7 +282,7 @@ export class ScopedStack {
|
|
|
287
282
|
if (!covers)
|
|
288
283
|
continue;
|
|
289
284
|
const matches = actions.some((action) => {
|
|
290
|
-
if (!grantConveys(
|
|
285
|
+
if (!grantConveys(reach.actions, action))
|
|
291
286
|
return false;
|
|
292
287
|
if (matchOwn && action.endsWith('-own'))
|
|
293
288
|
return record?.entityId === grantee;
|
|
@@ -306,15 +301,11 @@ export class ScopedStack {
|
|
|
306
301
|
* Vacuously true when there's no delegation, where the principal and
|
|
307
302
|
* subject checks would be the same question asked twice.
|
|
308
303
|
*
|
|
309
|
-
*
|
|
310
|
-
* people who turn up, not software the
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
* Group-targeted grants don't count here either, one step removed: a
|
|
315
|
-
* roster is editable by any of the group's admins, so authority reaching
|
|
316
|
-
* a principal through one would let someone other than the owner name an
|
|
317
|
-
* app to a type. See docs/spec/access-control.md § Type-level grants.
|
|
304
|
+
* Neither default nor group-targeted grants count here. "Any
|
|
305
|
+
* authenticated entity" is about people who turn up, not software the
|
|
306
|
+
* owner installed; and a roster is editable by any of the group's admins,
|
|
307
|
+
* so authority reaching a principal through one would let someone other
|
|
308
|
+
* than the owner name an app to a type.
|
|
318
309
|
*/
|
|
319
310
|
principalAllows(typeId, actions, prefetchedGrants) {
|
|
320
311
|
if (!this.delegated)
|
|
@@ -352,14 +343,14 @@ export class ScopedStack {
|
|
|
352
343
|
* everyone else is told what a missing ID is told, so no one learns an ID
|
|
353
344
|
* is live who could not have learned it by reading. `message` therefore
|
|
354
345
|
* only ever reaches someone holding the record already.
|
|
355
|
-
* See docs/spec/
|
|
346
|
+
* See docs/spec/disclosure.md § Which refusal a Record answers with.
|
|
356
347
|
*/
|
|
357
348
|
async denialFor(record, message) {
|
|
358
349
|
// Prefetched here rather than threaded down from the gate: a write
|
|
359
350
|
// carried by a record-level permission settles without reading a grant
|
|
360
351
|
// at all, and that path must not pay for this one. Both halves of
|
|
361
352
|
// canRead share the one scan.
|
|
362
|
-
const grants = this.
|
|
353
|
+
const grants = this.identified ? await this.loadGrants() : undefined;
|
|
363
354
|
if (await this.canRead(record, grants))
|
|
364
355
|
return new StackPermissionError(message);
|
|
365
356
|
return new StackNotFoundError(`Record not found: "${record.id}"`);
|
|
@@ -470,13 +461,10 @@ export class ScopedStack {
|
|
|
470
461
|
* relationship target). Missing and unreadable both return false —
|
|
471
462
|
* indistinguishable, so this can't probe for a record's existence.
|
|
472
463
|
*
|
|
473
|
-
* The owner acting alone passes without the lookup
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
* refusal standing in for it. The anti-oracle property is unchanged for
|
|
478
|
-
* every other requester.
|
|
479
|
-
* See docs/spec/access-control.md § Reference-creation gating.
|
|
464
|
+
* The owner acting alone passes without the lookup: there is no record in
|
|
465
|
+
* their own stack they may not read, so the gate could only refuse them
|
|
466
|
+
* for absence — which is `Stack`'s to answer, with a conflict that names
|
|
467
|
+
* the problem. See docs/spec/access-control.md § Reference-creation gating.
|
|
480
468
|
*/
|
|
481
469
|
async canReadReferent(recordId) {
|
|
482
470
|
if (this.ownerActingAlone)
|
|
@@ -489,18 +477,14 @@ export class ScopedStack {
|
|
|
489
477
|
/**
|
|
490
478
|
* Whether this request can read some record referencing `fileId` —
|
|
491
479
|
* shared by canAccessFile() and the non-owner _attachment@1 create()
|
|
492
|
-
* carve-out, which deliberately excludes the uploader clause.
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
* file, never by the requester's own prior metadata record for it —
|
|
497
|
-
* allowing that would let one successful guess unlock unlimited further
|
|
498
|
-
* metadata records for the same fileId, reintroducing the circularity
|
|
499
|
-
* the carve-out's uploader-clause exclusion closes.
|
|
480
|
+
* carve-out, which deliberately excludes the uploader clause.
|
|
481
|
+
* `_attachment@1` records never match: the carve-out has to be satisfied
|
|
482
|
+
* by some *other* record referencing the file, or one successful guess
|
|
483
|
+
* would unlock unlimited further metadata records for the same fileId.
|
|
500
484
|
* See docs/spec/attachments.md § Creating `_attachment@1` records directly.
|
|
501
485
|
*/
|
|
502
486
|
async hasReadableReference(fileId) {
|
|
503
|
-
const prefetchedGrants = this.
|
|
487
|
+
const prefetchedGrants = this.identified ? await this.loadGrants() : undefined;
|
|
504
488
|
const groupRoles = new Map();
|
|
505
489
|
const match = await findFirstMatch((q) => this.stack.query(q),
|
|
506
490
|
// Reach, not enumeration: a record readable by ID conveys the file it
|
|
@@ -533,17 +517,25 @@ export class ScopedStack {
|
|
|
533
517
|
}
|
|
534
518
|
if (this.subjectEntityId === this.stack.ownerEntityId)
|
|
535
519
|
return true;
|
|
520
|
+
// `includeUnlisted`, as hasReadableReference() above: withholding a
|
|
521
|
+
// record from enumeration decides nothing about what it conveys, and
|
|
522
|
+
// the uploader clause does not lapse. See docs/spec/unlisted.md.
|
|
536
523
|
return filtersContent(this.stack.features)
|
|
537
524
|
? (await this.stack.query({
|
|
538
525
|
filter: {
|
|
539
526
|
typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
|
|
540
527
|
entityId: this.subjectEntityId,
|
|
541
528
|
content: { fileId },
|
|
529
|
+
includeUnlisted: true,
|
|
542
530
|
},
|
|
543
531
|
limit: 1,
|
|
544
532
|
})).records.length > 0
|
|
545
533
|
: (await queryAllPages((q) => this.stack.query(q), {
|
|
546
|
-
filter: {
|
|
534
|
+
filter: {
|
|
535
|
+
typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
|
|
536
|
+
entityId: this.subjectEntityId,
|
|
537
|
+
includeUnlisted: true,
|
|
538
|
+
},
|
|
547
539
|
})).some((r) => r.content.fileId === fileId);
|
|
548
540
|
}
|
|
549
541
|
/** Names of the type's top-level file-ref fields — the content-reference half of attachmentFileId matching. */
|
|
@@ -607,26 +599,19 @@ export class ScopedStack {
|
|
|
607
599
|
* creation refused save one carve-out. A scoped create always stamps
|
|
608
600
|
* authorship — an absent entityId means an unscoped `Stack` wrote it.
|
|
609
601
|
* `createdAt`/`updatedAt` are refused to everyone but the owner acting
|
|
610
|
-
* alone
|
|
611
|
-
* See
|
|
602
|
+
* alone, rather than dropped, so an app never believes it published
|
|
603
|
+
* something it didn't. See docs/spec/access-control.md and
|
|
604
|
+
* docs/spec/data-model.md § Record IDs.
|
|
612
605
|
*/
|
|
613
606
|
async create(typeId, content, opts = {}) {
|
|
614
607
|
const principal = this.principalEntityId;
|
|
615
608
|
if (!principal)
|
|
616
609
|
throw new StackPermissionError('Anonymous requesters cannot create records');
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
// skew check below already exists to stop from forging a sort
|
|
622
|
-
// position, and a delegated app acting for the owner inherits none of
|
|
623
|
-
// the owner's extra trust — same reasoning as mayGrantAccess() below.
|
|
624
|
-
// Refused rather than silently dropped, so an app never believes it
|
|
625
|
-
// published something it didn't — asked value-wise, since an
|
|
610
|
+
// A grantee is exactly the untrusted actor the `id` skew check below
|
|
611
|
+
// exists to stop from forging a sort position, and a delegated app
|
|
612
|
+
// acting for the owner inherits none of the owner's extra trust — same
|
|
613
|
+
// reasoning as mayGrantAccess() below. Asked value-wise, since an
|
|
626
614
|
// `undefined` carries no date and Stack.create() reads it as absent.
|
|
627
|
-
// This is also the enforcement a server built on ScopedStack inherits
|
|
628
|
-
// for `POST /records`: an owner-authenticated request may carry both
|
|
629
|
-
// fields, anyone else's has them ignored.
|
|
630
615
|
if ((opts.createdAt !== undefined || opts.updatedAt !== undefined) && !this.ownerActingAlone) {
|
|
631
616
|
throw new StackPermissionError('createdAt/updatedAt can only be set by the stack owner acting alone; a grantee or delegated create always stamps the current time.');
|
|
632
617
|
}
|
|
@@ -705,7 +690,7 @@ export class ScopedStack {
|
|
|
705
690
|
* A record this request may read, or null. Never throws
|
|
706
691
|
* StackPermissionError: an unreadable record answers exactly as a missing
|
|
707
692
|
* one does, so a caller learns only what it may read.
|
|
708
|
-
* See docs/spec/
|
|
693
|
+
* See docs/spec/disclosure.md § Which refusal a Record answers with.
|
|
709
694
|
*/
|
|
710
695
|
async get(id, opts = {}) {
|
|
711
696
|
const record = await this.stack.get(id, opts);
|
|
@@ -744,7 +729,7 @@ export class ScopedStack {
|
|
|
744
729
|
const records = [];
|
|
745
730
|
const maxFetched = limit * 10;
|
|
746
731
|
let totalFetched = 0;
|
|
747
|
-
const prefetchedGrants = this.
|
|
732
|
+
const prefetchedGrants = this.identified ? await this.loadGrants() : undefined;
|
|
748
733
|
// Scoped to this query, like prefetchedGrants beside it: every
|
|
749
734
|
// candidate Record shares one roster resolution per group, and nothing
|
|
750
735
|
// is carried into the next operation.
|
|
@@ -784,17 +769,48 @@ export class ScopedStack {
|
|
|
784
769
|
if (patchErrors.length > 0)
|
|
785
770
|
throw new StackValidationError(patchErrors);
|
|
786
771
|
}
|
|
787
|
-
|
|
772
|
+
// Refused here as well as in `Stack` below, so the partition is a
|
|
773
|
+
// refusal rather than a permission question: an `associations` key
|
|
774
|
+
// naming an authority kind is the wrong surface for every requester,
|
|
775
|
+
// owner included.
|
|
776
|
+
if (changes.associations)
|
|
777
|
+
assertDataAssociations(changes.associations, 'associations');
|
|
778
|
+
if (changes.permissions)
|
|
779
|
+
assertAuthorityAssociations(changes.permissions, 'permissions');
|
|
788
780
|
const writes = changes.contentPatch !== undefined ||
|
|
789
781
|
changes.associations !== undefined ||
|
|
790
782
|
changes.parentId !== undefined;
|
|
783
|
+
// `unlisted` reshares on the key's presence alone: it is a boolean, so
|
|
784
|
+
// naming it is the whole of what it can say. `permissions` is a set,
|
|
785
|
+
// and a set restated is not a reshare — see permissionsMove() below,
|
|
786
|
+
// which refines the escalation on this branch alone. A change set
|
|
787
|
+
// naming no writable key is gated before any record is in hand to
|
|
788
|
+
// compute a delta from, so there it reshares on presence, like the
|
|
789
|
+
// boolean. See docs/spec/access-control.md
|
|
790
|
+
// § Storage unifies; the API does not.
|
|
791
|
+
const unlists = changes.unlisted !== undefined;
|
|
791
792
|
// requireUpdatable() reads the record and applies the write gate; the
|
|
792
793
|
// reshare keys need the record before their own gate, and a change set
|
|
793
794
|
// carrying only those must not be held to the write gate it doesn't
|
|
794
795
|
// need. One read either way.
|
|
795
796
|
const record = writes ? await this.requireUpdatable(id) : await this.requireReshareable(id);
|
|
796
|
-
|
|
797
|
-
|
|
797
|
+
// Narrowed to what the gate below actually authorized. A key the gate
|
|
798
|
+
// read as inert is dropped rather than forwarded: `Stack` recomputes
|
|
799
|
+
// the delta against its own read of the record, so a key left standing
|
|
800
|
+
// on the strength of one read would write the ACL on the strength of
|
|
801
|
+
// another — and the set that reaches it is the one this requester saw,
|
|
802
|
+
// which is by then stale. See docs/spec/access-control.md
|
|
803
|
+
// § Storage unifies; the API does not.
|
|
804
|
+
let authorized = changes;
|
|
805
|
+
if (writes) {
|
|
806
|
+
const moves = this.permissionsMove(record, changes);
|
|
807
|
+
if (unlists || moves)
|
|
808
|
+
await this.requireReshareOf(record);
|
|
809
|
+
if (changes.permissions !== undefined && !moves && !this.canReshare(record)) {
|
|
810
|
+
const { permissions: _inert, ...rest } = changes;
|
|
811
|
+
authorized = rest;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
798
814
|
if (changes.contentPatch) {
|
|
799
815
|
const patch = changes.contentPatch;
|
|
800
816
|
// Value-wise, not presence-wise: a client that reads a card, edits
|
|
@@ -820,7 +836,7 @@ export class ScopedStack {
|
|
|
820
836
|
if (changes.parentId != null && !(await this.canReadReferent(changes.parentId))) {
|
|
821
837
|
throw new StackPermissionError();
|
|
822
838
|
}
|
|
823
|
-
return this.stack.mutate(id,
|
|
839
|
+
return this.stack.mutate(id, authorized, { ...opts, ...this.actor });
|
|
824
840
|
}
|
|
825
841
|
async patchContent(id, patch, opts = {}) {
|
|
826
842
|
return this.mutate(id, { contentPatch: patch }, opts);
|
|
@@ -843,46 +859,54 @@ export class ScopedStack {
|
|
|
843
859
|
// decision above, for the reason refuseIfDeleted() gives.
|
|
844
860
|
return this.refuseIfDeleted(record, true);
|
|
845
861
|
}
|
|
862
|
+
/**
|
|
863
|
+
* Whether a change set's `permissions` key actually moves the ACL. Read
|
|
864
|
+
* off the computed delta rather than the elements the caller supplied:
|
|
865
|
+
* a gate that inspected only what was named would miss the wholesale
|
|
866
|
+
* replacement that drops everything, which names nothing at all. Any
|
|
867
|
+
* add, remove or repoint in either direction is a reshare.
|
|
868
|
+
* See docs/spec/access-control.md § Record-level permissions.
|
|
869
|
+
*/
|
|
870
|
+
permissionsMove(record, changes) {
|
|
871
|
+
if (changes.permissions === undefined)
|
|
872
|
+
return false;
|
|
873
|
+
return associationDelta(record.permissions ?? [], changes.permissions).length > 0;
|
|
874
|
+
}
|
|
846
875
|
/** The reshare decision alone, for a record already read and write-gated. */
|
|
847
876
|
async requireReshareOf(record) {
|
|
848
|
-
if (
|
|
849
|
-
// Group management, not authorship: a creator later demoted from the
|
|
850
|
-
// admin roster shouldn't retain a side door to reassign who can read
|
|
851
|
-
// or write the group record.
|
|
852
|
-
if (!this.isGroupManager(record))
|
|
853
|
-
throw await this.denialFor(record);
|
|
854
|
-
return;
|
|
855
|
-
}
|
|
856
|
-
// Intersected like every other authority here: the principal must hold
|
|
857
|
-
// the verb, and the subject must be able to reach this record —
|
|
858
|
-
// without which an owner principal would carry its subject to records
|
|
859
|
-
// the subject cannot touch. create() withholds the same reach via
|
|
860
|
-
// mayGrantAccess().
|
|
861
|
-
if (!this.mayReshare(this.principalEntityId, record))
|
|
877
|
+
if (!this.canReshare(record))
|
|
862
878
|
throw await this.denialFor(record);
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Whether this request may decide who else reaches `record` — the
|
|
882
|
+
* decision requireReshareOf() refuses on, without the refusal, for the
|
|
883
|
+
* read paths that project on it rather than throw.
|
|
884
|
+
*
|
|
885
|
+
* A `_group` asks management, not authorship: a creator later demoted
|
|
886
|
+
* from the admin roster shouldn't retain a side door to reassign who can
|
|
887
|
+
* read or write the group record. Everything else is intersected like
|
|
888
|
+
* every other authority here, or an owner principal would carry its
|
|
889
|
+
* subject to records the subject cannot touch.
|
|
890
|
+
*/
|
|
891
|
+
canReshare(record) {
|
|
892
|
+
if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP)
|
|
893
|
+
return this.isGroupManager(record);
|
|
894
|
+
if (!this.mayReshare(this.principalEntityId, record))
|
|
895
|
+
return false;
|
|
896
|
+
return !this.delegated || this.mayReshare(this.subjectEntityId, record);
|
|
866
897
|
}
|
|
867
898
|
/**
|
|
868
899
|
* Naming the software behind a key is the trust decision the `_app`
|
|
869
900
|
* registry exists to record, so both halves of that binding — `did` and
|
|
870
|
-
* `appId` — belong to the owner alone.
|
|
871
|
-
*
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
* pointed at a write-holder's own key while keeping the name the owner
|
|
875
|
-
* gave it, or relabelled to claim another app's `appId`. `name` and
|
|
876
|
-
* `version` stay writable — they are display, not lookup.
|
|
877
|
-
*
|
|
878
|
-
* Owner *acting alone*, in both directions: a delegated app never holds
|
|
879
|
-
* it, and an owner principal doesn't lend it to a subject holding
|
|
880
|
-
* record-level `write` on a card — which would reopen the same route
|
|
881
|
-
* from the other side.
|
|
901
|
+
* `appId` — belong to the owner alone. Without this, record-level `write`
|
|
902
|
+
* on a card would be a second way in: one carrying no DID yet could be
|
|
903
|
+
* pointed at a write-holder's own key, or relabelled to claim another
|
|
904
|
+
* app's `appId`. `name` and `version` stay writable — display, not lookup.
|
|
882
905
|
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
*
|
|
906
|
+
* Owner *acting alone*, in both directions, or the same route reopens
|
|
907
|
+
* from the subject's side. `_entity` deliberately does not get this rule:
|
|
908
|
+
* naming people is what a contacts app does, so its cards stay writable
|
|
909
|
+
* by grant. See docs/spec/identity.md § DID bindings.
|
|
886
910
|
*/
|
|
887
911
|
requireOwnerForAppIdentity(typeId, touches) {
|
|
888
912
|
if (baseIdOf(typeId) !== SYSTEM_TYPES.APP)
|
|
@@ -893,13 +917,11 @@ export class ScopedStack {
|
|
|
893
917
|
}
|
|
894
918
|
/**
|
|
895
919
|
* A self-reported `appId` must agree with the `_app` card naming the
|
|
896
|
-
* principal's DID, where the owner registered one
|
|
920
|
+
* principal's DID, where the owner registered one: `principalId` is
|
|
897
921
|
* verified, so letting the pair disagree would leave a verified principal
|
|
898
|
-
* claiming a name the owner gave different software
|
|
899
|
-
*
|
|
900
|
-
*
|
|
901
|
-
* every undelegated writer.
|
|
902
|
-
* See docs/spec/identity.md § Attribution and what can be trusted.
|
|
922
|
+
* claiming a name the owner gave different software. A principal with no
|
|
923
|
+
* card keeps `appId` as the bare self-report it is for every undelegated
|
|
924
|
+
* writer. See docs/spec/identity.md § Attribution and what can be trusted.
|
|
903
925
|
*/
|
|
904
926
|
async requireAppIdMatchesPrincipal(appId) {
|
|
905
927
|
if (appId === undefined || !this.delegated)
|
|
@@ -948,15 +970,43 @@ export class ScopedStack {
|
|
|
948
970
|
return;
|
|
949
971
|
throw await this.denialFor(record, 'Only the stack owner may write a _grant record');
|
|
950
972
|
}
|
|
973
|
+
/**
|
|
974
|
+
* Gated on the write bit alone, which is why the kind refusal comes
|
|
975
|
+
* first: authority reached through this verb would be exactly the
|
|
976
|
+
* escalation the partition exists to stop, decided before any record is
|
|
977
|
+
* read so it cannot depend on who is asking.
|
|
978
|
+
*/
|
|
951
979
|
async associate(id, association) {
|
|
980
|
+
assertDataAssociations([association], 'associate()');
|
|
952
981
|
const record = await this.requireUpdatable(id);
|
|
953
982
|
await this.requireAssociationAccess(record.typeId, association);
|
|
954
983
|
return this.stack.associate(id, association, this.actor);
|
|
955
984
|
}
|
|
985
|
+
/** See associate() — the same write gate, the same kind refusal. */
|
|
956
986
|
async dissociate(id, association) {
|
|
987
|
+
assertDataAssociations([association], 'dissociate()');
|
|
957
988
|
await this.requireUpdatable(id);
|
|
958
989
|
return this.stack.dissociate(id, association, this.actor);
|
|
959
990
|
}
|
|
991
|
+
/**
|
|
992
|
+
* Extend who reaches a record — the reshare gate's own verb, on the
|
|
993
|
+
* owner-or-creator rule the `permissions` key carries. The write bit
|
|
994
|
+
* does not confer it: a write-holder who could grant would escalate to
|
|
995
|
+
* deciding who else reaches the record, which is the whole of what
|
|
996
|
+
* scoping access is for.
|
|
997
|
+
* See docs/spec/access-control.md § Record-level permissions.
|
|
998
|
+
*/
|
|
999
|
+
async grantAccess(id, permission) {
|
|
1000
|
+
assertAuthorityAssociations([permission], 'grantAccess()');
|
|
1001
|
+
await this.requireReshareable(id);
|
|
1002
|
+
return this.stack.grantAccess(id, permission, this.actor);
|
|
1003
|
+
}
|
|
1004
|
+
/** Withdraw one element of who reaches a record — see grantAccess(). */
|
|
1005
|
+
async revokeAccess(id, permission) {
|
|
1006
|
+
assertAuthorityAssociations([permission], 'revokeAccess()');
|
|
1007
|
+
await this.requireReshareable(id);
|
|
1008
|
+
return this.stack.revokeAccess(id, permission, this.actor);
|
|
1009
|
+
}
|
|
960
1010
|
/**
|
|
961
1011
|
* Hard delete is owner-only: it is irreversible and destroys version
|
|
962
1012
|
* history, so neither the write bit nor delete-own/delete-any grants
|
|
@@ -981,46 +1031,40 @@ export class ScopedStack {
|
|
|
981
1031
|
}
|
|
982
1032
|
/**
|
|
983
1033
|
* History is the mutation/recovery surface, not a read surface — gated
|
|
984
|
-
* like patchContent(),
|
|
985
|
-
* the
|
|
986
|
-
*
|
|
987
|
-
*
|
|
988
|
-
* alone: seeing how a Record you can already read got that way is not
|
|
989
|
-
* the escalation that fence exists to stop, and losing it would leave a
|
|
990
|
-
* write-holder unable to audit the Record they hold.
|
|
991
|
-
* See docs/spec/versioning.md § History access.
|
|
1034
|
+
* like patchContent(). Reading it changes nothing, so it is the one path
|
|
1035
|
+
* the `_grant` write fence leaves alone: seeing how a Record you can
|
|
1036
|
+
* already read got that way is not the escalation that fence exists to
|
|
1037
|
+
* stop. See docs/spec/versioning.md § History access.
|
|
992
1038
|
*/
|
|
993
1039
|
async getVersions(id) {
|
|
994
1040
|
await this.requireUpdatable(id, { mutating: false });
|
|
995
|
-
|
|
996
|
-
return this.ownerActingAlone ? versions : versions.map(stripVersionPermissions);
|
|
1041
|
+
return this.stack.getVersions(id);
|
|
997
1042
|
}
|
|
998
1043
|
/**
|
|
999
1044
|
* See getVersions() — the same mutate-surface gate, for the same reason.
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
1045
|
+
*
|
|
1046
|
+
* The journal is where a Record's sharing history lives, and that half
|
|
1047
|
+
* is the resharer's: a reader who could not have moved the ACL is served
|
|
1048
|
+
* the `permissions` op without the elements beneath it, so the entry
|
|
1049
|
+
* still names that it moved. Asked of both identities, so delegation is
|
|
1050
|
+
* no route to it either. See docs/spec/journal.md § Reading it.
|
|
1003
1051
|
*/
|
|
1004
1052
|
async getJournal(id, query = {}) {
|
|
1005
|
-
await this.requireUpdatable(id, { mutating: false });
|
|
1006
|
-
|
|
1053
|
+
const record = await this.requireUpdatable(id, { mutating: false });
|
|
1054
|
+
const entries = await this.stack.getJournal(id, query);
|
|
1055
|
+
return this.canReshare(record) ? entries : entries.map(withoutAuthorityChanges);
|
|
1007
1056
|
}
|
|
1008
|
-
/** See getVersions() — same mutate-surface gate
|
|
1057
|
+
/** See getVersions() — the same mutate-surface gate. */
|
|
1009
1058
|
async getVersion(id, version) {
|
|
1010
1059
|
await this.requireUpdatable(id, { mutating: false });
|
|
1011
|
-
|
|
1012
|
-
if (!target)
|
|
1013
|
-
return null;
|
|
1014
|
-
return this.ownerActingAlone ? target : stripVersionPermissions(target);
|
|
1060
|
+
return this.stack.getVersion(id, version);
|
|
1015
1061
|
}
|
|
1016
1062
|
/**
|
|
1017
1063
|
* Re-runs the reference-creation checks against the snapshot, so a
|
|
1018
|
-
* restore can't re-convey access to a file
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
* against the subject, which is whose reach the restore would widen.
|
|
1023
|
-
* See docs/spec/versioning.md § Restore semantics.
|
|
1064
|
+
* restore can't re-convey access to a file the subject can no longer
|
|
1065
|
+
* reach today. Only the owner acting alone is exempt: under delegation
|
|
1066
|
+
* the checks resolve against the subject, which is whose reach the
|
|
1067
|
+
* restore would widen. See docs/spec/versioning.md § Restore semantics.
|
|
1024
1068
|
*/
|
|
1025
1069
|
async restoreVersion(id, version, opts = {}) {
|
|
1026
1070
|
const record = await this.requireUpdatable(id);
|
|
@@ -1031,50 +1075,28 @@ export class ScopedStack {
|
|
|
1031
1075
|
// decision a content patch reserves to the owner, reached by another route.
|
|
1032
1076
|
this.requireOwnerForAppIdentity(record.typeId, (field) => target.content[field] !==
|
|
1033
1077
|
record.content[field]);
|
|
1034
|
-
// Only a restore that moves the record *into* a container creates a
|
|
1035
|
-
// reference. A snapshot at the root names nothing, and a parentId
|
|
1036
|
-
// the restore would not change is not re-gated: the record is
|
|
1037
|
-
// already there, so a content rollback is not refused over a move
|
|
1038
|
-
// it isn't making.
|
|
1039
|
-
if (target.parentId !== undefined &&
|
|
1040
|
-
target.parentId !== record.parentId &&
|
|
1041
|
-
!(await this.canReadReferent(target.parentId))) {
|
|
1042
|
-
throw new StackPermissionError();
|
|
1043
|
-
}
|
|
1044
1078
|
await this.requireFileRefAccess(target.typeId, target.content);
|
|
1045
|
-
//
|
|
1046
|
-
//
|
|
1047
|
-
//
|
|
1079
|
+
// Content's file refs are the only reference a restore can put
|
|
1080
|
+
// back: containment and associations are not on a snapshot at all,
|
|
1081
|
+
// so a restore never introduces either — see
|
|
1082
|
+
// docs/spec/versioning.md § Restore semantics.
|
|
1048
1083
|
}
|
|
1049
1084
|
}
|
|
1050
1085
|
return this.stack.restoreVersion(id, version, { ...opts, ...this.actor });
|
|
1051
1086
|
}
|
|
1052
1087
|
/**
|
|
1053
|
-
* Commit a per-record migration — **the owner acting alone, only
|
|
1054
|
-
*
|
|
1055
|
-
* Migration is owner-driven by design: `migrateAll()`, the bulk path,
|
|
1056
|
-
* lives on `Stack` and is deliberately absent from `StackClient`, the
|
|
1057
|
-
* same way `grant()`/`revoke()` are. This is its per-record counterpart
|
|
1058
|
-
* and carries the same restriction, rather than inventing a grant model
|
|
1059
|
-
* that the bulk path deliberately doesn't have.
|
|
1088
|
+
* Commit a per-record migration — **the owner acting alone, only**, the
|
|
1089
|
+
* same restriction the bulk `migrateAll()` carries by living on `Stack`.
|
|
1060
1090
|
*
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1063
|
-
* would have to re-derive every gate `create()` applies at the
|
|
1091
|
+
* Migrate replaces `content` and `typeId` wholesale, so a grant-based
|
|
1092
|
+
* version would have to re-derive every gate `create()` applies at the
|
|
1064
1093
|
* destination *and* every gate `mutate()` applies over the existing
|
|
1065
|
-
* content,
|
|
1066
|
-
* non-owner `_attachment@1` refusal
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1069
|
-
*
|
|
1070
|
-
*
|
|
1071
|
-
* (see docs/spec/attachments.md § Creating `_attachment@1` records
|
|
1072
|
-
* directly). Ordinary write access to a record is not consent to move it
|
|
1073
|
-
* between families.
|
|
1074
|
-
*
|
|
1075
|
-
* A server implementing `POST /records/:id/migrate` therefore serves it
|
|
1076
|
-
* to the stack owner and answers 403 otherwise. See
|
|
1077
|
-
* docs/spec/data-model.md § Type migrations.
|
|
1094
|
+
* content, reopening each one it missed. The sharpest is create()'s
|
|
1095
|
+
* non-owner `_attachment@1` refusal: without it, a requester could
|
|
1096
|
+
* migrate a record they authored into the family naming any `fileId` and
|
|
1097
|
+
* read the bytes through canAccessFile()'s uploader clause. Ordinary
|
|
1098
|
+
* write access to a record is not consent to move it between families.
|
|
1099
|
+
* See docs/spec/data-model.md § Type migrations.
|
|
1078
1100
|
*/
|
|
1079
1101
|
async commitMigration(id, toTypeId, content, opts = {}) {
|
|
1080
1102
|
this.requireOwnerActingAlone('Only the stack owner may commit a migration');
|
|
@@ -1169,7 +1191,7 @@ export class ScopedStack {
|
|
|
1169
1191
|
* the stack makes.
|
|
1170
1192
|
*/
|
|
1171
1193
|
async canReadCached(record, cache) {
|
|
1172
|
-
const grants = this.
|
|
1194
|
+
const grants = this.identified ? await cache.grants(() => this.loadGrants()) : undefined;
|
|
1173
1195
|
return this.canRead(record, grants, cache.roles);
|
|
1174
1196
|
}
|
|
1175
1197
|
}
|