@haverstack/core 0.41.0 → 0.43.0

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