@haverstack/core 0.41.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.
Files changed (287) hide show
  1. package/README.md +121 -59
  2. package/dist/access.d.ts +34 -36
  3. package/dist/access.d.ts.map +1 -1
  4. package/dist/access.js +65 -42
  5. package/dist/access.js.map +1 -1
  6. package/dist/{combine.d.ts → adapter/combine.d.ts} +8 -1
  7. package/dist/adapter/combine.d.ts.map +1 -0
  8. package/dist/{combine.js → adapter/combine.js} +7 -0
  9. package/dist/adapter/combine.js.map +1 -0
  10. package/dist/{adapter-entry.d.ts → adapter/index.d.ts} +6 -5
  11. package/dist/adapter/index.d.ts.map +1 -0
  12. package/dist/{adapter-entry.js → adapter/index.js} +5 -4
  13. package/dist/adapter/index.js.map +1 -0
  14. package/dist/{sort.d.ts → adapter/sort.d.ts} +20 -20
  15. package/dist/adapter/sort.d.ts.map +1 -0
  16. package/dist/{sort.js → adapter/sort.js} +37 -27
  17. package/dist/adapter/sort.js.map +1 -0
  18. package/dist/associations/identity.d.ts +93 -0
  19. package/dist/associations/identity.d.ts.map +1 -0
  20. package/dist/associations/identity.js +198 -0
  21. package/dist/associations/identity.js.map +1 -0
  22. package/dist/associations/validation.d.ts +90 -0
  23. package/dist/associations/validation.d.ts.map +1 -0
  24. package/dist/associations/validation.js +339 -0
  25. package/dist/associations/validation.js.map +1 -0
  26. package/dist/attachment-records.d.ts +48 -0
  27. package/dist/attachment-records.d.ts.map +1 -0
  28. package/dist/attachment-records.js +52 -0
  29. package/dist/attachment-records.js.map +1 -0
  30. package/dist/collection.d.ts +182 -0
  31. package/dist/collection.d.ts.map +1 -0
  32. package/dist/collection.js +407 -0
  33. package/dist/collection.js.map +1 -0
  34. package/dist/content-match.d.ts +25 -0
  35. package/dist/content-match.d.ts.map +1 -0
  36. package/dist/content-match.js +51 -0
  37. package/dist/content-match.js.map +1 -0
  38. package/dist/content-translation.d.ts +42 -0
  39. package/dist/content-translation.d.ts.map +1 -0
  40. package/dist/content-translation.js +108 -0
  41. package/dist/content-translation.js.map +1 -0
  42. package/dist/did/did.d.ts.map +1 -0
  43. package/dist/{did.js → did/did.js} +3 -4
  44. package/dist/did/did.js.map +1 -0
  45. package/dist/{did-entry.d.ts → did/index.d.ts} +1 -1
  46. package/dist/did/index.d.ts.map +1 -0
  47. package/dist/{did-entry.js → did/index.js} +3 -3
  48. package/dist/did/index.js.map +1 -0
  49. package/dist/errors.d.ts +36 -63
  50. package/dist/errors.d.ts.map +1 -1
  51. package/dist/errors.js +35 -58
  52. package/dist/errors.js.map +1 -1
  53. package/dist/grants.d.ts +18 -42
  54. package/dist/grants.d.ts.map +1 -1
  55. package/dist/grants.js +68 -133
  56. package/dist/grants.js.map +1 -1
  57. package/dist/id.d.ts +13 -31
  58. package/dist/id.d.ts.map +1 -1
  59. package/dist/id.js +13 -31
  60. package/dist/id.js.map +1 -1
  61. package/dist/index.d.ts +12 -8
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +8 -5
  64. package/dist/index.js.map +1 -1
  65. package/dist/install.d.ts +3 -6
  66. package/dist/install.d.ts.map +1 -1
  67. package/dist/install.js +6 -10
  68. package/dist/install.js.map +1 -1
  69. package/dist/merge.d.ts.map +1 -1
  70. package/dist/merge.js +5 -11
  71. package/dist/merge.js.map +1 -1
  72. package/dist/migration-steps.d.ts +102 -0
  73. package/dist/migration-steps.d.ts.map +1 -0
  74. package/dist/migration-steps.js +293 -0
  75. package/dist/migration-steps.js.map +1 -0
  76. package/dist/query-validation.d.ts +28 -94
  77. package/dist/query-validation.d.ts.map +1 -1
  78. package/dist/query-validation.js +156 -336
  79. package/dist/query-validation.js.map +1 -1
  80. package/dist/record-changes.d.ts +2 -117
  81. package/dist/record-changes.d.ts.map +1 -1
  82. package/dist/record-changes.js +10 -212
  83. package/dist/record-changes.js.map +1 -1
  84. package/dist/schema.d.ts +18 -7
  85. package/dist/schema.d.ts.map +1 -1
  86. package/dist/schema.js +59 -30
  87. package/dist/schema.js.map +1 -1
  88. package/dist/scoped-stack/authority.d.ts +156 -0
  89. package/dist/scoped-stack/authority.d.ts.map +1 -0
  90. package/dist/scoped-stack/authority.js +384 -0
  91. package/dist/scoped-stack/authority.js.map +1 -0
  92. package/dist/scoped-stack/scoped-stack.d.ts +338 -0
  93. package/dist/scoped-stack/scoped-stack.d.ts.map +1 -0
  94. package/dist/scoped-stack/scoped-stack.js +776 -0
  95. package/dist/scoped-stack/scoped-stack.js.map +1 -0
  96. package/dist/scoped-stack/subscription.d.ts +55 -0
  97. package/dist/scoped-stack/subscription.d.ts.map +1 -0
  98. package/dist/scoped-stack/subscription.js +110 -0
  99. package/dist/scoped-stack/subscription.js.map +1 -0
  100. package/dist/stack/attachments.d.ts +24 -0
  101. package/dist/stack/attachments.d.ts.map +1 -0
  102. package/dist/stack/attachments.js +113 -0
  103. package/dist/stack/attachments.js.map +1 -0
  104. package/dist/{changes.d.ts → stack/changes.d.ts} +29 -65
  105. package/dist/stack/changes.d.ts.map +1 -0
  106. package/dist/{changes.js → stack/changes.js} +32 -66
  107. package/dist/stack/changes.js.map +1 -0
  108. package/dist/stack/client.d.ts +271 -0
  109. package/dist/stack/client.d.ts.map +1 -0
  110. package/dist/stack/client.js +9 -0
  111. package/dist/stack/client.js.map +1 -0
  112. package/dist/stack/identity-bindings.d.ts.map +1 -0
  113. package/dist/{identity-bindings.js → stack/identity-bindings.js} +5 -14
  114. package/dist/stack/identity-bindings.js.map +1 -0
  115. package/dist/stack/install-app.d.ts +22 -0
  116. package/dist/stack/install-app.d.ts.map +1 -0
  117. package/dist/stack/install-app.js +260 -0
  118. package/dist/stack/install-app.js.map +1 -0
  119. package/dist/stack/integrity.d.ts +94 -0
  120. package/dist/stack/integrity.d.ts.map +1 -0
  121. package/dist/stack/integrity.js +268 -0
  122. package/dist/stack/integrity.js.map +1 -0
  123. package/dist/{limits.d.ts → stack/limits.d.ts} +2 -5
  124. package/dist/stack/limits.d.ts.map +1 -0
  125. package/dist/{limits.js → stack/limits.js} +3 -6
  126. package/dist/stack/limits.js.map +1 -0
  127. package/dist/stack/migrations.d.ts +59 -0
  128. package/dist/stack/migrations.d.ts.map +1 -0
  129. package/dist/stack/migrations.js +141 -0
  130. package/dist/stack/migrations.js.map +1 -0
  131. package/dist/{stack-reads.d.ts → stack/reads.d.ts} +33 -6
  132. package/dist/stack/reads.d.ts.map +1 -0
  133. package/dist/{stack-reads.js → stack/reads.js} +49 -7
  134. package/dist/stack/reads.js.map +1 -0
  135. package/dist/stack/record-id.d.ts +35 -0
  136. package/dist/stack/record-id.d.ts.map +1 -0
  137. package/dist/stack/record-id.js +82 -0
  138. package/dist/stack/record-id.js.map +1 -0
  139. package/dist/stack/stack.d.ts +438 -0
  140. package/dist/stack/stack.d.ts.map +1 -0
  141. package/dist/stack/stack.js +1366 -0
  142. package/dist/stack/stack.js.map +1 -0
  143. package/dist/stack/system-types.d.ts +16 -0
  144. package/dist/stack/system-types.d.ts.map +1 -0
  145. package/dist/stack/system-types.js +111 -0
  146. package/dist/stack/system-types.js.map +1 -0
  147. package/dist/stack/type-grants.d.ts +20 -0
  148. package/dist/stack/type-grants.d.ts.map +1 -0
  149. package/dist/stack/type-grants.js +69 -0
  150. package/dist/stack/type-grants.js.map +1 -0
  151. package/dist/stack/write-expectation.d.ts +78 -0
  152. package/dist/stack/write-expectation.d.ts.map +1 -0
  153. package/dist/stack/write-expectation.js +71 -0
  154. package/dist/stack/write-expectation.js.map +1 -0
  155. package/dist/testing.d.ts +21 -26
  156. package/dist/testing.d.ts.map +1 -1
  157. package/dist/testing.js +50 -70
  158. package/dist/testing.js.map +1 -1
  159. package/dist/type-handle.d.ts +67 -78
  160. package/dist/type-handle.d.ts.map +1 -1
  161. package/dist/type-handle.js +44 -111
  162. package/dist/type-handle.js.map +1 -1
  163. package/dist/types/adapter.d.ts +223 -0
  164. package/dist/types/adapter.d.ts.map +1 -0
  165. package/dist/types/adapter.js +5 -0
  166. package/dist/types/adapter.js.map +1 -0
  167. package/dist/types/associations.d.ts +156 -0
  168. package/dist/types/associations.d.ts.map +1 -0
  169. package/dist/types/associations.js +13 -0
  170. package/dist/types/associations.js.map +1 -0
  171. package/dist/types/capabilities.d.ts +89 -0
  172. package/dist/types/capabilities.d.ts.map +1 -0
  173. package/dist/types/capabilities.js +5 -0
  174. package/dist/types/capabilities.js.map +1 -0
  175. package/dist/types/events.d.ts +155 -0
  176. package/dist/types/events.d.ts.map +1 -0
  177. package/dist/types/events.js +5 -0
  178. package/dist/types/events.js.map +1 -0
  179. package/dist/types/ids.d.ts +43 -0
  180. package/dist/types/ids.d.ts.map +1 -0
  181. package/dist/types/ids.js +5 -0
  182. package/dist/types/ids.js.map +1 -0
  183. package/dist/types/index.d.ts +19 -0
  184. package/dist/types/index.d.ts.map +1 -0
  185. package/dist/types/index.js +19 -0
  186. package/dist/types/index.js.map +1 -0
  187. package/dist/types/journal.d.ts +47 -0
  188. package/dist/types/journal.d.ts.map +1 -0
  189. package/dist/types/journal.js +5 -0
  190. package/dist/types/journal.js.map +1 -0
  191. package/dist/types/query.d.ts +185 -0
  192. package/dist/types/query.d.ts.map +1 -0
  193. package/dist/types/query.js +10 -0
  194. package/dist/types/query.js.map +1 -0
  195. package/dist/types/records.d.ts +76 -0
  196. package/dist/types/records.d.ts.map +1 -0
  197. package/dist/types/records.js +5 -0
  198. package/dist/types/records.js.map +1 -0
  199. package/dist/types/schema.d.ts +74 -0
  200. package/dist/types/schema.d.ts.map +1 -0
  201. package/dist/types/schema.js +5 -0
  202. package/dist/types/schema.js.map +1 -0
  203. package/dist/types/system.d.ts +161 -0
  204. package/dist/types/system.d.ts.map +1 -0
  205. package/dist/types/system.js +32 -0
  206. package/dist/types/system.js.map +1 -0
  207. package/dist/types/tokens.d.ts +39 -0
  208. package/dist/types/tokens.d.ts.map +1 -0
  209. package/dist/types/tokens.js +5 -0
  210. package/dist/types/tokens.js.map +1 -0
  211. package/dist/validate.d.ts +29 -70
  212. package/dist/validate.d.ts.map +1 -1
  213. package/dist/validate.js +50 -91
  214. package/dist/validate.js.map +1 -1
  215. package/dist/{attachment-download.d.ts → wire/attachment-download.d.ts} +0 -57
  216. package/dist/wire/attachment-download.d.ts.map +1 -0
  217. package/dist/{attachment-download.js → wire/attachment-download.js} +0 -61
  218. package/dist/wire/attachment-download.js.map +1 -0
  219. package/dist/{auth.d.ts → wire/auth.d.ts} +5 -8
  220. package/dist/wire/auth.d.ts.map +1 -0
  221. package/dist/{auth.js → wire/auth.js} +5 -8
  222. package/dist/wire/auth.js.map +1 -0
  223. package/dist/{wire-body.d.ts → wire/body.d.ts} +4 -4
  224. package/dist/wire/body.d.ts.map +1 -0
  225. package/dist/{wire-body.js → wire/body.js} +5 -5
  226. package/dist/wire/body.js.map +1 -0
  227. package/dist/{wire-entry.d.ts → wire/index.d.ts} +12 -12
  228. package/dist/wire/index.d.ts.map +1 -0
  229. package/dist/{wire-entry.js → wire/index.js} +13 -17
  230. package/dist/wire/index.js.map +1 -0
  231. package/dist/{wire-record.d.ts → wire/record.d.ts} +10 -22
  232. package/dist/wire/record.d.ts.map +1 -0
  233. package/dist/{wire-record.js → wire/record.js} +12 -24
  234. package/dist/wire/record.js.map +1 -0
  235. package/dist/{wire-request.d.ts → wire/request.d.ts} +20 -37
  236. package/dist/wire/request.d.ts.map +1 -0
  237. package/dist/{wire-request.js → wire/request.js} +79 -54
  238. package/dist/wire/request.js.map +1 -0
  239. package/package.json +7 -7
  240. package/dist/adapter-entry.d.ts.map +0 -1
  241. package/dist/adapter-entry.js.map +0 -1
  242. package/dist/attachment-download.d.ts.map +0 -1
  243. package/dist/attachment-download.js.map +0 -1
  244. package/dist/auth.d.ts.map +0 -1
  245. package/dist/auth.js.map +0 -1
  246. package/dist/changes.d.ts.map +0 -1
  247. package/dist/changes.js.map +0 -1
  248. package/dist/combine.d.ts.map +0 -1
  249. package/dist/combine.js.map +0 -1
  250. package/dist/did-entry.d.ts.map +0 -1
  251. package/dist/did-entry.js.map +0 -1
  252. package/dist/did.d.ts.map +0 -1
  253. package/dist/did.js.map +0 -1
  254. package/dist/identity-bindings.d.ts.map +0 -1
  255. package/dist/identity-bindings.js.map +0 -1
  256. package/dist/limits.d.ts.map +0 -1
  257. package/dist/limits.js.map +0 -1
  258. package/dist/record-id.d.ts +0 -70
  259. package/dist/record-id.d.ts.map +0 -1
  260. package/dist/record-id.js +0 -127
  261. package/dist/record-id.js.map +0 -1
  262. package/dist/scoped-stack.d.ts +0 -549
  263. package/dist/scoped-stack.d.ts.map +0 -1
  264. package/dist/scoped-stack.js +0 -1301
  265. package/dist/scoped-stack.js.map +0 -1
  266. package/dist/sort.d.ts.map +0 -1
  267. package/dist/sort.js.map +0 -1
  268. package/dist/stack-reads.d.ts.map +0 -1
  269. package/dist/stack-reads.js.map +0 -1
  270. package/dist/stack.d.ts +0 -1032
  271. package/dist/stack.d.ts.map +0 -1
  272. package/dist/stack.js +0 -2337
  273. package/dist/stack.js.map +0 -1
  274. package/dist/types.d.ts +0 -1278
  275. package/dist/types.d.ts.map +0 -1
  276. package/dist/types.js +0 -51
  277. package/dist/types.js.map +0 -1
  278. package/dist/wire-body.d.ts.map +0 -1
  279. package/dist/wire-body.js.map +0 -1
  280. package/dist/wire-entry.d.ts.map +0 -1
  281. package/dist/wire-entry.js.map +0 -1
  282. package/dist/wire-record.d.ts.map +0 -1
  283. package/dist/wire-record.js.map +0 -1
  284. package/dist/wire-request.d.ts.map +0 -1
  285. package/dist/wire-request.js.map +0 -1
  286. /package/dist/{did.d.ts → did/did.d.ts} +0 -0
  287. /package/dist/{identity-bindings.d.ts → stack/identity-bindings.d.ts} +0 -0
@@ -1,1301 +0,0 @@
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.
13
- *
14
- * Also holds the feed side of the same rule: a subscription's deliveries
15
- * run through the same canRead() a get() answers with, so a feed can never
16
- * disagree with a read about what a session sees.
17
- *
18
- * See docs/spec/access-control.md and docs/spec/events.md § Permission scoping.
19
- */
20
- import { baseIdOf } from './schema.js';
21
- import { validatePatchValues } from './validate.js';
22
- import { checkAccess, groupRoleFromAssociations, isOwnerActingAlone } from './access.js';
23
- import { ChangeEmitter, Subscription, matchesFilter, passesUnlistedBoundary, assertSinceUsable, } from './changes.js';
24
- import { SYSTEM_TYPES } from './types.js';
25
- import { StackError, StackConflictError, StackNotFoundError, StackPermissionError, RelayScopeError, StackValidationError, } from './errors.js';
26
- import { assertAssociationEdits, assertAuthorityAssociations, assertDataAssociations, assertSortCapability, assertValidAssociationFilters, assertValidBaseIdFilter, assertValidSort, filtersContent, } from './query-validation.js';
27
- import { grantConveys, grantCoversGrantee, grantReach, loadGrantRecords, UNGRANTABLE_SYSTEM_TYPES, } from './grants.js';
28
- import { bindingFieldsOf } from './identity-bindings.js';
29
- import { claimedFamilies, familyStanding, linkedIds, INSTALL_APP_LABEL } from './install.js';
30
- import { assertAttachmentSize } from './limits.js';
31
- import { validateIdTimestampSkew, validateRecordId } from './record-id.js';
32
- import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT, findFirstMatch, lookupEntityByDid, queryAllPages, } from './stack-reads.js';
33
- import { associationDelta, associationEqual, isGroupRecord, presentDeleted, withoutAuthorityChanges, assertNonEmptyChangeSet, } from './record-changes.js';
34
- // Every import from stack.js is type-only: a ScopedStack never constructs
35
- // a Stack, so nothing here closes a runtime cycle with stack.ts, which does
36
- // construct a ScopedStack.
37
- import { isTypeHandle, typedCreate, typedGet, typedMutate, typedQuery, typedSubscribe, } from './type-handle.js';
38
- /**
39
- * The authority lookups canRead needs, held for the life of one
40
- * subscription. A subscription is long-lived where a query is not, so the
41
- * cache is only safe because every write that can change canRead's answer
42
- * arrives as an event that drops it — see ScopedSubscription.
43
- */
44
- class FeedAuthorityCache {
45
- grantRecords = null;
46
- /**
47
- * Bumped by every invalidation, so a load that was already in flight can
48
- * tell that its result is stale before seating it.
49
- */
50
- generation = 0;
51
- /** Roster roles, memoized per group, as ScopedStack.query() does per query. */
52
- roles = new Map();
53
- /** Every `_grant` record, refilled through `load` after an invalidation. */
54
- async grants(load) {
55
- if (this.grantRecords !== null)
56
- return this.grantRecords;
57
- const generation = this.generation;
58
- const loaded = await load();
59
- // An invalidation during the load already dropped the set these
60
- // replace, so seating them would outlive the write that expired them
61
- // and no later event would drop them again. The event being decided
62
- // precedes that write, so it is still decided on what was loaded.
63
- if (this.generation === generation)
64
- this.grantRecords = loaded;
65
- return loaded;
66
- }
67
- invalidateFor(typeFamily) {
68
- if (typeFamily === SYSTEM_TYPES.GRANT) {
69
- this.grantRecords = null;
70
- this.generation++;
71
- }
72
- if (typeFamily === SYSTEM_TYPES.GROUP)
73
- this.roles = new Map();
74
- }
75
- }
76
- /**
77
- * A ScopedStack's delivery: canRead per event, with the grant and roster
78
- * lookups it needs cached for the life of the subscription and dropped the
79
- * moment anything that feeds them changes.
80
- *
81
- * Two properties do the work, and neither is optional. **Deliveries are
82
- * serialized**, because the permission decision is asynchronous and two
83
- * changes to one record could otherwise resolve out of order, breaking the
84
- * feed's one ordering guarantee. And **the filter fails closed**: a check
85
- * that throws drops the event and reports the error.
86
- * See docs/spec/events.md § Permission scoping.
87
- */
88
- class ScopedSubscription extends Subscription {
89
- canRead;
90
- cache = new FeedAuthorityCache();
91
- /** Tail of the delivery chain — see the class comment. */
92
- queue = Promise.resolve();
93
- constructor(canRead, handler, opts) {
94
- super(handler, opts);
95
- this.canRead = canRead;
96
- }
97
- accept(emission) {
98
- // Invalidation reads every emission, including the ones this
99
- // subscriber may not see: a revocation the subscriber cannot read is
100
- // exactly the one that must still expire its cache.
101
- this.invalidateFor(emission);
102
- if (!matchesFilter(emission, this.opts.filter))
103
- return;
104
- this.queue = this.queue.then(() => this.filterAndDeliver(emission));
105
- }
106
- /**
107
- * A cached grant set outlives the write that revokes it unless something
108
- * drops it. Both writes that can change canRead's answer — a `_grant`
109
- * record, or a `_group` roster — arrive here as ordinary events, which
110
- * is what makes the cache safe to hold at all. A future authority change
111
- * that did not emit would silently strand it.
112
- */
113
- invalidateFor(emission) {
114
- this.cache.invalidateFor(baseIdOf(emission.change.typeId));
115
- }
116
- async filterAndDeliver(emission) {
117
- if (this.isClosed)
118
- return;
119
- if (!passesUnlistedBoundary(emission, this.opts.includeUnlisted))
120
- return;
121
- try {
122
- if (!(await this.canRead(emission.record, this.cache)))
123
- return;
124
- }
125
- catch (err) {
126
- // Fail closed: an undecided permission question is not a yes.
127
- this.reportError(err);
128
- return;
129
- }
130
- // After the permission decision, which needs the whole record: a frame
131
- // carrying a deleted record's body would be a read channel around the
132
- // tombstone. See docs/spec/events.md § Soft-deleted records reach the
133
- // feed as tombstones.
134
- this.deliver(this.project(emission));
135
- }
136
- project(emission) {
137
- const record = presentDeleted(emission.record);
138
- return record === emission.record ? emission : { ...emission, record };
139
- }
140
- }
141
- /**
142
- * Held by Stack alone: the index re-exports ScopedStack but not this, so
143
- * asEntity()/asActor() stay the only way to one, and the actor
144
- * normalization and open-check they run can't be skipped.
145
- */
146
- export const scopeToken = Symbol('ScopedStack');
147
- /**
148
- * A permission-enforcing view of a Stack for a single (principal, subject)
149
- * pair, obtained via `stack.asEntity(entityId)`. A record the request
150
- * cannot read answers exactly as a missing one does — null on reads,
151
- * StackNotFoundError on the verbs that name one — so only a requester who
152
- * could have read it is told a refusal was about access.
153
- * See docs/spec/disclosure.md § Which refusal a Record answers with.
154
- *
155
- * Which identity each gate keys on follows the module comment's rule.
156
- * Grant lookup and the privilege-bearing gates no grant reaches
157
- * (resharing, group management, purge, widening access at create
158
- * time) key on `principalId`; authorship, `-own` matching,
159
- * record-level permission resolution and "files I uploaded" lookups key on
160
- * `subjectId`. Unconditional owner access splits the same way: an
161
- * owner subject resolves past every permission check, an owner principal
162
- * is not bounded by grants, and under delegation both halves apply.
163
- */
164
- export class ScopedStack {
165
- #principalId;
166
- #subjectId;
167
- stack;
168
- idTimestampSkewMs;
169
- adapter;
170
- changes;
171
- assertStackOpen;
172
- relaysChanges;
173
- /** Not callable from outside the package — see ScopedStackInit. */
174
- constructor(token, init) {
175
- if (token !== scopeToken) {
176
- throw new TypeError('A ScopedStack is obtained through Stack.asEntity() or Stack.asActor()');
177
- }
178
- this.stack = init.stack;
179
- this.#principalId = init.principalId;
180
- this.#subjectId = init.subjectId;
181
- this.idTimestampSkewMs = init.idTimestampSkewMs;
182
- this.adapter = init.adapter;
183
- this.changes = init.changes;
184
- this.assertStackOpen = init.assertOpen;
185
- this.relaysChanges = init.relaysChanges;
186
- }
187
- /**
188
- * The identity whose authority this view acts with; `null` when
189
- * anonymous. See docs/spec/access-control.md § Delegation: principal and subject.
190
- */
191
- get principalId() {
192
- return this.#principalId;
193
- }
194
- /**
195
- * The identity this view acts for and attributes writes to; `null` when
196
- * anonymous. Equal to `principalId` unless an app is acting for a user.
197
- */
198
- get subjectId() {
199
- return this.#subjectId;
200
- }
201
- get capabilities() {
202
- return this.stack.capabilities;
203
- }
204
- resolveRecord = (id) => this.stack.get(id, { includeDeleted: true });
205
- /** Every `_grant` Record — see loadGrantRecords(). */
206
- loadGrants = () => loadGrantRecords((q) => this.stack.query(q));
207
- /** Whether a delegated app is acting for someone other than itself. */
208
- get delegated() {
209
- return this.#subjectId !== this.#principalId;
210
- }
211
- /**
212
- * Whether any grant could name this request — the one question every
213
- * prefetch asks before scanning the `_grant` family, since an anonymous
214
- * requester is reached by none. Both identities are read: `asEntity()`
215
- * refuses an anonymous principal acting for a subject, so they are null
216
- * together, and saying so costs nothing.
217
- */
218
- get identified() {
219
- return this.#principalId !== null || this.#subjectId !== null;
220
- }
221
- /**
222
- * Who this request is, for stamping onto whatever it mutates. Attribution
223
- * follows record-level authorship: the subject is the actor, and the
224
- * principal is named beside it only when the two differ.
225
- * See docs/spec/data-model.md § Authorship and attribution.
226
- */
227
- get requester() {
228
- if (this.#subjectId === null)
229
- return undefined;
230
- return this.delegated && this.#principalId !== null
231
- ? { subjectId: this.#subjectId, principalId: this.#principalId }
232
- : { subjectId: this.#subjectId };
233
- }
234
- get actor() {
235
- // The key is always present, so spreading this last overrides anything
236
- // a caller passed: a scoped requester names itself by making the
237
- // request, never by describing itself in the options.
238
- return { actor: this.requester };
239
- }
240
- /**
241
- * Whether unconditional owner authority applies — the owner acting as
242
- * itself. The verbs that rest on it are irreversible or disclose the
243
- * sharing graph, so delegation never carries one to a subject, whichever
244
- * side the owner is on. Shared with the predicate a server applies to a
245
- * session, which decides the same tier one step earlier. See
246
- * docs/spec/access-control.md § Delegation: principal and subject.
247
- */
248
- get ownerActingAlone() {
249
- return isOwnerActingAlone({ principalId: this.#principalId, subjectId: this.#subjectId }, this.stack.ownerEntityId);
250
- }
251
- /** Refuse anything but the owner acting as itself — see ownerActingAlone. */
252
- requireOwnerActingAlone(message) {
253
- if (!this.ownerActingAlone)
254
- throw new StackPermissionError(message);
255
- }
256
- checkRead(record) {
257
- return checkAccess(record, this.#subjectId, this.stack.ownerEntityId, 'read', this.resolveRecord);
258
- }
259
- checkWrite(record) {
260
- return checkAccess(record, this.#subjectId, this.stack.ownerEntityId, 'write', this.resolveRecord);
261
- }
262
- /**
263
- * Whether `grantee` holds a _grant covering one of `actions` for the
264
- * type's family (grants match by baseId, so a version bump never orphans
265
- * one). -own actions additionally require the author to be `grantee`,
266
- * unless `matchOwn` is false — on the principal side of a delegated
267
- * request the suffix reads as the bare verb, since which records are
268
- * reachable is the subject's business. `allowDefault` decides whether a
269
- * grant naming nobody counts.
270
- *
271
- * Reached only through subjectAllows()/principalAllows(), which fix those
272
- * two flags per side. Call one of those instead.
273
- */
274
- async hasGrant(typeId, actions, opts) {
275
- const { grantee, record, prefetchedGrants, matchOwn = true, allowDefault = true, allowGroup = true, } = opts;
276
- if (!grantee)
277
- return false;
278
- // Absent when the caller has no operation-scoped map to share — one
279
- // call's worth of memoization, which is still every grant record in
280
- // this loop naming the same group.
281
- const groupRoles = opts.groupRoles ?? new Map();
282
- const familyId = baseIdOf(typeId);
283
- // grantType() refuses to write these, but a _grant record is an ordinary
284
- // Record: an unscoped Stack, an import, or a server mapping a request
285
- // body onto Stack can mint one anyway. Refusing at the point of use is
286
- // what makes the rule hold regardless of how the record got there.
287
- if (UNGRANTABLE_SYSTEM_TYPES.has(familyId))
288
- return false;
289
- let grantRecords;
290
- if (prefetchedGrants !== undefined) {
291
- grantRecords = prefetchedGrants;
292
- }
293
- else {
294
- // Cursor-walked to see grants past page one. A stored baseId is
295
- // judged by grantReach(), which refuses a versioned one.
296
- grantRecords = await this.loadGrants();
297
- }
298
- for (const r of grantRecords) {
299
- const c = r.content;
300
- const reach = grantReach(c);
301
- if (!reach || reach.familyId !== familyId)
302
- continue;
303
- const covers = await grantCoversGrantee(c, grantee, {
304
- allowDefault,
305
- allowGroup,
306
- groupRoles,
307
- resolveRecord: this.resolveRecord,
308
- });
309
- if (!covers)
310
- continue;
311
- const matches = actions.some((action) => {
312
- if (!grantConveys(reach.actions, action))
313
- return false;
314
- if (matchOwn && action.endsWith('-own'))
315
- return record?.createdBy?.subjectId === grantee;
316
- return true;
317
- });
318
- if (matches)
319
- return true;
320
- }
321
- return false;
322
- }
323
- /**
324
- * The principal half of a delegated request's authority: does the app
325
- * hold any grant permitting these verbs on this type at all. Bounds what
326
- * the subject's own authority can reach through it, so a powerful app
327
- * can never lend its reach to a weaker subject — nor the reverse.
328
- * Vacuously true when there's no delegation, where the principal and
329
- * subject checks would be the same question asked twice.
330
- *
331
- * Neither default nor group-targeted grants count here. "Any
332
- * authenticated entity" is about people who turn up, not software the
333
- * owner installed; and a roster is editable by any of the group's admins,
334
- * so authority reaching a principal through one would let someone other
335
- * than the owner name an app to a type.
336
- */
337
- principalAllows(typeId, actions, prefetchedGrants) {
338
- if (!this.delegated)
339
- return Promise.resolve(true);
340
- if (this.#principalId === this.stack.ownerEntityId)
341
- return Promise.resolve(true);
342
- return this.hasGrant(typeId, actions, {
343
- grantee: this.#principalId,
344
- prefetchedGrants,
345
- matchOwn: false,
346
- allowDefault: false,
347
- allowGroup: false,
348
- });
349
- }
350
- /**
351
- * The subject half: which records are reachable, answered with `-own`
352
- * matching and default grants both in force — the ordinary reading of a
353
- * grant, since the subject is the entity a grant is written about.
354
- *
355
- * Paired with principalAllows() so that the two halves of the
356
- * intersection are the only callers of hasGrant(): its flags differ per
357
- * side and mean nothing on their own, so no call site sets them by hand.
358
- */
359
- subjectAllows(typeId, actions, opts = {}) {
360
- return this.hasGrant(typeId, actions, {
361
- grantee: this.#subjectId,
362
- record: opts.record,
363
- prefetchedGrants: opts.prefetchedGrants,
364
- groupRoles: opts.groupRoles,
365
- });
366
- }
367
- /**
368
- * How to refuse a record this request addressed by ID. A requester who
369
- * can read the record is told it exists and the verb was refused;
370
- * everyone else is told what a missing ID is told, so no one learns an ID
371
- * is live who could not have learned it by reading. `message` therefore
372
- * only ever reaches someone holding the record already.
373
- * See docs/spec/disclosure.md § Which refusal a Record answers with.
374
- */
375
- async denialFor(record, message) {
376
- // Prefetched here rather than threaded down from the gate: a write
377
- // carried by a record-level permission settles without reading a grant
378
- // at all, and that path must not pay for this one. Both halves of
379
- // canRead share the one scan.
380
- const grants = this.identified ? await this.loadGrants() : undefined;
381
- if (await this.canRead(record, grants))
382
- return new StackPermissionError(message);
383
- return new StackNotFoundError(`Record not found: "${record.id}"`);
384
- }
385
- async canRead(record, prefetchedGrants, groupRoles) {
386
- const reachable = (await this.checkRead(record)) ||
387
- (await this.subjectAllows(record.typeId, ['read-own', 'read-any'], {
388
- record,
389
- prefetchedGrants,
390
- groupRoles,
391
- }));
392
- if (!reachable)
393
- return false;
394
- return this.principalAllows(record.typeId, ['read-own', 'read-any'], prefetchedGrants);
395
- }
396
- async checkCreateGrant(typeId) {
397
- if (this.ownerActingAlone)
398
- return true;
399
- const reachable = this.#subjectId === this.stack.ownerEntityId ||
400
- (await this.subjectAllows(typeId, ['create']));
401
- if (!reachable)
402
- return false;
403
- return this.principalAllows(typeId, ['create']);
404
- }
405
- /**
406
- * `_group` records are managed, not merely written: only the owner or an
407
- * `admin` roster holder may mutate them — ordinary write permissions and
408
- * grants don't apply. Asked of both identities under delegation, like
409
- * a reshare. See docs/spec/identity.md § Group.
410
- */
411
- isGroupManager(record) {
412
- if (!this.managesGroup(this.#principalId, record))
413
- return false;
414
- return !this.delegated || this.managesGroup(this.#subjectId, record);
415
- }
416
- /** Whether one identity, on its own, manages `record` — see isGroupManager(). */
417
- managesGroup(entityId, record) {
418
- if (!entityId)
419
- return false;
420
- if (entityId === this.stack.ownerEntityId)
421
- return true;
422
- return groupRoleFromAssociations(record.associations, entityId) === 'admin';
423
- }
424
- /**
425
- * Fetch a record the subject can reach and the principal holds `update` on
426
- * (via permissions or an update grant), or throw. `mutating: false` marks
427
- * the history readers, which borrow this gate without changing anything —
428
- * the one way through it a `_grant` Record stays open to.
429
- */
430
- async requireUpdatable(id, opts = {}) {
431
- // The `_grant` write fence applies to mutating callers only: reading a
432
- // grant Record's history is not the escalation that fence exists to stop.
433
- return this.requireVerb(id, ['update-own', 'update-any'], {
434
- fenceGrantRecord: opts.mutating ?? true,
435
- });
436
- }
437
- /**
438
- * The write gate both requireUpdatable() and requireDeletable() are: the
439
- * subject can reach the record (record-level permission or a grant) and
440
- * the principal holds the same verb, intersected — or, for a `_group`, the
441
- * stricter management rule that no grant reaches.
442
- * See docs/spec/access-control.md § Delegation: principal and subject.
443
- */
444
- async requireVerb(id, actions, opts) {
445
- const record = await this.stack.get(id, { includeDeleted: true });
446
- if (!record)
447
- throw new StackNotFoundError(`Record not found: "${id}"`);
448
- if (opts.fenceGrantRecord)
449
- await this.requireOwnerForGrantRecord(record);
450
- if (isGroupRecord(record)) {
451
- if (!this.isGroupManager(record)) {
452
- throw await this.denialFor(record, `Cannot change group "${id}": only its admins can manage it`);
453
- }
454
- return record;
455
- }
456
- const subjectReaches = (await this.checkWrite(record)) ||
457
- (await this.subjectAllows(record.typeId, actions, { record }));
458
- const principalHolds = await this.principalAllows(record.typeId, actions);
459
- if (!subjectReaches || !principalHolds) {
460
- const verb = actions[0].split('-')[0];
461
- const what = `Cannot ${verb} "${id}" (${record.typeId})`;
462
- throw await this.denialFor(record, subjectReaches
463
- ? `${what}: the app acting for this entity holds no ${verb} grant`
464
- : `${what}: requires ${actions.join(' or ')}`);
465
- }
466
- return record;
467
- }
468
- /**
469
- * Fetch a record the subject can reach and the principal holds `delete` on
470
- * (via permissions or a delete grant), or throw.
471
- */
472
- requireDeletable(id) {
473
- // No soft-delete refusal: undelete() shares this gate, and a tombstone
474
- // is exactly what it addresses.
475
- return this.requireVerb(id, ['delete-own', 'delete-any'], { fenceGrantRecord: true });
476
- }
477
- /**
478
- * Whether this request may reference `recordId` (as a parentId or
479
- * relationship target). Missing and unreadable both return false —
480
- * indistinguishable, so this can't probe for a record's existence.
481
- *
482
- * The owner acting alone passes without the lookup: there is no record in
483
- * their own stack they may not read, so the gate could only refuse them
484
- * for absence — which is `Stack`'s to answer, with a conflict that names
485
- * the problem. See docs/spec/access-control.md § Reference-creation gating.
486
- */
487
- async canReadReferent(recordId) {
488
- if (this.ownerActingAlone)
489
- return true;
490
- const record = await this.stack.get(recordId, { includeDeleted: true });
491
- if (!record)
492
- return false;
493
- return this.canRead(record);
494
- }
495
- /**
496
- * Whether this request can read some record referencing `fileId` —
497
- * shared by canAccessFile() and the non-owner _attachment@1 create()
498
- * carve-out, which deliberately excludes the uploader clause.
499
- * `_attachment@1` records never match: the carve-out has to be satisfied
500
- * by some *other* record referencing the file, or one successful guess
501
- * would unlock unlimited further metadata records for the same fileId.
502
- * See docs/spec/attachments.md § Creating `_attachment@1` records directly.
503
- */
504
- async hasReadableReference(fileId) {
505
- const prefetchedGrants = this.identified ? await this.loadGrants() : undefined;
506
- const groupRoles = new Map();
507
- const match = await findFirstMatch((q) => this.stack.query(q),
508
- // Reach, not enumeration: a record readable by ID conveys the file it
509
- // references. See docs/spec/unlisted.md.
510
- { filter: { referencesFileId: fileId, includeUnlisted: true } }, (record) => baseIdOf(record.typeId) !== SYSTEM_TYPES.ATTACHMENT &&
511
- this.canRead(record, prefetchedGrants, groupRoles));
512
- return match !== undefined;
513
- }
514
- /**
515
- * Whether this request may reference or download `fileId` — the dual of
516
- * getAttachment()'s access rule. Nonexistent and inaccessible are
517
- * indistinguishable (both false), so no confirmation oracle for guessed
518
- * hashes. See docs/spec/access-control.md § Reference-creation gating.
519
- */
520
- async canAccessFile(fileId) {
521
- if (this.ownerActingAlone)
522
- return true;
523
- // Reaching a file through a record this request can read is already
524
- // fully intersected — canRead() applied the principal's mask against
525
- // that record's own type, which is the type the reference lives on.
526
- if (await this.hasReadableReference(fileId))
527
- return true;
528
- if (!this.#subjectId)
529
- return false;
530
- // The remaining paths are authorship facts about the subject, so they
531
- // decide *which* files match — they are not themselves a grant, and the
532
- // principal still needs one of its own on the attachment type.
533
- if (!(await this.principalAllows(`${SYSTEM_TYPES.ATTACHMENT}@1`, ['read-own', 'read-any']))) {
534
- return false;
535
- }
536
- if (this.#subjectId === this.stack.ownerEntityId)
537
- return true;
538
- // `includeUnlisted`, as hasReadableReference() above: withholding a
539
- // record from enumeration decides nothing about what it conveys, and
540
- // the uploader clause does not lapse. See docs/spec/unlisted.md.
541
- return filtersContent(this.stack.capabilities)
542
- ? (await this.stack.query({
543
- filter: {
544
- typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
545
- createdBy: { subjectId: this.#subjectId },
546
- content: { fileId },
547
- includeUnlisted: true,
548
- },
549
- limit: 1,
550
- })).records.length > 0
551
- : (await queryAllPages((q) => this.stack.query(q), {
552
- filter: {
553
- typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
554
- createdBy: { subjectId: this.#subjectId },
555
- includeUnlisted: true,
556
- },
557
- })).some((r) => r.content.fileId === fileId);
558
- }
559
- /** Names of the type's top-level file-ref fields — the content-reference half of referencesFileId matching. */
560
- async fileRefFieldNames(typeId) {
561
- const type = await this.stack.getType(typeId);
562
- if (!type)
563
- return [];
564
- return Object.entries(type.schema)
565
- .filter(([, def]) => def.kind === 'file-ref')
566
- .map(([field]) => field);
567
- }
568
- /**
569
- * Gates file-ref content fields on file access, mirroring the
570
- * attachment-association gate — a file-ref field conveys attachment
571
- * access exactly like an `attachment` association. Only fields present
572
- * in `content` are checked (a content patch is a merge patch; untouched fields
573
- * carry no new reference).
574
- */
575
- async requireFileRefAccess(typeId, content) {
576
- for (const field of await this.fileRefFieldNames(typeId)) {
577
- const value = content[field];
578
- if (typeof value !== 'string')
579
- continue;
580
- if (!(await this.canAccessFile(value))) {
581
- throw new StackPermissionError(`Cannot reference file "${value}" in field "${field}": it does not exist or you cannot read it`);
582
- }
583
- }
584
- }
585
- /**
586
- * Reference-creation gate for one association: `attachment` requires
587
- * file access, and a `relationship` naming a record in this stack
588
- * requires read access to it. `tag` is unchecked, and `_group` roster
589
- * associations are gated by the stricter isGroupManager() instead.
590
- *
591
- * The other target arms are ungated because the gate's purpose —
592
- * refusing a reference that would convey access to, or confirm the
593
- * existence of, an unreadable record — has nothing to bite on: core
594
- * never resolves them, so no access flows through one.
595
- * See docs/spec/access-control.md § Reference-creation gating.
596
- */
597
- async requireAssociationAccess(typeId, association) {
598
- if (association.kind === 'attachment') {
599
- if (!(await this.canAccessFile(association.fileId))) {
600
- throw new StackPermissionError(`Cannot reference file "${association.fileId}": it does not exist or you cannot read it`);
601
- }
602
- }
603
- else if (association.kind === 'relationship' && baseIdOf(typeId) !== SYSTEM_TYPES.GROUP) {
604
- const { target } = association;
605
- // `stackUrl` is tested for a value, not for presence: absent and
606
- // empty are one target — storage, targetEqual() and the filter all
607
- // read them as this stack — so a check on presence alone would
608
- // leave one spelling of a local Record ungated.
609
- if (target.kind !== 'record' || target.stackUrl)
610
- return;
611
- if (!(await this.canReadReferent(target.recordId))) {
612
- throw new StackPermissionError(`Cannot reference record "${target.recordId}": it does not exist or you cannot read it`);
613
- }
614
- }
615
- }
616
- async create(typeIdOrHandle, content, opts = {}) {
617
- if (isTypeHandle(typeIdOrHandle)) {
618
- return typedCreate(this, typeIdOrHandle, content, opts);
619
- }
620
- const typeId = typeIdOrHandle;
621
- const principal = this.#principalId;
622
- if (!principal)
623
- throw new StackPermissionError('Anonymous requesters cannot create records');
624
- // A grantee is exactly the untrusted actor the `id` skew check below
625
- // exists to stop from forging a sort position, and a delegated app
626
- // acting for the owner inherits none of the owner's extra trust — same
627
- // reasoning as mayGrantAccess() below. Asked value-wise, since an
628
- // `undefined` carries no date and Stack.create() reads it as absent.
629
- if ((opts.createdAt !== undefined || opts.updatedAt !== undefined) && !this.ownerActingAlone) {
630
- 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.');
631
- }
632
- if (!(await this.checkCreateGrant(typeId))) {
633
- throw new StackPermissionError(`No create grant for type "${typeId}"`);
634
- }
635
- // The exemption is the owner's own, so delegation doesn't carry it: an
636
- // owner principal acting for someone else would otherwise let that
637
- // subject name any fileId and reach the bytes through the uploader
638
- // clause, which matches on the subject this create stamps.
639
- if (!this.ownerActingAlone && baseIdOf(typeId) === SYSTEM_TYPES.ATTACHMENT) {
640
- const fileId = content.fileId;
641
- if (typeof fileId !== 'string' || !(await this.hasReadableReference(fileId))) {
642
- throw new StackPermissionError(`Cannot reference file "${String(fileId)}": it does not exist or you cannot read it`);
643
- }
644
- }
645
- if (opts.permissions?.length && !this.mayGrantAccess()) {
646
- throw new StackPermissionError('A delegated principal cannot set permissions, at create time or after');
647
- }
648
- if (opts.unlisted && !this.mayGrantAccess()) {
649
- throw new StackPermissionError('A delegated principal cannot create an unlisted record, at create time or after');
650
- }
651
- this.requireOwnerForOwnerDid(typeId, content.did);
652
- await this.requireAppIdMatchesPrincipal(opts.appId);
653
- if (opts.id !== undefined) {
654
- validateRecordId(opts.id);
655
- // Skipped when createdAt is also supplied: only the owner reaches
656
- // here with that combination (checked above), and Stack.create()
657
- // below checks the id against createdAt instead of "now" — the
658
- // check here exists for a live grantee write, and a backdated
659
- // owner create is deliberately not one. See
660
- // docs/spec/data-model.md § Record IDs.
661
- if (opts.createdAt === undefined) {
662
- validateIdTimestampSkew(opts.id, this.idTimestampSkewMs, Date.now(), 'the current time');
663
- }
664
- }
665
- if (opts.parentId !== undefined && !(await this.canReadReferent(opts.parentId))) {
666
- throw new StackPermissionError(`Cannot reference record "${opts.parentId}" as parent: it does not exist or you cannot read it`);
667
- }
668
- for (const assoc of opts.associations ?? []) {
669
- await this.requireAssociationAccess(typeId, assoc);
670
- }
671
- await this.requireFileRefAccess(typeId, content);
672
- return this.stack.create(typeId, content, {
673
- ...opts,
674
- createdBy: this.requester,
675
- });
676
- }
677
- /**
678
- * Whether this request may decide who else reaches a record — the rule
679
- * a reshare enforces, asked at create time too so the reach it
680
- * withholds can't be taken one step earlier while authoring. A delegated
681
- * app is denied it: widening access is the one thing containment most
682
- * needs to hold. Refused rather than silently ignored, so an app never
683
- * believes it published something it didn't. Not `ownerActingAlone`:
684
- * the record is the subject's own, so an owner principal grants it no
685
- * reach the subject lacks.
686
- * See docs/spec/access-control.md § Delegation: principal and subject.
687
- */
688
- mayGrantAccess() {
689
- return !this.delegated || this.#principalId === this.stack.ownerEntityId;
690
- }
691
- /**
692
- * Whether one identity, on its own, may decide who else reaches `record`
693
- * — the owner-or-creator rule a reshare enforces, asked of one
694
- * side at a time. See
695
- * docs/spec/access-control.md § Delegation: principal and subject.
696
- */
697
- mayReshare(entityId, record) {
698
- if (!entityId)
699
- return false;
700
- return entityId === this.stack.ownerEntityId || entityId === record.createdBy?.subjectId;
701
- }
702
- async get(idOrHandle, idOrOpts = {}) {
703
- if (isTypeHandle(idOrHandle))
704
- return typedGet(this, idOrHandle, idOrOpts);
705
- const id = idOrHandle;
706
- const opts = idOrOpts;
707
- const record = await this.stack.get(id, opts);
708
- if (!record)
709
- return null;
710
- if (!(await this.canRead(record)))
711
- return null;
712
- return presentDeleted(record);
713
- }
714
- /**
715
- * Runs through this.query(), so canRead() still applies — this answers a
716
- * question the caller asks on its own behalf. includeUnlisted is passed
717
- * only when the request is the owner acting alone, per
718
- * docs/spec/identity.md § DID bindings.
719
- */
720
- async getEntityByDid(did) {
721
- return lookupEntityByDid((q) => this.query(q), did, filtersContent(this.stack.capabilities), this.ownerActingAlone);
722
- }
723
- async getOwnerEntity() {
724
- return this.getEntityByDid(this.stack.ownerEntityId);
725
- }
726
- async query(first = {}, typedQueryArg) {
727
- if (isTypeHandle(first))
728
- return typedQuery(this, first, typedQueryArg);
729
- const query = first;
730
- assertValidSort(query.sort);
731
- assertSortCapability(query.sort, this.stack.capabilities);
732
- assertValidAssociationFilters(query.filter);
733
- assertValidBaseIdFilter(query.filter);
734
- if (query.filter?.includeUnlisted && !this.ownerActingAlone) {
735
- throw new StackPermissionError('includeUnlisted is owner-only');
736
- }
737
- const limit = Math.min(query.limit ?? DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT);
738
- const records = [];
739
- const maxFetched = limit * 10;
740
- let totalFetched = 0;
741
- const prefetchedGrants = this.identified ? await this.loadGrants() : undefined;
742
- // Scoped to this query, like prefetchedGrants beside it: every
743
- // candidate Record shares one roster resolution per group, and nothing
744
- // is carried into the next operation.
745
- const groupRoles = new Map();
746
- let page = { records: [], cursor: query.cursor ?? null };
747
- do {
748
- page = await this.stack.query({ ...query, cursor: page.cursor ?? undefined });
749
- totalFetched += page.records.length;
750
- for (const record of page.records) {
751
- // Projected here as well as in get(), or `includeDeleted` would be
752
- // a strictly better read channel than the fetch-by-ID it is
753
- // supposed to match.
754
- if (await this.canRead(record, prefetchedGrants, groupRoles)) {
755
- records.push(presentDeleted(record));
756
- }
757
- }
758
- } while (records.length < limit && page.cursor && totalFetched < maxFetched);
759
- return { records, cursor: page.cursor };
760
- }
761
- async mutate(first, second, third, fourth = {}) {
762
- if (isTypeHandle(first)) {
763
- return typedMutate(this, first, second, third, fourth);
764
- }
765
- const id = first;
766
- const changes = second;
767
- const opts = (third ?? {});
768
- // Ahead of every gate: a malformed change set is a validation error
769
- // for every requester, rather than one for the owner and a permission
770
- // refusal for everyone else.
771
- assertNonEmptyChangeSet(changes);
772
- if (changes.contentPatch) {
773
- const patchErrors = validatePatchValues(changes.contentPatch);
774
- if (patchErrors.length > 0)
775
- throw new StackValidationError(patchErrors);
776
- }
777
- // Refused here as well as in `Stack` below, so the partition is a
778
- // refusal rather than a permission question: an `associations` key
779
- // naming an authority kind is the wrong surface for every requester,
780
- // owner included.
781
- if (changes.associations)
782
- assertDataAssociations(changes.associations, 'associations');
783
- if (changes.permissions)
784
- assertAuthorityAssociations(changes.permissions, 'permissions');
785
- const writes = changes.contentPatch !== undefined ||
786
- changes.associations !== undefined ||
787
- changes.parentId !== undefined;
788
- // `unlisted` reshares on the key's presence alone: it is a boolean, so
789
- // naming it is the whole of what it can say. `permissions` is a set,
790
- // and a set restated is not a reshare — see permissionsMove() below,
791
- // which refines the escalation on this branch alone. A change set
792
- // naming no writable key is gated before any record is in hand to
793
- // compute a delta from, so there it reshares on presence, like the
794
- // boolean. See docs/spec/access-control.md
795
- // § Storage unifies; the API does not.
796
- const unlists = changes.unlisted !== undefined;
797
- // requireUpdatable() reads the record and applies the write gate; the
798
- // reshare keys need the record before their own gate, and a change set
799
- // carrying only those must not be held to the write gate it doesn't
800
- // need. One read either way.
801
- const record = writes ? await this.requireUpdatable(id) : await this.requireReshareable(id);
802
- // Narrowed to what the gate below actually authorized. A key the gate
803
- // read as inert is dropped rather than forwarded: `Stack` recomputes
804
- // the delta against its own read of the record, so a key left standing
805
- // on the strength of one read would write the ACL on the strength of
806
- // another — and the set that reaches it is the one this requester saw,
807
- // which is by then stale. See docs/spec/access-control.md
808
- // § Storage unifies; the API does not.
809
- let authorized = changes;
810
- if (writes) {
811
- const moves = this.permissionsMove(record, changes);
812
- if (unlists || moves)
813
- await this.requireReshareOf(record);
814
- if (changes.permissions !== undefined && !moves && !this.canReshare(record)) {
815
- const { permissions: _inert, ...rest } = changes;
816
- authorized = rest;
817
- }
818
- }
819
- if (changes.contentPatch) {
820
- const patch = changes.contentPatch;
821
- // Value-wise, not presence-wise: a client that reads a card, edits
822
- // its `name` and sends the whole content object back is not setting
823
- // the binding it round-trips, and `name` is writable by record-level
824
- // permission. Same predicate restoreVersion() applies to a snapshot.
825
- this.requireOwnerForAppIdentity(record.typeId, (field) => field in patch && patch[field] !== record.content[field]);
826
- // Likewise value-wise: re-sending the DID a card already holds
827
- // claims nothing, and immutability refuses changing it regardless.
828
- if ('did' in patch && patch.did !== record.content.did) {
829
- this.requireOwnerForOwnerDid(record.typeId, patch.did);
830
- }
831
- await this.requireFileRefAccess(record.typeId, patch);
832
- }
833
- // Every association the change set would add is a reference this
834
- // requester has to be allowed to create — asked against the stored
835
- // set, so an association already on the record is not re-gated.
836
- for (const assoc of changes.associations ?? []) {
837
- if ((record.associations ?? []).some((a) => associationEqual(a, assoc)))
838
- continue;
839
- await this.requireAssociationAccess(record.typeId, assoc);
840
- }
841
- if (changes.parentId != null && !(await this.canReadReferent(changes.parentId))) {
842
- throw new StackPermissionError(`Cannot reference record "${changes.parentId}" as parent: it does not exist or you cannot read it`);
843
- }
844
- return this.stack.mutate(id, authorized, { ...opts, ...this.actor });
845
- }
846
- async patchContent(first, second, third, fourth) {
847
- if (isTypeHandle(first)) {
848
- return this.mutate(first, second, { contentPatch: third }, fourth);
849
- }
850
- return this.mutate(first, { contentPatch: second }, third);
851
- }
852
- /**
853
- * The record, having established that this request may decide who else
854
- * reaches it — the owner-or-creator rule, asked of both sides of a
855
- * delegation, or a Group's admin rule for a `_group`. The gate the
856
- * `permissions` and `unlisted` keys carry, which the write bit
857
- * deliberately does not confer.
858
- */
859
- async requireReshareable(id) {
860
- const record = await this.stack.get(id, { includeDeleted: true });
861
- if (!record)
862
- throw new StackNotFoundError(`Record not found: "${id}"`);
863
- await this.requireOwnerForGrantRecord(record);
864
- await this.requireReshareOf(record);
865
- return record;
866
- }
867
- /**
868
- * Whether a change set's `permissions` key actually moves the ACL. Read
869
- * off the computed delta rather than the elements the caller supplied:
870
- * a gate that inspected only what was named would miss the wholesale
871
- * replacement that drops everything, which names nothing at all. Any
872
- * add, remove or repoint in either direction is a reshare.
873
- * See docs/spec/access-control.md § Record-level permissions.
874
- */
875
- permissionsMove(record, changes) {
876
- if (changes.permissions === undefined)
877
- return false;
878
- return associationDelta(record.permissions ?? [], changes.permissions).length > 0;
879
- }
880
- /** The reshare decision alone, for a record already read and write-gated. */
881
- async requireReshareOf(record) {
882
- if (!this.canReshare(record)) {
883
- throw await this.denialFor(record, `Cannot change permissions on "${record.id}": only its author or the stack owner can reshare it`);
884
- }
885
- }
886
- /**
887
- * Whether this request may decide who else reaches `record` — the
888
- * decision requireReshareOf() refuses on, without the refusal, for the
889
- * read paths that project on it rather than throw.
890
- *
891
- * A `_group` asks management, not authorship: a creator later demoted
892
- * from the admin roster shouldn't retain a side door to reassign who can
893
- * read or write the group record. Everything else is intersected like
894
- * every other authority here, or an owner principal would carry its
895
- * subject to records the subject cannot touch.
896
- */
897
- canReshare(record) {
898
- if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP)
899
- return this.isGroupManager(record);
900
- if (!this.mayReshare(this.#principalId, record))
901
- return false;
902
- return !this.delegated || this.mayReshare(this.#subjectId, record);
903
- }
904
- /**
905
- * Naming the software behind a key is the trust decision the `_app`
906
- * registry exists to record, so both halves of that binding — `did` and
907
- * `appId` — belong to the owner alone. Without this, record-level `write`
908
- * on a card would be a second way in: one carrying no DID yet could be
909
- * pointed at a write-holder's own key, or relabelled to claim another
910
- * app's `appId`. `name` and `version` stay writable — display, not lookup.
911
- *
912
- * Owner *acting alone*, in both directions, or the same route reopens
913
- * from the subject's side. `_entity` deliberately does not get this rule:
914
- * naming people is what a contacts app does, so its cards stay writable
915
- * by grant. See docs/spec/identity.md § DID bindings.
916
- */
917
- requireOwnerForAppIdentity(typeId, touches) {
918
- if (baseIdOf(typeId) !== SYSTEM_TYPES.APP)
919
- return;
920
- if (!bindingFieldsOf(SYSTEM_TYPES.APP).some(touches))
921
- return;
922
- this.requireOwnerActingAlone('Only the stack owner may set an _app record’s did or appId');
923
- }
924
- /**
925
- * A self-reported `appId` must agree with the `_app` card naming the
926
- * principal's DID, where the owner registered one: `principalId` is
927
- * verified, so letting the pair disagree would leave a verified principal
928
- * claiming a name the owner gave different software. A principal with no
929
- * card keeps `appId` as the bare self-report it is for every undelegated
930
- * writer. See docs/spec/identity.md § Attribution and what can be trusted.
931
- */
932
- async requireAppIdMatchesPrincipal(appId) {
933
- if (appId === undefined || !this.delegated)
934
- return;
935
- const card = await findFirstMatch((q) => this.stack.query(q), {
936
- filter: {
937
- baseId: SYSTEM_TYPES.APP,
938
- includeDeleted: true,
939
- includeUnlisted: true,
940
- ...(filtersContent(this.stack.capabilities) && {
941
- content: { did: this.#principalId },
942
- }),
943
- },
944
- }, (r) => r.content.did === this.#principalId);
945
- if (card && card.content.appId !== appId) {
946
- throw new StackPermissionError(`appId "${appId}" is not the appId registered for this principal`);
947
- }
948
- }
949
- /**
950
- * The owner's own DID is the one `_entity` binding a grantee may not claim.
951
- * `ownerProfile` adopts whichever card holds it, so a card minted by
952
- * someone else becomes the stack's own profile, and uniqueness then makes
953
- * that permanent. Every other DID stays open to a contacts app, which is
954
- * the reach `_entity` is grantable for.
955
- * See docs/spec/identity.md § DID bindings.
956
- */
957
- requireOwnerForOwnerDid(typeId, did) {
958
- if (baseIdOf(typeId) !== SYSTEM_TYPES.ENTITY)
959
- return;
960
- if (did !== this.stack.ownerEntityId)
961
- return;
962
- this.requireOwnerActingAlone('Only the stack owner may claim the owner’s own did');
963
- }
964
- /**
965
- * A `_grant` Record *is* authority, so rewriting one is the escalation
966
- * UNGRANTABLE_SYSTEM_TYPES refuses at evaluation, reached by editing an
967
- * existing grant rather than minting a fresh one. An `_install` decides
968
- * grants and migration authority, so it is fenced the same way. The verbs
969
- * that write either live on `Stack`, never `StackClient`, so no scoped
970
- * write is lost. Writes only: reading one and its history stays on the
971
- * ordinary gate. See docs/spec/access-control.md § Type-level grants.
972
- */
973
- async requireOwnerForGrantRecord(record) {
974
- const family = baseIdOf(record.typeId);
975
- if (family !== SYSTEM_TYPES.GRANT && family !== SYSTEM_TYPES.INSTALL)
976
- return;
977
- if (this.ownerActingAlone)
978
- return;
979
- throw await this.denialFor(record, `Only the stack owner may write a ${family} record`);
980
- }
981
- /**
982
- * Gated on the write bit alone, which is why the list is held to its
983
- * rules first: authority reached through this verb would be exactly the
984
- * escalation the partition exists to stop, decided before any record is
985
- * read so it cannot depend on who is asking.
986
- */
987
- async associate(id, associations) {
988
- return this.amendAssociations(id, associations.map((association) => ({ op: 'add', association })), 'associate()');
989
- }
990
- /** See associate() — the same write gate, the same kind refusal. */
991
- async dissociate(id, associations) {
992
- return this.amendAssociations(id, associations.map((association) => ({ op: 'remove', association })), 'dissociate()');
993
- }
994
- /**
995
- * The write gate, plus the per-element reference gate on each add: what a
996
- * remove names is already on the record, so it is gated by the write bit
997
- * alone. See associate().
998
- */
999
- async amendAssociations(id, changes, surface = 'amendAssociations()') {
1000
- assertAssociationEdits(changes, surface, 'data');
1001
- const record = await this.requireUpdatable(id);
1002
- for (const change of changes) {
1003
- if (change.op === 'add')
1004
- await this.requireAssociationAccess(record.typeId, change.association);
1005
- }
1006
- return this.stack.amendAssociations(id, changes, this.actor, surface);
1007
- }
1008
- /**
1009
- * Extend who reaches a record — the reshare gate's own verb, on the
1010
- * owner-or-creator rule the `permissions` key carries. The write bit
1011
- * does not confer it: a write-holder who could grant would escalate to
1012
- * deciding who else reaches the record, which is the whole of what
1013
- * scoping access is for.
1014
- * See docs/spec/access-control.md § Record-level permissions.
1015
- */
1016
- async grantAccess(id, permissions) {
1017
- return this.amendAccess(id, permissions.map((association) => ({ op: 'add', association })), 'grantAccess()');
1018
- }
1019
- /** Withdraw elements of who reaches a record — see grantAccess(). */
1020
- async revokeAccess(id, permissions) {
1021
- return this.amendAccess(id, permissions.map((association) => ({ op: 'remove', association })), 'revokeAccess()');
1022
- }
1023
- /** The reshare gate over an edit list — see grantAccess(). */
1024
- async amendAccess(id, changes, surface = 'amendAccess()') {
1025
- assertAssociationEdits(changes, surface, 'authority');
1026
- await this.requireReshareable(id);
1027
- return this.stack.amendAccess(id, changes, this.actor, surface);
1028
- }
1029
- /**
1030
- * Purge is owner-only: it is irreversible and destroys version
1031
- * history, so neither the write bit nor delete-own/delete-any grants
1032
- * reach it, and delegation doesn't carry it either. Everyone else is
1033
- * limited to soft delete.
1034
- */
1035
- async delete(id, opts = {}) {
1036
- const { referencedFileIds } = await this.deleteAndReturn(id, opts);
1037
- return { referencedFileIds };
1038
- }
1039
- /**
1040
- * delete(), plus the record it acted on. Gated identically — the
1041
- * existence/visibility check that produces a 404 ahead of the 403
1042
- * permission gate still runs via requireDeletable() before either kind
1043
- * of delete, so this changes what a successful call reports, not the
1044
- * disclosure ordering a refused one follows.
1045
- */
1046
- async deleteAndReturn(id, opts = {}) {
1047
- await this.requireDeletable(id);
1048
- if (opts.purge && !this.ownerActingAlone) {
1049
- throw new StackPermissionError('Purge is owner-only');
1050
- }
1051
- return this.stack.deleteAndReturn(id, { ...opts, ...this.actor });
1052
- }
1053
- /**
1054
- * Reverse a soft delete. Gated the same as delete() — undelete is the
1055
- * inverse of soft delete, so granting one direction without the other
1056
- * would be backwards. Idempotent, per Stack.undelete().
1057
- */
1058
- async undelete(id, opts = {}) {
1059
- await this.requireDeletable(id);
1060
- return this.stack.undelete(id, { ...opts, ...this.actor });
1061
- }
1062
- /**
1063
- * History is the mutation/recovery surface, not a read surface — gated
1064
- * like patchContent(). Reading it changes nothing, so it is the one path
1065
- * the `_grant` write fence leaves alone: seeing how a Record you can
1066
- * already read got that way is not the escalation that fence exists to
1067
- * stop. See docs/spec/versioning.md § History access.
1068
- */
1069
- async getVersions(id, query = {}) {
1070
- await this.requireUpdatable(id, { mutating: false });
1071
- return this.stack.getVersions(id, query);
1072
- }
1073
- /**
1074
- * See getVersions() — the same mutate-surface gate, for the same reason.
1075
- *
1076
- * The journal is where a Record's sharing history lives, and that half
1077
- * is the resharer's: a reader who could not have moved the ACL is served
1078
- * the `reshare` op without the elements beneath it, so the entry
1079
- * still names that it moved. Asked of both identities, so delegation is
1080
- * no route to it either. See docs/spec/journal.md § Reading it.
1081
- */
1082
- async getJournal(id, query = {}) {
1083
- const record = await this.requireUpdatable(id, { mutating: false });
1084
- const entries = await this.stack.getJournal(id, query);
1085
- return this.canReshare(record) ? entries : entries.map(withoutAuthorityChanges);
1086
- }
1087
- /** See getVersions() — the same mutate-surface gate. */
1088
- async getVersion(id, version) {
1089
- await this.requireUpdatable(id, { mutating: false });
1090
- return this.stack.getVersion(id, version);
1091
- }
1092
- /**
1093
- * Re-runs the reference-creation checks against the snapshot, so a
1094
- * restore can't re-convey access to a file the subject can no longer
1095
- * reach today. Only the owner acting alone is exempt: under delegation
1096
- * the checks resolve against the subject, which is whose reach the
1097
- * restore would widen. See docs/spec/versioning.md § Restore semantics.
1098
- */
1099
- async restoreVersion(id, version, opts = {}) {
1100
- const record = await this.requireUpdatable(id);
1101
- if (!this.ownerActingAlone) {
1102
- const target = await this.stack.getVersion(id, version);
1103
- if (target) {
1104
- // A rollback that would move a card's binding is the same trust
1105
- // decision a content patch reserves to the owner, reached by another route.
1106
- this.requireOwnerForAppIdentity(record.typeId, (field) => target.content[field] !==
1107
- record.content[field]);
1108
- await this.requireFileRefAccess(target.typeId, target.content);
1109
- // Content's file refs are the only reference a restore can put
1110
- // back: containment and associations are not on a snapshot at all,
1111
- // so a restore never introduces either — see
1112
- // docs/spec/versioning.md § Restore semantics.
1113
- }
1114
- }
1115
- return this.stack.restoreVersion(id, version, { ...opts, ...this.actor });
1116
- }
1117
- /**
1118
- * Commit a per-record migration — **the owner acting alone**, the same
1119
- * restriction the bulk `migrateAll()` carries by living on `Stack`, with
1120
- * one exception: an installed app migrating within its own families (see
1121
- * installMayMigrate()).
1122
- *
1123
- * Migrate replaces `content` and `typeId` wholesale, so a grant-based
1124
- * version would have to re-derive every gate `create()` applies at the
1125
- * destination *and* every gate `mutate()` applies over the existing
1126
- * content, reopening each one it missed. The sharpest is create()'s
1127
- * non-owner `_attachment@1` refusal: without it, a requester could
1128
- * migrate a record they authored into the family naming any `fileId` and
1129
- * read the bytes through canAccessFile()'s uploader clause. Ordinary
1130
- * write access to a record is not consent to move it between families.
1131
- * See docs/spec/data-model.md § Type migrations.
1132
- */
1133
- async commitMigration(id, toTypeId, content, opts = {}) {
1134
- if (!this.ownerActingAlone && !(await this.installMayMigrate(id, toTypeId))) {
1135
- throw new StackPermissionError('Only the stack owner, or an installed app within the type families it defines, may commit a migration');
1136
- }
1137
- // The new content is a fresh set of file references, so an app is held
1138
- // to the gate create() applies; otherwise a migration could point one of
1139
- // its records at any attachment and read the bytes through it.
1140
- if (!this.ownerActingAlone)
1141
- await this.requireFileRefAccess(toTypeId, content);
1142
- return this.stack.commitMigration(id, toTypeId, content, { ...opts, ...this.actor });
1143
- }
1144
- /**
1145
- * Whether this request is an installed app migrating a record within its
1146
- * own families: the app acting as itself, its key linked to the one live
1147
- * install claiming both families in its own namespace, `toTypeId` a version the owner
1148
- * approved, and an `update-any` grant on each family made out to the key
1149
- * directly. Read as data, so a family two installs claim — however that
1150
- * came to be — confers nothing.
1151
- *
1152
- * The reasons migration is otherwise owner-only do not reach this case:
1153
- * no install can claim a system family, so neither an `_attachment` nor
1154
- * a DID binding is in reach, and the owner's approval of the version is
1155
- * the consent. See docs/spec/apps.md § Migrating an installed app's types.
1156
- */
1157
- async installMayMigrate(id, toTypeId) {
1158
- const principal = this.#principalId;
1159
- if (!principal || this.delegated)
1160
- return false;
1161
- const record = await this.stack.get(id, { includeDeleted: true });
1162
- if (!record)
1163
- return false;
1164
- const families = new Set([baseIdOf(record.typeId), baseIdOf(toTypeId)]);
1165
- const installs = (await queryAllPages((q) => this.stack.query(q), {
1166
- filter: { baseId: SYSTEM_TYPES.INSTALL, includeDeleted: true, includeUnlisted: true },
1167
- }));
1168
- let install;
1169
- for (const family of families) {
1170
- const claimants = installs.filter((r) => claimedFamilies(r.content).has(family));
1171
- if (claimants.length !== 1)
1172
- return false;
1173
- if (familyStanding(family, claimants[0].content.appId) !== 'own')
1174
- return false;
1175
- if (install && install.id !== claimants[0].id)
1176
- return false;
1177
- install = claimants[0];
1178
- }
1179
- if (!install || install.deletedAt)
1180
- return false;
1181
- if (!Array.isArray(install.content.defines) || !install.content.defines.includes(toTypeId)) {
1182
- return false;
1183
- }
1184
- let linked = false;
1185
- for (const cardId of linkedIds(install, INSTALL_APP_LABEL)) {
1186
- const card = await this.stack.get(cardId);
1187
- if (card && baseIdOf(card.typeId) === SYSTEM_TYPES.APP) {
1188
- if (card.content.did === principal)
1189
- linked = true;
1190
- }
1191
- }
1192
- if (!linked)
1193
- return false;
1194
- const grants = await this.loadGrants();
1195
- for (const family of families) {
1196
- const held = await this.hasGrant(`${family}@1`, ['update-any'], {
1197
- grantee: principal,
1198
- prefetchedGrants: grants,
1199
- matchOwn: false,
1200
- allowDefault: false,
1201
- allowGroup: false,
1202
- });
1203
- if (!held)
1204
- return false;
1205
- }
1206
- // Asked only once authority is settled, as every mutating verb asks it.
1207
- // The owner may migrate a tombstone; an app sees it without content.
1208
- // See docs/spec/apps.md § Migrating an installed app's types.
1209
- if (record.deletedAt) {
1210
- throw new StackConflictError(`Record "${id}" is soft-deleted; undelete it before migrating it.`);
1211
- }
1212
- return true;
1213
- }
1214
- /**
1215
- * Store bytes and create an _attachment@1 metadata record (create grant
1216
- * on `_attachment@1` required; anonymous denied), returning that record.
1217
- * Authorship and principal are stamped exactly as create() does.
1218
- */
1219
- async putAttachment(data, opts) {
1220
- const { mimeType, filename, appId } = opts;
1221
- // The one ScopedStack path that reaches the adapter without going
1222
- // through Stack first — without this, a closed stack would still write
1223
- // bytes before the delegated create() refused.
1224
- this.assertStackOpen();
1225
- const principal = this.#principalId;
1226
- if (!principal) {
1227
- throw new StackPermissionError('Anonymous requesters cannot upload attachments');
1228
- }
1229
- if (!(await this.checkCreateGrant(`${SYSTEM_TYPES.ATTACHMENT}@1`))) {
1230
- throw new StackPermissionError(`No create grant for type "${SYSTEM_TYPES.ATTACHMENT}@1"`);
1231
- }
1232
- await this.requireAppIdMatchesPrincipal(appId);
1233
- assertAttachmentSize(data.byteLength, this.capabilities.limits.attachmentBytes);
1234
- const fileId = await this.adapter.putBlob(data);
1235
- return this.stack.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, {
1236
- fileId,
1237
- mimeType,
1238
- size: data.byteLength,
1239
- ...(filename && { filename }),
1240
- }, { createdBy: this.requester, appId });
1241
- }
1242
- /**
1243
- * Download attachment bytes. Accessible to the owner, a reader of any
1244
- * referencing record, or the uploader pre-association — the same
1245
- * predicate as the reference-creation gate (canAccessFile).
1246
- */
1247
- async getAttachment(fileId) {
1248
- if (!(await this.canAccessFile(fileId))) {
1249
- throw new StackPermissionError(`Cannot read file "${fileId}": it does not exist or you cannot read it`);
1250
- }
1251
- return this.stack.getAttachment(fileId);
1252
- }
1253
- /**
1254
- * Delete an attachment. Only the stack owner may delete attachments.
1255
- * Delegates to Stack.deleteAttachment(), which enforces the "not referenced" check.
1256
- */
1257
- async deleteAttachment(fileId) {
1258
- this.requireOwnerActingAlone('Only the stack owner can delete attachments');
1259
- return this.stack.deleteAttachment(fileId, this.actor);
1260
- }
1261
- /**
1262
- * Sweep for unreferenced attachment bytes and delete them. Only the stack
1263
- * owner may run this. Delegates to Stack.collectAttachmentGarbage().
1264
- */
1265
- async collectAttachmentGarbage(opts) {
1266
- this.requireOwnerActingAlone('Only the stack owner can collect attachment garbage');
1267
- return this.stack.collectAttachmentGarbage({ ...opts, ...this.actor });
1268
- }
1269
- async subscribe(first, second, third) {
1270
- if (isTypeHandle(first)) {
1271
- return typedSubscribe(this, first, second, third);
1272
- }
1273
- const handler = first;
1274
- const opts = (second ?? {});
1275
- this.assertStackOpen();
1276
- if (this.relaysChanges) {
1277
- throw new RelayScopeError('This stack relays changes from elsewhere, and a scoped view cannot narrow that feed: ' +
1278
- 'a relayed frame was already scoped by the session that opened it, and a purge leaves ' +
1279
- 'no record to re-check. Subscribe with a session-scoped stack instead.');
1280
- }
1281
- // A relaying stack was already refused above, so relaysChanges is
1282
- // always false here — `since` never has a cursor to mean anything by.
1283
- assertSinceUsable(opts.since, false);
1284
- assertValidBaseIdFilter(opts.filter);
1285
- if (opts.includeUnlisted && !this.ownerActingAlone) {
1286
- throw new StackPermissionError('includeUnlisted is owner-only');
1287
- }
1288
- return this.changes.add(new ScopedSubscription((record, cache) => this.canReadCached(record, cache), handler, opts));
1289
- }
1290
- /**
1291
- * canRead for one event, through the subscription's own cache. Grants
1292
- * are prefetched once and refilled after an invalidation rather than
1293
- * re-queried per event, which would be a `_grant` scan for every change
1294
- * the stack makes.
1295
- */
1296
- async canReadCached(record, cache) {
1297
- const grants = this.identified ? await cache.grants(() => this.loadGrants()) : undefined;
1298
- return this.canRead(record, grants, cache.roles);
1299
- }
1300
- }
1301
- //# sourceMappingURL=scoped-stack.js.map