@haverstack/core 0.40.0 → 0.42.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 +121 -59
- package/dist/access.d.ts +34 -36
- package/dist/access.d.ts.map +1 -1
- package/dist/access.js +65 -42
- 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} +7 -0
- package/dist/adapter/combine.js.map +1 -0
- package/dist/{adapter-entry.d.ts → adapter/index.d.ts} +6 -5
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/{adapter-entry.js → adapter/index.js} +5 -4
- package/dist/adapter/index.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 +93 -0
- package/dist/associations/identity.d.ts.map +1 -0
- package/dist/associations/identity.js +198 -0
- package/dist/associations/identity.js.map +1 -0
- package/dist/associations/validation.d.ts +90 -0
- package/dist/associations/validation.d.ts.map +1 -0
- package/dist/associations/validation.js +339 -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 +182 -0
- package/dist/collection.d.ts.map +1 -0
- package/dist/collection.js +407 -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 +36 -63
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +35 -58
- package/dist/errors.js.map +1 -1
- package/dist/grants.d.ts +20 -43
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +71 -134
- 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 +14 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -5
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +141 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/install.js +219 -0
- package/dist/install.js.map +1 -0
- package/dist/merge.d.ts.map +1 -1
- package/dist/merge.js +5 -11
- 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 +28 -94
- package/dist/query-validation.d.ts.map +1 -1
- package/dist/query-validation.js +156 -336
- package/dist/query-validation.js.map +1 -1
- package/dist/record-changes.d.ts +2 -117
- package/dist/record-changes.d.ts.map +1 -1
- package/dist/record-changes.js +10 -212
- 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.d.ts +156 -0
- package/dist/scoped-stack/authority.d.ts.map +1 -0
- package/dist/scoped-stack/authority.js +384 -0
- package/dist/scoped-stack/authority.js.map +1 -0
- package/dist/scoped-stack/scoped-stack.d.ts +338 -0
- package/dist/scoped-stack/scoped-stack.d.ts.map +1 -0
- package/dist/scoped-stack/scoped-stack.js +776 -0
- package/dist/scoped-stack/scoped-stack.js.map +1 -0
- package/dist/scoped-stack/subscription.d.ts +55 -0
- package/dist/scoped-stack/subscription.d.ts.map +1 -0
- package/dist/scoped-stack/subscription.js +110 -0
- package/dist/scoped-stack/subscription.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} +29 -65
- package/dist/stack/changes.d.ts.map +1 -0
- package/dist/{changes.js → stack/changes.js} +32 -66
- package/dist/stack/changes.js.map +1 -0
- package/dist/stack/client.d.ts +271 -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} +8 -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 +260 -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 → stack/reads.d.ts} +33 -6
- package/dist/stack/reads.d.ts.map +1 -0
- package/dist/{stack-reads.js → stack/reads.js} +49 -7
- 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 +438 -0
- package/dist/stack/stack.d.ts.map +1 -0
- package/dist/stack/stack.js +1366 -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 +78 -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/testing.d.ts +21 -26
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +50 -70
- 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 +223 -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 +156 -0
- package/dist/types/associations.d.ts.map +1 -0
- package/dist/types/associations.js +13 -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 +155 -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 +29 -70
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +65 -97
- 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} +13 -5
- package/dist/wire/body.d.ts.map +1 -0
- package/dist/{wire-body.js → wire/body.js} +65 -7
- 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 → wire/record.d.ts} +10 -22
- package/dist/wire/record.d.ts.map +1 -0
- package/dist/{wire-record.js → wire/record.js} +12 -24
- package/dist/wire/record.js.map +1 -0
- package/dist/{wire-request.d.ts → wire/request.d.ts} +20 -37
- package/dist/wire/request.d.ts.map +1 -0
- package/dist/{wire-request.js → wire/request.js} +79 -54
- package/dist/wire/request.js.map +1 -0
- package/package.json +7 -7
- package/dist/adapter-entry.d.ts.map +0 -1
- 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 -532
- package/dist/scoped-stack.d.ts.map +0 -1
- package/dist/scoped-stack.js +0 -1219
- 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.map +0 -1
- package/dist/stack-reads.js.map +0 -1
- package/dist/stack.d.ts +0 -971
- package/dist/stack.d.ts.map +0 -1
- package/dist/stack.js +0 -2012
- package/dist/stack.js.map +0 -1
- package/dist/types.d.ts +0 -1250
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -49
- 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.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
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScopedStack — the permission-enforcing view
|
|
3
|
+
* -------------------------------------------------------
|
|
4
|
+
* A `Stack` answers every question; a `ScopedStack` answers only the ones a
|
|
5
|
+
* particular requester is entitled to. It wraps a Stack rather than an
|
|
6
|
+
* adapter, so every invariant the Stack layer holds is already in force by
|
|
7
|
+
* the time a check here runs, and the gates below only ever narrow.
|
|
8
|
+
*
|
|
9
|
+
* Two identities, one rule: **the principal governs authority, the subject
|
|
10
|
+
* governs attribution.** Both are DIDs and both are often equal, which is
|
|
11
|
+
* what makes them easy to confuse — so the split is stated on every gate
|
|
12
|
+
* that depends on it. Grant lookup and the privilege-bearing gates no grant
|
|
13
|
+
* reaches (resharing, group management, purge, widening access at create
|
|
14
|
+
* time) key on the principal; authorship, `-own` matching, record-level
|
|
15
|
+
* permissions and "files I uploaded" key on the subject. Unconditional
|
|
16
|
+
* owner access splits the same way: an owner subject passes every
|
|
17
|
+
* permission check, an owner principal is not bounded by grants, and under
|
|
18
|
+
* delegation both halves apply. The questions those gates ask are
|
|
19
|
+
* ScopeAuthority's, in authority.ts; this module turns their answers into
|
|
20
|
+
* refusals.
|
|
21
|
+
*
|
|
22
|
+
* Also holds the feed side of the same rule: a subscription's deliveries
|
|
23
|
+
* run through the same canRead() a get() answers with, so a feed can never
|
|
24
|
+
* disagree with a read about what a session sees.
|
|
25
|
+
*
|
|
26
|
+
* See docs/spec/access-control.md and docs/spec/events.md § Permission scoping.
|
|
27
|
+
*/
|
|
28
|
+
import { baseIdOf } from '../schema.js';
|
|
29
|
+
import { validatePatchValues } from '../validate.js';
|
|
30
|
+
import { ChangeEmitter, assertSinceUsable } from '../stack/changes.js';
|
|
31
|
+
import { SYSTEM_TYPES } from '../types/index.js';
|
|
32
|
+
import { StackConflictError, StackNotFoundError, StackPermissionError, RelayScopeError, StackValidationError, } from '../errors.js';
|
|
33
|
+
import { assertAssociationEdits, assertAssociationList, assertAuthorityAssociations, assertDataAssociations, } from '../associations/validation.js';
|
|
34
|
+
import { associationDelta, associationEqual, editsOf } from '../associations/identity.js';
|
|
35
|
+
import { isGroupRecord } from '../access.js';
|
|
36
|
+
import { assertSortCapability, assertValidAssociationFilters, assertValidBaseIdFilter, assertValidSort, filtersContent, } from '../query-validation.js';
|
|
37
|
+
import { bindingFieldsOf } from '../stack/identity-bindings.js';
|
|
38
|
+
import { checkFamily, storedVersionDiffers, WRITE_EXPECTATION, } from '../stack/write-expectation.js';
|
|
39
|
+
import { assertAttachmentSize } from '../stack/limits.js';
|
|
40
|
+
import { validateIdTimestampSkew, validateRecordId } from '../stack/record-id.js';
|
|
41
|
+
import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT, findAppCardByDid, lookupEntityByDid, } from '../stack/reads.js';
|
|
42
|
+
import { assertNonEmptyChangeSet, presentDeleted, withoutAuthorityChanges, } from '../record-changes.js';
|
|
43
|
+
import { createCollection } from '../collection.js';
|
|
44
|
+
import { uploadContent } from '../stack/attachments.js';
|
|
45
|
+
import { ScopeAuthority } from './authority.js';
|
|
46
|
+
import { ScopedSubscription } from './subscription.js';
|
|
47
|
+
/**
|
|
48
|
+
* Held by Stack alone: the index re-exports ScopedStack but not this, so
|
|
49
|
+
* asEntity()/asActor() stay the only way to one, and the actor
|
|
50
|
+
* normalization and open-check they run can't be skipped.
|
|
51
|
+
*/
|
|
52
|
+
export const scopeToken = Symbol('ScopedStack');
|
|
53
|
+
/**
|
|
54
|
+
* A permission-enforcing view of a Stack for one (principal, subject) pair,
|
|
55
|
+
* obtained via `stack.asEntity()` or `stack.asActor()`. A record the request
|
|
56
|
+
* cannot read answers exactly as a missing one does.
|
|
57
|
+
* See docs/spec/disclosure.md § Which refusal a Record answers with.
|
|
58
|
+
*/
|
|
59
|
+
export class ScopedStack {
|
|
60
|
+
#authority;
|
|
61
|
+
stack;
|
|
62
|
+
idTimestampSkewMs;
|
|
63
|
+
adapter;
|
|
64
|
+
changes;
|
|
65
|
+
assertStackOpen;
|
|
66
|
+
relaysChanges;
|
|
67
|
+
migrations;
|
|
68
|
+
/** Not callable from outside the package — see ScopedStackInit. */
|
|
69
|
+
constructor(token, init) {
|
|
70
|
+
if (token !== scopeToken) {
|
|
71
|
+
throw new TypeError('A ScopedStack is obtained through Stack.asEntity() or Stack.asActor()');
|
|
72
|
+
}
|
|
73
|
+
this.stack = init.stack;
|
|
74
|
+
this.#authority = new ScopeAuthority(init.stack, init.principalId, init.subjectId);
|
|
75
|
+
this.idTimestampSkewMs = init.idTimestampSkewMs;
|
|
76
|
+
this.adapter = init.adapter;
|
|
77
|
+
this.changes = init.changes;
|
|
78
|
+
this.assertStackOpen = init.assertOpen;
|
|
79
|
+
this.relaysChanges = init.relaysChanges;
|
|
80
|
+
this.migrations = init.migrations;
|
|
81
|
+
}
|
|
82
|
+
collection(handle) {
|
|
83
|
+
this.assertStackOpen();
|
|
84
|
+
return createCollection(this, this.migrations, handle, () => this.stack.listTypes());
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The identity whose authority this view acts with; `null` when
|
|
88
|
+
* anonymous. See docs/spec/access-control.md § Delegation: principal and subject.
|
|
89
|
+
*/
|
|
90
|
+
get principalId() {
|
|
91
|
+
return this.#authority.principalId;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The identity this view acts for and attributes writes to; `null` when
|
|
95
|
+
* anonymous. Equal to `principalId` unless an app is acting for a user.
|
|
96
|
+
*/
|
|
97
|
+
get subjectId() {
|
|
98
|
+
return this.#authority.subjectId;
|
|
99
|
+
}
|
|
100
|
+
get capabilities() {
|
|
101
|
+
return this.stack.capabilities;
|
|
102
|
+
}
|
|
103
|
+
get actor() {
|
|
104
|
+
// The key is always present, so spreading this last overrides anything
|
|
105
|
+
// a caller passed: a scoped requester names itself by making the
|
|
106
|
+
// request, never by describing itself in the options.
|
|
107
|
+
return { actor: this.#authority.requester };
|
|
108
|
+
}
|
|
109
|
+
/** Refuse a create this request holds no grant for — see ScopeAuthority.checkCreateGrant(). */
|
|
110
|
+
async requireCreateGrant(typeId) {
|
|
111
|
+
if (!(await this.#authority.checkCreateGrant(typeId))) {
|
|
112
|
+
throw new StackPermissionError(`No create grant for type "${typeId}"`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Refuse anything but the owner acting as itself — see ScopeAuthority.ownerActingAlone. */
|
|
116
|
+
requireOwnerActingAlone(message) {
|
|
117
|
+
if (!this.#authority.ownerActingAlone)
|
|
118
|
+
throw new StackPermissionError(message);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Fetch a record the subject can reach and the principal holds `update` on
|
|
122
|
+
* (via permissions or an update grant), or throw. `mutating: false` marks
|
|
123
|
+
* the history readers, which borrow this gate without changing anything —
|
|
124
|
+
* the one way through it a `_grant` Record stays open to.
|
|
125
|
+
*/
|
|
126
|
+
async requireUpdatable(id, opts = {}) {
|
|
127
|
+
// The `_grant` write fence applies to mutating callers only: reading a
|
|
128
|
+
// grant Record's history is not the escalation that fence exists to stop.
|
|
129
|
+
return this.requireVerb(id, ['update-own', 'update-any'], {
|
|
130
|
+
fenceGrantRecord: opts.mutating ?? true,
|
|
131
|
+
expect: opts.expect,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The write gate both requireUpdatable() and requireDeletable() are: the
|
|
136
|
+
* subject can reach the record (record-level permission or a grant) and
|
|
137
|
+
* the principal holds the same verb, intersected — or, for a `_group`, the
|
|
138
|
+
* stricter management rule that no grant reaches.
|
|
139
|
+
* See docs/spec/access-control.md § Delegation: principal and subject.
|
|
140
|
+
*/
|
|
141
|
+
async requireVerb(id, actions, opts) {
|
|
142
|
+
const record = await this.stack.get(id, { includeDeleted: true });
|
|
143
|
+
if (!record)
|
|
144
|
+
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
145
|
+
// Ahead of the gate, so another family's record is not found whatever
|
|
146
|
+
// this requester may do to it — the answer a collection's get() gives.
|
|
147
|
+
checkFamily(record, opts.expect);
|
|
148
|
+
if (opts.fenceGrantRecord)
|
|
149
|
+
await this.requireOwnerForGrantRecord(record);
|
|
150
|
+
if (isGroupRecord(record)) {
|
|
151
|
+
if (!this.#authority.isGroupManager(record)) {
|
|
152
|
+
throw await this.#authority.denialFor(record, `Cannot change group "${id}": only its admins can manage it`);
|
|
153
|
+
}
|
|
154
|
+
return record;
|
|
155
|
+
}
|
|
156
|
+
const subjectReaches = (await this.#authority.recordPermits(record, 'write')) ||
|
|
157
|
+
(await this.#authority.subjectAllows(record.typeId, actions, { record }));
|
|
158
|
+
const principalHolds = await this.#authority.principalAllows(record.typeId, actions);
|
|
159
|
+
if (!subjectReaches || !principalHolds) {
|
|
160
|
+
const verb = actions[0].split('-')[0];
|
|
161
|
+
const what = `Cannot ${verb} "${id}" (${record.typeId})`;
|
|
162
|
+
throw await this.#authority.denialFor(record, subjectReaches
|
|
163
|
+
? `${what}: the app acting for this entity holds no ${verb} grant`
|
|
164
|
+
: `${what}: requires ${actions.join(' or ')}`);
|
|
165
|
+
}
|
|
166
|
+
return record;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Fetch a record the subject can reach and the principal holds `delete` on
|
|
170
|
+
* (via permissions or a delete grant), or throw.
|
|
171
|
+
*/
|
|
172
|
+
requireDeletable(id, expect) {
|
|
173
|
+
// No soft-delete refusal: undelete() shares this gate, and a tombstone
|
|
174
|
+
// is exactly what it addresses.
|
|
175
|
+
return this.requireVerb(id, ['delete-own', 'delete-any'], { fenceGrantRecord: true, expect });
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Gates file-ref content fields on file access, mirroring the
|
|
179
|
+
* attachment-association gate — a file-ref field conveys attachment
|
|
180
|
+
* access exactly like an `attachment` association. Only fields present
|
|
181
|
+
* in `content` are checked (a content patch is a merge patch; untouched fields
|
|
182
|
+
* carry no new reference).
|
|
183
|
+
*/
|
|
184
|
+
async requireFileRefAccess(typeId, content) {
|
|
185
|
+
for (const field of await this.#authority.fileRefFieldNames(typeId)) {
|
|
186
|
+
const value = content[field];
|
|
187
|
+
if (typeof value !== 'string')
|
|
188
|
+
continue;
|
|
189
|
+
if (!(await this.#authority.canAccessFile(value))) {
|
|
190
|
+
throw new StackPermissionError(`Cannot reference file "${value}" in field "${field}": it does not exist or you cannot read it`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Reference-creation gate for one association: file access for an
|
|
196
|
+
* `attachment`, read access for a `relationship` to a record in this
|
|
197
|
+
* stack. Other targets convey nothing, and a `_group` roster has the
|
|
198
|
+
* stricter management gate. See docs/spec/access-control.md
|
|
199
|
+
* § Reference-creation gating.
|
|
200
|
+
*/
|
|
201
|
+
async requireAssociationAccess(typeId, association) {
|
|
202
|
+
if (association.kind === 'attachment') {
|
|
203
|
+
if (!(await this.#authority.canAccessFile(association.fileId))) {
|
|
204
|
+
throw new StackPermissionError(`Cannot reference file "${association.fileId}": it does not exist or you cannot read it`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else if (association.kind === 'relationship' && baseIdOf(typeId) !== SYSTEM_TYPES.GROUP) {
|
|
208
|
+
const { target } = association;
|
|
209
|
+
// `stackUrl` is tested for a value, not for presence: absent and
|
|
210
|
+
// empty are one target — storage, targetEqual() and the filter all
|
|
211
|
+
// read them as this stack — so a check on presence alone would
|
|
212
|
+
// leave one spelling of a local Record ungated.
|
|
213
|
+
if (target.kind !== 'record' || target.stackUrl)
|
|
214
|
+
return;
|
|
215
|
+
if (!(await this.#authority.canReadReferent(target.recordId))) {
|
|
216
|
+
throw new StackPermissionError(`Cannot reference record "${target.recordId}": it does not exist or you cannot read it`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Create a record on behalf of the subject, stamped with the requester as
|
|
222
|
+
* `createdBy`. Every option that could widen reach or forge a position is
|
|
223
|
+
* refused rather than dropped, so an app never believes it published
|
|
224
|
+
* something it didn't. See docs/spec/data-model.md § Backdating on import.
|
|
225
|
+
*/
|
|
226
|
+
async create(typeId, content, opts = {}) {
|
|
227
|
+
const principal = this.#authority.principalId;
|
|
228
|
+
if (!principal)
|
|
229
|
+
throw new StackPermissionError('Anonymous requesters cannot create records');
|
|
230
|
+
// A grantee is exactly the untrusted actor the `id` skew check below
|
|
231
|
+
// exists to stop from forging a sort position, and a delegated app
|
|
232
|
+
// acting for the owner inherits none of the owner's extra trust — same
|
|
233
|
+
// reasoning as ScopeAuthority.mayGrantAccess(). Asked value-wise, since an
|
|
234
|
+
// `undefined` carries no date and Stack.create() reads it as absent.
|
|
235
|
+
if ((opts.createdAt !== undefined || opts.updatedAt !== undefined) &&
|
|
236
|
+
!this.#authority.ownerActingAlone) {
|
|
237
|
+
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.');
|
|
238
|
+
}
|
|
239
|
+
await this.requireCreateGrant(typeId);
|
|
240
|
+
// The exemption is the owner's own, so delegation doesn't carry it: an
|
|
241
|
+
// owner principal acting for someone else would otherwise let that
|
|
242
|
+
// subject name any fileId and reach the bytes through the uploader
|
|
243
|
+
// clause, which matches on the subject this create stamps.
|
|
244
|
+
if (!this.#authority.ownerActingAlone && baseIdOf(typeId) === SYSTEM_TYPES.ATTACHMENT) {
|
|
245
|
+
const fileId = content.fileId;
|
|
246
|
+
if (typeof fileId !== 'string' || !(await this.#authority.hasReadableReference(fileId))) {
|
|
247
|
+
throw new StackPermissionError(`Cannot reference file "${String(fileId)}": it does not exist or you cannot read it`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (opts.permissions?.length && !this.#authority.mayGrantAccess()) {
|
|
251
|
+
throw new StackPermissionError('A delegated principal cannot set permissions, at create time or after');
|
|
252
|
+
}
|
|
253
|
+
if (opts.unlisted && !this.#authority.mayGrantAccess()) {
|
|
254
|
+
throw new StackPermissionError('A delegated principal cannot create an unlisted record, at create time or after');
|
|
255
|
+
}
|
|
256
|
+
this.requireOwnerForOwnerDid(typeId, content.did);
|
|
257
|
+
await this.requireAppIdMatchesPrincipal(opts.appId);
|
|
258
|
+
if (opts.id !== undefined) {
|
|
259
|
+
validateRecordId(opts.id);
|
|
260
|
+
// A backdated create — the owner's alone, checked above — has its id
|
|
261
|
+
// checked against `createdAt` by Stack.create() instead of against now.
|
|
262
|
+
// See docs/spec/data-model.md § Record IDs.
|
|
263
|
+
if (opts.createdAt === undefined) {
|
|
264
|
+
validateIdTimestampSkew(opts.id, this.idTimestampSkewMs, Date.now(), 'the current time');
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (opts.parentId !== undefined && !(await this.#authority.canReadReferent(opts.parentId))) {
|
|
268
|
+
throw new StackPermissionError(`Cannot reference record "${opts.parentId}" as parent: it does not exist or you cannot read it`);
|
|
269
|
+
}
|
|
270
|
+
for (const assoc of opts.associations ?? []) {
|
|
271
|
+
await this.requireAssociationAccess(typeId, assoc);
|
|
272
|
+
}
|
|
273
|
+
await this.requireFileRefAccess(typeId, content);
|
|
274
|
+
return this.stack.create(typeId, content, {
|
|
275
|
+
...opts,
|
|
276
|
+
createdBy: this.#authority.requester,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* A record this request may read, or null. Never throws
|
|
281
|
+
* StackPermissionError: an unreadable record answers exactly as a missing
|
|
282
|
+
* one does, so a caller learns only what it may read.
|
|
283
|
+
* See docs/spec/disclosure.md § Which refusal a Record answers with.
|
|
284
|
+
*/
|
|
285
|
+
async get(id, opts = {}) {
|
|
286
|
+
const record = await this.stack.get(id, opts);
|
|
287
|
+
if (!record)
|
|
288
|
+
return null;
|
|
289
|
+
if (!(await this.#authority.canRead(record)))
|
|
290
|
+
return null;
|
|
291
|
+
return presentDeleted(record);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Runs through this.query(), so canRead() still applies — this answers a
|
|
295
|
+
* question the caller asks on its own behalf. includeUnlisted is passed
|
|
296
|
+
* only when the request is the owner acting alone, per
|
|
297
|
+
* docs/spec/identity.md § DID bindings.
|
|
298
|
+
*/
|
|
299
|
+
async getEntityByDid(did) {
|
|
300
|
+
return lookupEntityByDid((q) => this.query(q), did, filtersContent(this.stack.capabilities), this.#authority.ownerActingAlone);
|
|
301
|
+
}
|
|
302
|
+
async getOwnerEntity() {
|
|
303
|
+
return this.getEntityByDid(this.stack.ownerEntityId);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Query records, filtered to those this request can read. Pages are
|
|
307
|
+
* filtered then refilled, so a page may slightly overshoot `limit` but
|
|
308
|
+
* never skips a record. Grants are prefetched once, cursor-walked to
|
|
309
|
+
* exhaustion.
|
|
310
|
+
*/
|
|
311
|
+
async query(query = {}) {
|
|
312
|
+
assertValidSort(query.sort);
|
|
313
|
+
assertSortCapability(query.sort, this.stack.capabilities);
|
|
314
|
+
assertValidAssociationFilters(query.filter);
|
|
315
|
+
assertValidBaseIdFilter(query.filter);
|
|
316
|
+
if (query.filter?.includeUnlisted && !this.#authority.ownerActingAlone) {
|
|
317
|
+
throw new StackPermissionError('includeUnlisted is owner-only');
|
|
318
|
+
}
|
|
319
|
+
const limit = Math.min(query.limit ?? DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT);
|
|
320
|
+
const records = [];
|
|
321
|
+
const maxFetched = limit * 10;
|
|
322
|
+
let totalFetched = 0;
|
|
323
|
+
const prefetchedGrants = this.#authority.identified
|
|
324
|
+
? await this.#authority.loadGrants()
|
|
325
|
+
: undefined;
|
|
326
|
+
// Scoped to this query, like prefetchedGrants beside it: every
|
|
327
|
+
// candidate Record shares one roster resolution per group, and nothing
|
|
328
|
+
// is carried into the next operation.
|
|
329
|
+
const groupRoles = new Map();
|
|
330
|
+
let page = { records: [], cursor: query.cursor ?? null };
|
|
331
|
+
do {
|
|
332
|
+
page = await this.stack.query({ ...query, cursor: page.cursor ?? undefined });
|
|
333
|
+
totalFetched += page.records.length;
|
|
334
|
+
for (const record of page.records) {
|
|
335
|
+
// Projected here as well as in get(), or `includeDeleted` would be
|
|
336
|
+
// a strictly better read channel than the fetch-by-ID it is
|
|
337
|
+
// supposed to match.
|
|
338
|
+
if (await this.#authority.canRead(record, prefetchedGrants, groupRoles)) {
|
|
339
|
+
records.push(presentDeleted(record));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
} while (records.length < limit && page.cursor && totalFetched < maxFetched);
|
|
343
|
+
return { records, cursor: page.cursor };
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Apply a change set on behalf of the subject. Each key is gated against
|
|
347
|
+
* the record as it stands, never as the set would leave it, and one
|
|
348
|
+
* refused key refuses the call. See docs/spec/access-control.md
|
|
349
|
+
* § Composing a change set.
|
|
350
|
+
*/
|
|
351
|
+
async mutate(id, changes, opts = {}) {
|
|
352
|
+
// Ahead of every gate: a malformed change set is a validation error
|
|
353
|
+
// for every requester, rather than one for the owner and a permission
|
|
354
|
+
// refusal for everyone else.
|
|
355
|
+
assertNonEmptyChangeSet(changes);
|
|
356
|
+
if (changes.contentPatch) {
|
|
357
|
+
const patchErrors = validatePatchValues(changes.contentPatch);
|
|
358
|
+
if (patchErrors.length > 0)
|
|
359
|
+
throw new StackValidationError(patchErrors);
|
|
360
|
+
}
|
|
361
|
+
// Refused here as well as in `Stack` below, so the partition is a
|
|
362
|
+
// refusal rather than a permission question: an `associations` key
|
|
363
|
+
// naming an authority kind is the wrong surface for every requester,
|
|
364
|
+
// owner included.
|
|
365
|
+
if (changes.associations)
|
|
366
|
+
assertDataAssociations(changes.associations, 'associations');
|
|
367
|
+
if (changes.permissions)
|
|
368
|
+
assertAuthorityAssociations(changes.permissions, 'permissions');
|
|
369
|
+
const writes = changes.contentPatch !== undefined ||
|
|
370
|
+
changes.associations !== undefined ||
|
|
371
|
+
changes.parentId !== undefined;
|
|
372
|
+
// `unlisted` reshares on presence alone; a restated `permissions` set
|
|
373
|
+
// does not, which permissionsMove() decides once the record is in hand.
|
|
374
|
+
// See docs/spec/access-control.md § Storage unifies; the API does not.
|
|
375
|
+
const unlists = changes.unlisted !== undefined;
|
|
376
|
+
// requireUpdatable() reads the record and applies the write gate; the
|
|
377
|
+
// reshare keys need the record before their own gate, and a change set
|
|
378
|
+
// carrying only those must not be held to the write gate it doesn't
|
|
379
|
+
// need. One read either way.
|
|
380
|
+
const record = writes
|
|
381
|
+
? await this.requireUpdatable(id, { expect: opts })
|
|
382
|
+
: await this.requireReshareable(id, opts);
|
|
383
|
+
// A key the gate read as inert is dropped, not forwarded: `Stack`
|
|
384
|
+
// recomputes the delta from its own read, and must not write an ACL on
|
|
385
|
+
// the strength of this one. See docs/spec/access-control.md
|
|
386
|
+
// § Storage unifies; the API does not.
|
|
387
|
+
let authorized = changes;
|
|
388
|
+
if (writes) {
|
|
389
|
+
const moves = this.permissionsMove(record, changes);
|
|
390
|
+
if (unlists || moves)
|
|
391
|
+
await this.requireReshareOf(record);
|
|
392
|
+
if (changes.permissions !== undefined && !moves && !this.#authority.canReshare(record)) {
|
|
393
|
+
const { permissions: _inert, ...rest } = changes;
|
|
394
|
+
authorized = rest;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// A patch for another version is refused by `Stack`, which owes the
|
|
398
|
+
// tombstone and `ifVersion` refusals first; the content gates would read
|
|
399
|
+
// a stored Type it was not written for. Pinned to this read, `Stack`
|
|
400
|
+
// cannot find a record the patch lands on.
|
|
401
|
+
const mismatched = storedVersionDiffers(record, opts, changes.contentPatch !== undefined);
|
|
402
|
+
if (changes.contentPatch && !mismatched) {
|
|
403
|
+
const patch = changes.contentPatch;
|
|
404
|
+
// Value-wise, not presence-wise: a client that reads a card, edits
|
|
405
|
+
// its `name` and sends the whole content object back is not setting
|
|
406
|
+
// the binding it round-trips, and `name` is writable by record-level
|
|
407
|
+
// permission. Same predicate restoreVersion() applies to a snapshot.
|
|
408
|
+
this.requireOwnerForAppIdentity(record.typeId, (field) => field in patch && patch[field] !== record.content[field]);
|
|
409
|
+
// Likewise value-wise: re-sending the DID a card already holds
|
|
410
|
+
// claims nothing, and immutability refuses changing it regardless.
|
|
411
|
+
if ('did' in patch && patch.did !== record.content.did) {
|
|
412
|
+
this.requireOwnerForOwnerDid(record.typeId, patch.did);
|
|
413
|
+
}
|
|
414
|
+
await this.requireFileRefAccess(record.typeId, patch);
|
|
415
|
+
}
|
|
416
|
+
// Every association the change set would add is a reference this
|
|
417
|
+
// requester has to be allowed to create — asked against the stored
|
|
418
|
+
// set, so an association already on the record is not re-gated.
|
|
419
|
+
for (const assoc of changes.associations ?? []) {
|
|
420
|
+
if ((record.associations ?? []).some((a) => associationEqual(a, assoc)))
|
|
421
|
+
continue;
|
|
422
|
+
await this.requireAssociationAccess(record.typeId, assoc);
|
|
423
|
+
}
|
|
424
|
+
if (changes.parentId != null && !(await this.#authority.canReadReferent(changes.parentId))) {
|
|
425
|
+
throw new StackPermissionError(`Cannot reference record "${changes.parentId}" as parent: it does not exist or you cannot read it`);
|
|
426
|
+
}
|
|
427
|
+
const ifVersion = mismatched ? (opts.ifVersion ?? record.version) : opts.ifVersion;
|
|
428
|
+
return this.stack.mutate(id, authorized, { ...opts, ifVersion, ...this.actor });
|
|
429
|
+
}
|
|
430
|
+
async patchContent(id, patch, opts) {
|
|
431
|
+
return this.mutate(id, { contentPatch: patch }, opts);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* The record, having established that this request may decide who else
|
|
435
|
+
* reaches it — the owner-or-creator rule, asked of both sides of a
|
|
436
|
+
* delegation, or a Group's admin rule for a `_group`. The gate the
|
|
437
|
+
* `permissions` and `unlisted` keys carry, which the write bit
|
|
438
|
+
* deliberately does not confer.
|
|
439
|
+
*/
|
|
440
|
+
async requireReshareable(id, expect) {
|
|
441
|
+
const record = await this.stack.get(id, { includeDeleted: true });
|
|
442
|
+
if (!record)
|
|
443
|
+
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
444
|
+
checkFamily(record, expect);
|
|
445
|
+
await this.requireOwnerForGrantRecord(record);
|
|
446
|
+
await this.requireReshareOf(record);
|
|
447
|
+
return record;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Whether a change set's `permissions` key moves the ACL, read off the
|
|
451
|
+
* computed delta so a replacement that drops everything — naming nothing
|
|
452
|
+
* — still counts. See docs/spec/access-control.md § Record-level permissions.
|
|
453
|
+
*/
|
|
454
|
+
permissionsMove(record, changes) {
|
|
455
|
+
if (changes.permissions === undefined)
|
|
456
|
+
return false;
|
|
457
|
+
return associationDelta(record.permissions ?? [], changes.permissions).length > 0;
|
|
458
|
+
}
|
|
459
|
+
/** The reshare decision alone, for a record already read and write-gated. */
|
|
460
|
+
async requireReshareOf(record) {
|
|
461
|
+
if (!this.#authority.canReshare(record)) {
|
|
462
|
+
throw await this.#authority.denialFor(record, `Cannot change permissions on "${record.id}": only its author or the stack owner can reshare it`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* An `_app` card's `did` and `appId` are the owner's alone to set, or
|
|
467
|
+
* record-level `write` would be a second way to name the software behind
|
|
468
|
+
* a key. `_entity` cards stay writable by grant, as a contacts app needs.
|
|
469
|
+
* See docs/spec/identity.md § DID bindings.
|
|
470
|
+
*/
|
|
471
|
+
requireOwnerForAppIdentity(typeId, touches) {
|
|
472
|
+
if (baseIdOf(typeId) !== SYSTEM_TYPES.APP)
|
|
473
|
+
return;
|
|
474
|
+
if (!bindingFieldsOf(SYSTEM_TYPES.APP).some(touches))
|
|
475
|
+
return;
|
|
476
|
+
this.requireOwnerActingAlone('Only the stack owner may set an _app record’s did or appId');
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* A delegated write's `appId` must match the `_app` card the owner
|
|
480
|
+
* registered for the principal's DID, if there is one: a verified
|
|
481
|
+
* principal may not claim a name the owner gave other software.
|
|
482
|
+
* See docs/spec/identity.md § Attribution and what can be trusted.
|
|
483
|
+
*/
|
|
484
|
+
async requireAppIdMatchesPrincipal(appId) {
|
|
485
|
+
const principal = this.#authority.principalId;
|
|
486
|
+
if (appId === undefined || !this.#authority.delegated || !principal)
|
|
487
|
+
return;
|
|
488
|
+
const card = await findAppCardByDid(this.stack, principal);
|
|
489
|
+
if (card && card.content.appId !== appId) {
|
|
490
|
+
throw new StackPermissionError(`appId "${appId}" is not the appId registered for this principal`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* The owner's own DID is the one `_entity` binding a grantee may not
|
|
495
|
+
* claim: `ownerProfile` adopts whichever card holds it.
|
|
496
|
+
* See docs/spec/identity.md § DID bindings.
|
|
497
|
+
*/
|
|
498
|
+
requireOwnerForOwnerDid(typeId, did) {
|
|
499
|
+
if (baseIdOf(typeId) !== SYSTEM_TYPES.ENTITY)
|
|
500
|
+
return;
|
|
501
|
+
if (did !== this.stack.ownerEntityId)
|
|
502
|
+
return;
|
|
503
|
+
this.requireOwnerActingAlone('Only the stack owner may claim the owner’s own did');
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* `_grant` and `_install` Records are authority, so only the owner acting
|
|
507
|
+
* alone may write one; reading them stays on the ordinary gate.
|
|
508
|
+
* See docs/spec/access-control.md § What a grant covers.
|
|
509
|
+
*/
|
|
510
|
+
async requireOwnerForGrantRecord(record) {
|
|
511
|
+
const family = baseIdOf(record.typeId);
|
|
512
|
+
if (family !== SYSTEM_TYPES.GRANT && family !== SYSTEM_TYPES.INSTALL)
|
|
513
|
+
return;
|
|
514
|
+
if (this.#authority.ownerActingAlone)
|
|
515
|
+
return;
|
|
516
|
+
throw await this.#authority.denialFor(record, `Only the stack owner may write a ${family} record`);
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Gated on the write bit alone, which is why the list is held to its
|
|
520
|
+
* rules first: authority reached through this verb would be exactly the
|
|
521
|
+
* escalation the partition exists to stop, decided before any record is
|
|
522
|
+
* read so it cannot depend on who is asking.
|
|
523
|
+
*/
|
|
524
|
+
async associate(id, associations, opts = {}) {
|
|
525
|
+
assertAssociationList(associations, 'associate()', 'associations', 'data');
|
|
526
|
+
return this.amendAssociations(id, editsOf('add', associations), opts, 'associate()');
|
|
527
|
+
}
|
|
528
|
+
/** See associate() — the same write gate, the same kind refusal. */
|
|
529
|
+
async dissociate(id, associations, opts = {}) {
|
|
530
|
+
assertAssociationList(associations, 'dissociate()', 'associations', 'data');
|
|
531
|
+
return this.amendAssociations(id, editsOf('remove', associations), opts, 'dissociate()');
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* The write gate, plus the per-element reference gate on each add: what a
|
|
535
|
+
* remove names is already on the record, so it is gated by the write bit
|
|
536
|
+
* alone. See associate().
|
|
537
|
+
*/
|
|
538
|
+
async amendAssociations(id, changes, opts = {}, surface = 'amendAssociations()') {
|
|
539
|
+
assertAssociationEdits(changes, surface, 'data');
|
|
540
|
+
const record = await this.requireUpdatable(id, { expect: opts });
|
|
541
|
+
for (const change of changes) {
|
|
542
|
+
if (change.op === 'add')
|
|
543
|
+
await this.requireAssociationAccess(record.typeId, change.association);
|
|
544
|
+
}
|
|
545
|
+
return this.stack.amendAssociations(id, changes, { ...opts, ...this.actor }, surface);
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Extend who reaches a record, on the reshare gate the `permissions` key
|
|
549
|
+
* carries. The write bit does not confer it, or a write-holder could
|
|
550
|
+
* decide who else reaches the record.
|
|
551
|
+
* See docs/spec/access-control.md § Record-level permissions.
|
|
552
|
+
*/
|
|
553
|
+
async grantAccess(id, permissions, opts = {}) {
|
|
554
|
+
assertAssociationList(permissions, 'grantAccess()', 'permissions', 'authority');
|
|
555
|
+
return this.amendAccess(id, editsOf('add', permissions), opts, 'grantAccess()');
|
|
556
|
+
}
|
|
557
|
+
/** Withdraw elements of who reaches a record — see grantAccess(). */
|
|
558
|
+
async revokeAccess(id, permissions, opts = {}) {
|
|
559
|
+
assertAssociationList(permissions, 'revokeAccess()', 'permissions', 'authority');
|
|
560
|
+
return this.amendAccess(id, editsOf('remove', permissions), opts, 'revokeAccess()');
|
|
561
|
+
}
|
|
562
|
+
/** The reshare gate over an edit list — see grantAccess(). */
|
|
563
|
+
async amendAccess(id, changes, opts = {}, surface = 'amendAccess()') {
|
|
564
|
+
assertAssociationEdits(changes, surface, 'authority');
|
|
565
|
+
await this.requireReshareable(id, opts);
|
|
566
|
+
return this.stack.amendAccess(id, changes, { ...opts, ...this.actor }, surface);
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Purge is owner-only: it is irreversible and destroys version
|
|
570
|
+
* history, so neither the write bit nor delete-own/delete-any grants
|
|
571
|
+
* reach it, and delegation doesn't carry it either. Everyone else is
|
|
572
|
+
* limited to soft delete.
|
|
573
|
+
*/
|
|
574
|
+
async delete(id, opts = {}) {
|
|
575
|
+
const { referencedFileIds } = await this.deleteAndReturn(id, opts);
|
|
576
|
+
return { referencedFileIds };
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* delete(), plus the record it acted on. Gated identically — the
|
|
580
|
+
* existence/visibility check that produces a 404 ahead of the 403
|
|
581
|
+
* permission gate still runs via requireDeletable() before either kind
|
|
582
|
+
* of delete, so this changes what a successful call reports, not the
|
|
583
|
+
* disclosure ordering a refused one follows.
|
|
584
|
+
*/
|
|
585
|
+
async deleteAndReturn(id, opts = {}) {
|
|
586
|
+
const record = await this.requireDeletable(id, opts);
|
|
587
|
+
if (opts.purge && !this.#authority.ownerActingAlone) {
|
|
588
|
+
throw new StackPermissionError('Purge is owner-only');
|
|
589
|
+
}
|
|
590
|
+
if (!opts[WRITE_EXPECTATION])
|
|
591
|
+
return this.stack.deleteAndReturn(id, { ...opts, ...this.actor });
|
|
592
|
+
// Checked against the read above, which the delete is pinned to.
|
|
593
|
+
// Forwarded, it would cost a purge the read `Stack` otherwise skips.
|
|
594
|
+
const { [WRITE_EXPECTATION]: _checked, ...rest } = opts;
|
|
595
|
+
return this.stack.deleteAndReturn(id, {
|
|
596
|
+
...rest,
|
|
597
|
+
ifVersion: opts.ifVersion ?? record.version,
|
|
598
|
+
...this.actor,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Reverse a soft delete. Gated the same as delete() — undelete is the
|
|
603
|
+
* inverse of soft delete, so granting one direction without the other
|
|
604
|
+
* would be backwards. Idempotent, per Stack.undelete().
|
|
605
|
+
*/
|
|
606
|
+
async undelete(id, opts = {}) {
|
|
607
|
+
await this.requireDeletable(id, opts);
|
|
608
|
+
return this.stack.undelete(id, { ...opts, ...this.actor });
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* History is the mutation/recovery surface, not a read surface — gated
|
|
612
|
+
* like patchContent(). Reading it changes nothing, so it is the one path
|
|
613
|
+
* the `_grant` write fence leaves alone: seeing how a Record you can
|
|
614
|
+
* already read got that way is not the escalation that fence exists to
|
|
615
|
+
* stop. See docs/spec/versioning.md § History access.
|
|
616
|
+
*/
|
|
617
|
+
async getVersions(id, query = {}) {
|
|
618
|
+
await this.requireUpdatable(id, { mutating: false });
|
|
619
|
+
return this.stack.getVersions(id, query);
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* See getVersions() — the same gate. A requester who may not reshare the
|
|
623
|
+
* record is served each `reshare` op without the elements beneath it.
|
|
624
|
+
* See docs/spec/journal.md § Reading it.
|
|
625
|
+
*/
|
|
626
|
+
async getJournal(id, query = {}) {
|
|
627
|
+
const record = await this.requireUpdatable(id, { mutating: false });
|
|
628
|
+
const entries = await this.stack.getJournal(id, query);
|
|
629
|
+
return this.#authority.canReshare(record) ? entries : entries.map(withoutAuthorityChanges);
|
|
630
|
+
}
|
|
631
|
+
/** See getVersions() — the same mutate-surface gate. */
|
|
632
|
+
async getVersion(id, version) {
|
|
633
|
+
await this.requireUpdatable(id, { mutating: false });
|
|
634
|
+
return this.stack.getVersion(id, version);
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Re-runs the reference-creation checks against the snapshot, so a
|
|
638
|
+
* restore can't re-convey access to a file the subject can no longer
|
|
639
|
+
* reach today. Only the owner acting alone is exempt: under delegation
|
|
640
|
+
* the checks resolve against the subject, which is whose reach the
|
|
641
|
+
* restore would widen. See docs/spec/versioning.md § Restore semantics.
|
|
642
|
+
*/
|
|
643
|
+
async restoreVersion(id, version, opts = {}) {
|
|
644
|
+
const record = await this.requireUpdatable(id, { expect: opts });
|
|
645
|
+
if (!this.#authority.ownerActingAlone) {
|
|
646
|
+
const target = await this.stack.getVersion(id, version);
|
|
647
|
+
if (target) {
|
|
648
|
+
// A rollback that would move a card's binding is the same trust
|
|
649
|
+
// decision a content patch reserves to the owner, reached by another route.
|
|
650
|
+
this.requireOwnerForAppIdentity(record.typeId, (field) => target.content[field] !==
|
|
651
|
+
record.content[field]);
|
|
652
|
+
await this.requireFileRefAccess(target.typeId, target.content);
|
|
653
|
+
// Content's file refs are the only reference a restore can put
|
|
654
|
+
// back: containment and associations are not on a snapshot at all,
|
|
655
|
+
// so a restore never introduces either — see
|
|
656
|
+
// docs/spec/versioning.md § Restore semantics.
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return this.stack.restoreVersion(id, version, { ...opts, ...this.actor });
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Commit a per-record migration: the owner acting alone, or an installed
|
|
663
|
+
* app within its own families (requireInstallMigration()). Ordinary write
|
|
664
|
+
* access is not consent to move a record between families.
|
|
665
|
+
* See docs/spec/access-control.md § What a grant covers.
|
|
666
|
+
*/
|
|
667
|
+
async commitMigration(id, toTypeId, content, opts = {}) {
|
|
668
|
+
if (!this.#authority.ownerActingAlone)
|
|
669
|
+
await this.requireInstallMigration(id, toTypeId);
|
|
670
|
+
// The new content is a fresh set of file references, so an app is held
|
|
671
|
+
// to the gate create() applies; otherwise a migration could point one of
|
|
672
|
+
// its records at any attachment and read the bytes through it.
|
|
673
|
+
if (!this.#authority.ownerActingAlone)
|
|
674
|
+
await this.requireFileRefAccess(toTypeId, content);
|
|
675
|
+
return this.stack.commitMigration(id, toTypeId, content, { ...opts, ...this.actor });
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Refuse a migration this request may not commit as an installed app —
|
|
679
|
+
* see ScopeAuthority.installMayMigrate(). The tombstone refusal comes
|
|
680
|
+
* only once authority is settled, as every mutating verb asks it: the
|
|
681
|
+
* owner may migrate a tombstone; an app sees it without content.
|
|
682
|
+
* See docs/spec/apps.md § Migrating an installed app's types.
|
|
683
|
+
*/
|
|
684
|
+
async requireInstallMigration(id, toTypeId) {
|
|
685
|
+
const record = await this.stack.get(id, { includeDeleted: true });
|
|
686
|
+
if (!record || !(await this.#authority.installMayMigrate(record, toTypeId))) {
|
|
687
|
+
throw new StackPermissionError('Only the stack owner, or an installed app within the type families it defines, may commit a migration');
|
|
688
|
+
}
|
|
689
|
+
if (record.deletedAt) {
|
|
690
|
+
throw new StackConflictError(`Record "${id}" is soft-deleted; undelete it before migrating it.`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Store bytes and create an _attachment@1 metadata record (create grant
|
|
695
|
+
* on `_attachment@1` required; anonymous denied), returning that record.
|
|
696
|
+
* Authorship and principal are stamped exactly as create() does.
|
|
697
|
+
*/
|
|
698
|
+
async putAttachment(data, opts) {
|
|
699
|
+
const { appId } = opts;
|
|
700
|
+
// The one ScopedStack path that reaches the adapter without going
|
|
701
|
+
// through Stack first — without this, a closed stack would still write
|
|
702
|
+
// bytes before the delegated create() refused.
|
|
703
|
+
this.assertStackOpen();
|
|
704
|
+
const principal = this.#authority.principalId;
|
|
705
|
+
if (!principal) {
|
|
706
|
+
throw new StackPermissionError('Anonymous requesters cannot upload attachments');
|
|
707
|
+
}
|
|
708
|
+
await this.requireCreateGrant(`${SYSTEM_TYPES.ATTACHMENT}@1`);
|
|
709
|
+
await this.requireAppIdMatchesPrincipal(appId);
|
|
710
|
+
assertAttachmentSize(data.byteLength, this.capabilities.limits.attachmentBytes);
|
|
711
|
+
const fileId = await this.adapter.putBlob(data);
|
|
712
|
+
return this.stack.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, uploadContent(fileId, data, opts), { createdBy: this.#authority.requester, appId });
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Download attachment bytes. Accessible to the owner, a reader of any
|
|
716
|
+
* referencing record, or the uploader pre-association — the same
|
|
717
|
+
* predicate as the reference-creation gate (canAccessFile).
|
|
718
|
+
*/
|
|
719
|
+
async getAttachment(fileId) {
|
|
720
|
+
if (!(await this.#authority.canAccessFile(fileId))) {
|
|
721
|
+
throw new StackPermissionError(`Cannot read file "${fileId}": it does not exist or you cannot read it`);
|
|
722
|
+
}
|
|
723
|
+
return this.stack.getAttachment(fileId);
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Delete an attachment. Only the stack owner may delete attachments.
|
|
727
|
+
* Delegates to Stack.deleteAttachment(), which enforces the "not referenced" check.
|
|
728
|
+
*/
|
|
729
|
+
async deleteAttachment(fileId) {
|
|
730
|
+
this.requireOwnerActingAlone('Only the stack owner can delete attachments');
|
|
731
|
+
return this.stack.deleteAttachment(fileId, this.actor);
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Sweep for unreferenced attachment bytes and delete them. Only the stack
|
|
735
|
+
* owner may run this. Delegates to Stack.collectAttachmentGarbage().
|
|
736
|
+
*/
|
|
737
|
+
async collectAttachmentGarbage(opts) {
|
|
738
|
+
this.requireOwnerActingAlone('Only the stack owner can collect attachment garbage');
|
|
739
|
+
return this.stack.collectAttachmentGarbage({ ...opts, ...this.actor });
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Observe the changes this request may read, decided per event by the
|
|
743
|
+
* same canRead() that get() and query() use. An unreadable record yields
|
|
744
|
+
* no event at all, since a change's existence is itself a disclosure.
|
|
745
|
+
* See docs/spec/events.md § Permission scoping.
|
|
746
|
+
*/
|
|
747
|
+
async subscribe(handler, opts = {}) {
|
|
748
|
+
this.assertStackOpen();
|
|
749
|
+
if (this.relaysChanges) {
|
|
750
|
+
throw new RelayScopeError('This stack relays changes from elsewhere, and a scoped view cannot narrow that feed: ' +
|
|
751
|
+
'a relayed frame was already scoped by the session that opened it, and a purge leaves ' +
|
|
752
|
+
'no record to re-check. Subscribe with a session-scoped stack instead.');
|
|
753
|
+
}
|
|
754
|
+
// A relaying stack was already refused above, so relaysChanges is
|
|
755
|
+
// always false here — `since` never has a cursor to mean anything by.
|
|
756
|
+
assertSinceUsable(opts.since, false);
|
|
757
|
+
assertValidBaseIdFilter(opts.filter);
|
|
758
|
+
if (opts.includeUnlisted && !this.#authority.ownerActingAlone) {
|
|
759
|
+
throw new StackPermissionError('includeUnlisted is owner-only');
|
|
760
|
+
}
|
|
761
|
+
return this.changes.add(new ScopedSubscription((record, cache) => this.canReadCached(record, cache), handler, opts));
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* canRead for one event, through the subscription's own cache. Grants
|
|
765
|
+
* are prefetched once and refilled after an invalidation rather than
|
|
766
|
+
* re-queried per event, which would be a `_grant` scan for every change
|
|
767
|
+
* the stack makes.
|
|
768
|
+
*/
|
|
769
|
+
async canReadCached(record, cache) {
|
|
770
|
+
const grants = this.#authority.identified
|
|
771
|
+
? await cache.grants(() => this.#authority.loadGrants())
|
|
772
|
+
: undefined;
|
|
773
|
+
return this.#authority.canRead(record, grants, cache.roles);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
//# sourceMappingURL=scoped-stack.js.map
|