@pathmx/core 0.5.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 (195) hide show
  1. package/LICENSE.md +172 -0
  2. package/README.md +111 -0
  3. package/actions/actions.ts +307 -0
  4. package/actions/draft.ts +105 -0
  5. package/actions/transaction.ts +164 -0
  6. package/assets.ts +149 -0
  7. package/authority/access.ts +820 -0
  8. package/authority/actors.ts +271 -0
  9. package/authority/agents.ts +95 -0
  10. package/authority/credentials.ts +144 -0
  11. package/authority/model.ts +71 -0
  12. package/authority/sharing.ts +89 -0
  13. package/browser-bundle.ts +61 -0
  14. package/canonical.ts +41 -0
  15. package/cli/dev.ts +134 -0
  16. package/cli/export.ts +74 -0
  17. package/cli/index.ts +52 -0
  18. package/cli/lint.ts +164 -0
  19. package/cli/mv.ts +30 -0
  20. package/cli/perms.ts +182 -0
  21. package/cli/plugin-packages.ts +159 -0
  22. package/cli/plugins.ts +95 -0
  23. package/cli/query.ts +225 -0
  24. package/cli/readme.md +95 -0
  25. package/cli/release.ts +75 -0
  26. package/cli/rm.ts +22 -0
  27. package/cli/serve.ts +100 -0
  28. package/cli/tree.ts +131 -0
  29. package/cli/update.ts +78 -0
  30. package/cli/utils.ts +53 -0
  31. package/core.ts +96 -0
  32. package/database.ts +189 -0
  33. package/dependencies.ts +60 -0
  34. package/dist/pathmx.js +1224 -0
  35. package/document.ts +194 -0
  36. package/environment.ts +110 -0
  37. package/environments/bun-plugins.ts +68 -0
  38. package/environments/bun-publish.ts +194 -0
  39. package/environments/bun.ts +223 -0
  40. package/globals.d.ts +14 -0
  41. package/graph.ts +153 -0
  42. package/host/compiler.ts +314 -0
  43. package/host/create.ts +66 -0
  44. package/host/engine.ts +630 -0
  45. package/host/plugin-host.ts +487 -0
  46. package/host/render.ts +102 -0
  47. package/host/response.ts +20 -0
  48. package/host/shell.html +15 -0
  49. package/host/source-sync.ts +254 -0
  50. package/html-attributes.ts +15 -0
  51. package/html.ts +48 -0
  52. package/icons/LICENSE +43 -0
  53. package/icons/lucide.ts +54 -0
  54. package/index.ts +75 -0
  55. package/invalidation.ts +85 -0
  56. package/log.ts +84 -0
  57. package/ops/href.ts +87 -0
  58. package/ops/index.ts +9 -0
  59. package/ops/move-source.ts +114 -0
  60. package/ops/plan.ts +47 -0
  61. package/ops/remove-source.ts +70 -0
  62. package/package.json +46 -0
  63. package/path.ts +75 -0
  64. package/pattern.ts +314 -0
  65. package/perf.ts +88 -0
  66. package/plugins/actor-path.ts +43 -0
  67. package/plugins/actor-profile.ts +55 -0
  68. package/plugins/agent-browsing.ts +183 -0
  69. package/plugins/app-component.ts +23 -0
  70. package/plugins/block-layout/index.ts +78 -0
  71. package/plugins/block-layout/theme.css +90 -0
  72. package/plugins/callout/index.ts +236 -0
  73. package/plugins/callout/theme.css +138 -0
  74. package/plugins/canonical-links.ts +144 -0
  75. package/plugins/code-highlight/client.ts +89 -0
  76. package/plugins/code-highlight/index.ts +147 -0
  77. package/plugins/code-highlight/theme.css +89 -0
  78. package/plugins/context.ts +131 -0
  79. package/plugins/datatype/Datatype[wdth,wght].woff2 +0 -0
  80. package/plugins/datatype/OFL.txt +96 -0
  81. package/plugins/datatype/index.ts +287 -0
  82. package/plugins/definition-list.ts +236 -0
  83. package/plugins/directive-definitions.ts +13 -0
  84. package/plugins/embedding/client.ts +88 -0
  85. package/plugins/embedding/index.ts +43 -0
  86. package/plugins/image/index.ts +68 -0
  87. package/plugins/image/theme.css +43 -0
  88. package/plugins/include.ts +119 -0
  89. package/plugins/index.ts +91 -0
  90. package/plugins/input/authoring.ts +236 -0
  91. package/plugins/input/config.ts +85 -0
  92. package/plugins/input/index.ts +359 -0
  93. package/plugins/input/model.ts +173 -0
  94. package/plugins/input/state.ts +272 -0
  95. package/plugins/layout.ts +313 -0
  96. package/plugins/literate/client.ts +208 -0
  97. package/plugins/literate/index.ts +742 -0
  98. package/plugins/math/index.ts +140 -0
  99. package/plugins/meta.ts +42 -0
  100. package/plugins/navigation/index.ts +158 -0
  101. package/plugins/navigation/model.ts +364 -0
  102. package/plugins/preset.ts +65 -0
  103. package/plugins/query/index.ts +322 -0
  104. package/plugins/rss/index.ts +46 -0
  105. package/plugins/runtime/actions.ts +62 -0
  106. package/plugins/runtime/client.ts +8 -0
  107. package/plugins/runtime/document.ts +506 -0
  108. package/plugins/runtime/error.ts +154 -0
  109. package/plugins/runtime/heading-links.ts +31 -0
  110. package/plugins/runtime/index.ts +104 -0
  111. package/plugins/runtime/live.ts +51 -0
  112. package/plugins/runtime/navigation.ts +113 -0
  113. package/plugins/runtime/pending.ts +53 -0
  114. package/plugins/runtime/publication.ts +19 -0
  115. package/plugins/runtime/start.ts +177 -0
  116. package/plugins/runtime/static-client.ts +6 -0
  117. package/plugins/runtime/static-live.ts +188 -0
  118. package/plugins/runtime/view-transition.ts +72 -0
  119. package/plugins/runtime/viewport.ts +110 -0
  120. package/plugins/schema.ts +24 -0
  121. package/plugins/scripts.ts +140 -0
  122. package/plugins/source-preview/client.ts +256 -0
  123. package/plugins/source-preview/index.ts +132 -0
  124. package/plugins/source-preview/model.ts +11 -0
  125. package/plugins/source-preview/preview.css +107 -0
  126. package/plugins/styles.ts +145 -0
  127. package/plugins/theme/components.ts +10 -0
  128. package/plugins/theme/foundation.css +358 -0
  129. package/plugins/theme/index.ts +333 -0
  130. package/plugins/theme/prose.css +284 -0
  131. package/plugins/theme/scheme.js +63 -0
  132. package/plugins/theme/toggle.css +44 -0
  133. package/plugins/theme/view-transition.css +68 -0
  134. package/plugins/toc/client.ts +146 -0
  135. package/plugins/toc/index.ts +232 -0
  136. package/plugins/toc/theme.css +172 -0
  137. package/plugins/types.ts +538 -0
  138. package/problem.ts +41 -0
  139. package/project.ts +6 -0
  140. package/publish/build.ts +402 -0
  141. package/publish/concurrent.ts +32 -0
  142. package/publish/identity.ts +63 -0
  143. package/publish/index.ts +11 -0
  144. package/publish/location.ts +167 -0
  145. package/publish/model.ts +58 -0
  146. package/publish/plan.ts +300 -0
  147. package/publish/validate.ts +150 -0
  148. package/query/candidate-index.ts +101 -0
  149. package/query/dependency-index.ts +99 -0
  150. package/query/fields.ts +216 -0
  151. package/query/invalidation.ts +48 -0
  152. package/query/kernel.ts +776 -0
  153. package/query/model.ts +79 -0
  154. package/repository.ts +374 -0
  155. package/runtime-profile.ts +34 -0
  156. package/schema/model.ts +92 -0
  157. package/schema/registry.ts +642 -0
  158. package/schema/standard.ts +226 -0
  159. package/server/context.ts +94 -0
  160. package/server/css-imports.ts +66 -0
  161. package/server/http.ts +333 -0
  162. package/server/page.ts +21 -0
  163. package/server/protocols.ts +12 -0
  164. package/server/representations.ts +66 -0
  165. package/server/router.ts +131 -0
  166. package/server/routes.ts +202 -0
  167. package/server/serve.ts +5 -0
  168. package/server/watch.ts +120 -0
  169. package/source/blocks.ts +71 -0
  170. package/source/format.ts +164 -0
  171. package/source/index.ts +67 -0
  172. package/source/interpolate.ts +63 -0
  173. package/source/markdown/blocks.ts +46 -0
  174. package/source/markdown/comments.ts +32 -0
  175. package/source/markdown/fences.ts +67 -0
  176. package/source/markdown/headings.ts +88 -0
  177. package/source/markdown/index.ts +34 -0
  178. package/source/markdown/inline-code.ts +6 -0
  179. package/source/markdown/links.ts +326 -0
  180. package/source/markdown/lists.ts +223 -0
  181. package/source/markdown/prose.ts +74 -0
  182. package/source/markdown/scan.ts +79 -0
  183. package/source/markdown/text.ts +82 -0
  184. package/source/meta.ts +87 -0
  185. package/source/source.ts +121 -0
  186. package/source/topmatter.ts +57 -0
  187. package/source/utils.ts +21 -0
  188. package/source-diagnostic.ts +63 -0
  189. package/text-file.ts +26 -0
  190. package/util.ts +35 -0
  191. package/view/components.ts +499 -0
  192. package/view/model.ts +91 -0
  193. package/view/projection.ts +162 -0
  194. package/view/session.ts +146 -0
  195. package/view/syntax.ts +372 -0
@@ -0,0 +1,820 @@
1
+ import { publicPath } from "../server/router.ts"
2
+ import type { Plugin } from "../plugins/types.ts"
3
+ import { SourceDiagnosticError } from "../source-diagnostic.ts"
4
+ import type { Repository } from "../repository.ts"
5
+ import type { Source } from "../source/index.ts"
6
+ import type { Actor, KnownActorIndex, Principal } from "./model.ts"
7
+ import { canonicalPath, canonicalSourceId } from "../canonical.ts"
8
+ import { isActorSource } from "./actors.ts"
9
+
10
+ export type SourceCapability = "read" | "write" | "execute"
11
+
12
+ export type SharedSourceGrant = Readonly<{
13
+ actor: string
14
+ can: readonly SourceCapability[]
15
+ }>
16
+
17
+ type CapabilityMask = number
18
+
19
+ type SharedSourceMember = Readonly<{
20
+ actor: string
21
+ capabilities: CapabilityMask
22
+ }>
23
+
24
+ type SharedSourceAccess = Readonly<{
25
+ type: "shared"
26
+ owner: string
27
+ members: readonly SharedSourceMember[]
28
+ capabilitiesByActor: ReadonlyMap<string, CapabilityMask>
29
+ }>
30
+
31
+ export type SourceAccessBasis =
32
+ | Readonly<{
33
+ type: "exact-grant"
34
+ sourceId: string
35
+ actorId: string
36
+ owner: boolean
37
+ }>
38
+ | Readonly<{
39
+ type: "home-owner"
40
+ sourceId: string
41
+ actorId: string
42
+ home: string
43
+ }>
44
+ | Readonly<{
45
+ type: "staff-review"
46
+ sourceId: string
47
+ actorId: string
48
+ }>
49
+ | Readonly<{ type: "public-source"; sourceId: string }>
50
+ | Readonly<{
51
+ type: "denied"
52
+ sourceId: string
53
+ reason:
54
+ | "actor-source"
55
+ | "authentication-required"
56
+ | "invalid-access"
57
+ | "not-granted"
58
+ | "private-home"
59
+ | "stale-actor"
60
+ | "unknown-source"
61
+ }>
62
+
63
+ export type SourceAccessInspection = Readonly<{
64
+ visibility: ReadAccess
65
+ capabilities: readonly SourceCapability[]
66
+ basis: SourceAccessBasis
67
+ }>
68
+
69
+ export type SourcePermissionRecord = SourceAccessInspection &
70
+ Readonly<{
71
+ sourceId: string
72
+ actorId: string | null
73
+ }>
74
+
75
+ type ResolvedSourceAccess = Readonly<{
76
+ visibility: ReadAccess
77
+ capabilities: CapabilityMask
78
+ basis?: SourceAccessBasis
79
+ }>
80
+
81
+ export type ProposedSourceChange = Readonly<
82
+ | { type: "create"; sourceId: string; after: Source }
83
+ | {
84
+ type: "update"
85
+ operation: "content" | "grants" | "attachments"
86
+ sourceId: string
87
+ before: Source
88
+ after: Source
89
+ }
90
+ | { type: "delete"; sourceId: string; before: Source }
91
+ >
92
+
93
+ export type AccessDecision =
94
+ Readonly<{ type: "allowed" }> | Readonly<{ type: "denied"; code: string }>
95
+
96
+ export type ReadAccess = "public" | "private" | "denied"
97
+
98
+ export type SourceAccessPolicy = Readonly<{
99
+ readAccess(principal: Principal, target: string | Source): ReadAccess
100
+ inspect(principal: Principal, target: string | Source): SourceAccessInspection
101
+ hasCapability(
102
+ principal: Principal,
103
+ target: string | Source,
104
+ capability: SourceCapability,
105
+ ): boolean
106
+ authorizeChange(actor: Actor, change: ProposedSourceChange): AccessDecision
107
+ canEmbed(container: string, dependency: string): boolean
108
+ diagnostics?(): readonly Readonly<{ sourceId: string; message: string }>[]
109
+ }>
110
+
111
+ export class SourceAccessError extends SourceDiagnosticError {
112
+ readonly sourceId: string
113
+ readonly detail: string
114
+
115
+ constructor(source: Source, message: string) {
116
+ super({
117
+ source: source.path,
118
+ plugin: "source-access",
119
+ message: `Invalid shared access on ${source.id}: ${message}`,
120
+ })
121
+ this.name = "SourceAccessError"
122
+ this.sourceId = source.id
123
+ this.detail = message
124
+ }
125
+ }
126
+
127
+ export class AccessDeniedError extends Error {
128
+ readonly code: string
129
+
130
+ constructor(code = "access-denied") {
131
+ super("Source access denied.")
132
+ this.name = "AccessDeniedError"
133
+ this.code = code
134
+ }
135
+ }
136
+
137
+ function record(value: unknown): Record<string, unknown> | undefined {
138
+ return value != null && typeof value === "object" && !Array.isArray(value)
139
+ ? (value as Record<string, unknown>)
140
+ : undefined
141
+ }
142
+
143
+ const capabilityBits = {
144
+ read: 1,
145
+ write: 2,
146
+ execute: 4,
147
+ } as const satisfies Record<SourceCapability, number>
148
+
149
+ const READ = capabilityBits.read
150
+ const WRITE = capabilityBits.write
151
+ const EXECUTE = capabilityBits.execute
152
+ const ALL_CAPABILITIES = READ | WRITE | EXECUTE
153
+ const capabilities = Object.freeze([
154
+ "read",
155
+ "write",
156
+ "execute",
157
+ ] as const satisfies readonly SourceCapability[])
158
+
159
+ function capabilitiesFor(mask: CapabilityMask) {
160
+ return capabilities.filter((capability) => mask & capabilityBits[capability])
161
+ }
162
+
163
+ function retainBasis(basis: SourceAccessBasis) {
164
+ return basis
165
+ }
166
+
167
+ function deniedAccess(
168
+ sourceId: string,
169
+ reason: Extract<SourceAccessBasis, { type: "denied" }>["reason"],
170
+ explain?: typeof retainBasis,
171
+ ): ResolvedSourceAccess {
172
+ return {
173
+ visibility: "denied",
174
+ capabilities: 0,
175
+ basis: explain?.({ type: "denied", sourceId, reason }),
176
+ }
177
+ }
178
+
179
+ function isSourceCapability(value: unknown): value is SourceCapability {
180
+ return typeof value === "string" && Object.hasOwn(capabilityBits, value)
181
+ }
182
+
183
+ function capabilityMask(value: unknown, source: Source, actor: string) {
184
+ if (!Array.isArray(value) || !value.length) {
185
+ throw new SourceAccessError(
186
+ source,
187
+ `member ${actor} must declare a non-empty can list`,
188
+ )
189
+ }
190
+ let mask = 0
191
+ for (const capability of value) {
192
+ if (!isSourceCapability(capability)) {
193
+ throw new SourceAccessError(
194
+ source,
195
+ `member ${actor} has an invalid capability: ${String(capability)}`,
196
+ )
197
+ }
198
+ const bit = capabilityBits[capability]
199
+ if (mask & bit) {
200
+ throw new SourceAccessError(
201
+ source,
202
+ `member ${actor} capabilities must be unique`,
203
+ )
204
+ }
205
+ mask |= bit
206
+ }
207
+ if (!(mask & READ)) {
208
+ throw new SourceAccessError(source, `member ${actor} must be readable`)
209
+ }
210
+ return mask
211
+ }
212
+
213
+ function sharedMember(source: Source, value: unknown): SharedSourceMember {
214
+ const member = record(value)
215
+ if (!member) {
216
+ throw new SourceAccessError(
217
+ source,
218
+ `members must contain actor capability grants`,
219
+ )
220
+ }
221
+ const actor = typeof member.actor === "string" ? member.actor.trim() : ""
222
+ if (!actor) {
223
+ throw new SourceAccessError(source, `member actor is required`)
224
+ }
225
+ try {
226
+ canonicalSourceId(actor)
227
+ } catch {
228
+ throw new SourceAccessError(
229
+ source,
230
+ `member actor must be a canonical Source id`,
231
+ )
232
+ }
233
+ return Object.freeze({
234
+ actor,
235
+ capabilities: capabilityMask(member.can, source, actor),
236
+ })
237
+ }
238
+
239
+ function memberCapabilities(access: SharedSourceAccess, actorId: string) {
240
+ return access.capabilitiesByActor.get(actorId) ?? 0
241
+ }
242
+
243
+ function sharedAccess(source: Source): SharedSourceAccess | undefined {
244
+ if (source.data.access == null) return
245
+ const access = record(source.data.access)
246
+ if (!access || access.type !== "shared") {
247
+ throw new SourceAccessError(source, `access.type must be shared`)
248
+ }
249
+ const owner = typeof access.owner === "string" ? access.owner.trim() : ""
250
+ if (!owner) throw new SourceAccessError(source, `owner is required`)
251
+ if (!Array.isArray(access.members) || !access.members.length) {
252
+ throw new SourceAccessError(source, `members must be a non-empty list`)
253
+ }
254
+ const members = access.members.map((member) => sharedMember(source, member))
255
+ const actors = members.map((member) => member.actor)
256
+ try {
257
+ canonicalSourceId(owner)
258
+ } catch {
259
+ throw new SourceAccessError(source, `owner must be a canonical Source id`)
260
+ }
261
+ if (new Set(actors).size !== actors.length) {
262
+ throw new SourceAccessError(source, `members must be unique`)
263
+ }
264
+ if (!actors.includes(owner)) {
265
+ throw new SourceAccessError(source, `owner must also be a member`)
266
+ }
267
+ const ownerCapabilities =
268
+ members.find((member) => member.actor === owner)?.capabilities ?? 0
269
+ if (!(ownerCapabilities & WRITE)) {
270
+ throw new SourceAccessError(source, `owner ${owner} must be writable`)
271
+ }
272
+ return Object.freeze({
273
+ type: "shared",
274
+ owner,
275
+ members: Object.freeze(members),
276
+ capabilitiesByActor: new Map(
277
+ members.map((member) => [member.actor, member.capabilities]),
278
+ ),
279
+ })
280
+ }
281
+
282
+ function attachmentPaths(source: Source) {
283
+ const authored = source.data.attachments
284
+ if (authored == null) return []
285
+ if (!Array.isArray(authored)) {
286
+ throw new SourceAccessError(source, `attachments must be a list`)
287
+ }
288
+ const attachments = authored.map((item) =>
289
+ typeof item === "string" ? item.trim() : "",
290
+ )
291
+ if (attachments.some((attachment) => !attachment)) {
292
+ throw new SourceAccessError(source, `attachments must contain paths`)
293
+ }
294
+ try {
295
+ for (const attachment of attachments) {
296
+ canonicalPath(attachment)
297
+ if (attachment.endsWith(".md")) throw new Error()
298
+ }
299
+ } catch {
300
+ throw new SourceAccessError(
301
+ source,
302
+ `attachments must be canonical non-Source paths`,
303
+ )
304
+ }
305
+ if (new Set(attachments).size !== attachments.length) {
306
+ throw new SourceAccessError(source, `attachments must be unique`)
307
+ }
308
+ return attachments
309
+ }
310
+
311
+ function within(path: string, root: string) {
312
+ return path === root || path.startsWith(`${root}/`)
313
+ }
314
+
315
+ /** Orphaned Homes stay private after their Actor Source is removed. */
316
+ function reservedHomePath(path: string) {
317
+ const segments = path.split("/").filter(Boolean)
318
+ return (
319
+ segments.length >= 3 &&
320
+ (segments[0] === "users" || segments[0] === "agents")
321
+ )
322
+ }
323
+
324
+ function sameAccess(before: Source, after: Source) {
325
+ return (
326
+ JSON.stringify(before.data.access) === JSON.stringify(after.data.access)
327
+ )
328
+ }
329
+
330
+ function sameAttachments(before: Source, after: Source) {
331
+ return (
332
+ JSON.stringify(before.data.attachments) ===
333
+ JSON.stringify(after.data.attachments)
334
+ )
335
+ }
336
+
337
+ function sameExceptAttachments(before: Source, after: Source) {
338
+ const { attachments: _before, ...beforeData } = before.data
339
+ const { attachments: _after, ...afterData } = after.data
340
+ return (
341
+ before.id === after.id &&
342
+ before.body === after.body &&
343
+ JSON.stringify(beforeData) === JSON.stringify(afterData)
344
+ )
345
+ }
346
+
347
+ const allowed: AccessDecision = Object.freeze({ type: "allowed" })
348
+
349
+ function denied(code: string): AccessDecision {
350
+ return { type: "denied", code }
351
+ }
352
+
353
+ export class SourceAccessIndex implements SourceAccessPolicy {
354
+ private sharedById = new Map<string, SharedSourceAccess>()
355
+ private sourcePaths = new Map<string, string>()
356
+ private actorSources = new Map<string, Set<string>>()
357
+ private invalidIds = new Set<string>()
358
+ private invalidMessages = new Map<string, string>()
359
+ private attachments = new Map<string, string>()
360
+ private sourceAttachments = new Map<string, readonly string[]>()
361
+
362
+ constructor(
363
+ private repo: Repository,
364
+ private actors: KnownActorIndex,
365
+ ) {}
366
+
367
+ index(source: Source) {
368
+ try {
369
+ const bound = attachmentPaths(source)
370
+ for (const attachment of bound) {
371
+ const existing = this.attachments.get(attachment)
372
+ if (existing && existing !== source.id) {
373
+ throw new SourceAccessError(
374
+ source,
375
+ `attachment already belongs to ${existing}: ${attachment}`,
376
+ )
377
+ }
378
+ }
379
+ for (const attachment of bound)
380
+ this.attachments.set(attachment, source.id)
381
+ if (bound.length) this.sourceAttachments.set(source.id, bound)
382
+ if (bound.length || source.data.access != null) {
383
+ this.sourcePaths.set(source.path, source.id)
384
+ }
385
+ if (source.data.access == null) return
386
+ const access = sharedAccess(source)
387
+ if (!access) return
388
+ this.sharedById.set(source.id, access)
389
+ this.sourcePaths.set(source.path, source.id)
390
+ this.invalidIds.delete(source.id)
391
+ this.invalidMessages.delete(source.id)
392
+ for (const { actor: actorId } of access.members) {
393
+ const sources = this.actorSources.get(actorId) ?? new Set<string>()
394
+ sources.add(source.id)
395
+ this.actorSources.set(actorId, sources)
396
+ }
397
+ } catch (error) {
398
+ this.invalidIds.add(source.id)
399
+ this.invalidMessages.set(
400
+ source.id,
401
+ error instanceof SourceAccessError
402
+ ? error.detail
403
+ : error instanceof Error
404
+ ? error.message
405
+ : String(error),
406
+ )
407
+ this.sourcePaths.set(source.path, source.id)
408
+ throw error
409
+ }
410
+ }
411
+
412
+ unindex(sourcePath: string) {
413
+ const sourceId = this.sourcePaths.get(sourcePath)
414
+ if (!sourceId) return
415
+ const access = this.sharedById.get(sourceId)
416
+ if (access) {
417
+ for (const { actor: actorId } of access.members) {
418
+ const sources = this.actorSources.get(actorId)
419
+ sources?.delete(sourceId)
420
+ if (!sources?.size) this.actorSources.delete(actorId)
421
+ }
422
+ }
423
+ for (const attachment of this.sourceAttachments.get(sourceId) ?? []) {
424
+ this.attachments.delete(attachment)
425
+ }
426
+ this.sourceAttachments.delete(sourceId)
427
+ this.sharedById.delete(sourceId)
428
+ this.sourcePaths.delete(sourcePath)
429
+ this.invalidIds.delete(sourceId)
430
+ this.invalidMessages.delete(sourceId)
431
+ }
432
+
433
+ validate() {
434
+ const diagnostic = this.diagnostics()[0]
435
+ if (diagnostic) {
436
+ const source = this.repo.get(diagnostic.sourceId)
437
+ if (source) throw new SourceAccessError(source, diagnostic.message)
438
+ }
439
+ }
440
+
441
+ diagnostics() {
442
+ const diagnostics = [...this.invalidMessages].map(
443
+ ([sourceId, message]) => ({ sourceId, message }),
444
+ )
445
+ for (const [sourceId, access] of this.sharedById) {
446
+ if (!this.actors.findActorById(access.owner)) {
447
+ diagnostics.push({
448
+ sourceId,
449
+ message: `owner does not resolve: ${access.owner}`,
450
+ })
451
+ continue
452
+ }
453
+ for (const member of access.members) {
454
+ if (!this.actors.findActorById(member.actor)) {
455
+ diagnostics.push({
456
+ sourceId,
457
+ message: `member does not resolve: ${member.actor}`,
458
+ })
459
+ }
460
+ }
461
+ }
462
+ return diagnostics.sort(
463
+ (a, b) =>
464
+ a.sourceId.localeCompare(b.sourceId) ||
465
+ a.message.localeCompare(b.message),
466
+ )
467
+ }
468
+
469
+ sharedSourcesFor(actorId: string) {
470
+ return [...(this.actorSources.get(actorId) ?? [])]
471
+ }
472
+
473
+ sourceForId(sourceId: string) {
474
+ return this.repo.get(sourceId)
475
+ }
476
+
477
+ private resolvedAccess(
478
+ principal: Principal,
479
+ target: string | Source,
480
+ inspect = false,
481
+ ): ResolvedSourceAccess {
482
+ // Optional calls below avoid allocating provenance objects on HTTP/Action checks.
483
+ const explain = inspect ? retainBasis : undefined
484
+ const source = typeof target === "string" ? this.sourceFor(target) : target
485
+ let sourceId: string
486
+ try {
487
+ sourceId =
488
+ source?.id ??
489
+ canonicalPath(typeof target === "string" ? target : target.id)
490
+ } catch {
491
+ return deniedAccess(
492
+ String(typeof target === "string" ? target : target.id),
493
+ "unknown-source",
494
+ explain,
495
+ )
496
+ }
497
+ if (!source) {
498
+ const ownerId = this.attachments.get(sourceId)
499
+ const owner = ownerId ? this.repo.get(ownerId) : undefined
500
+ if (owner) return this.resolvedAccess(principal, owner, inspect)
501
+ }
502
+ if (this.invalidIds.has(sourceId)) {
503
+ return deniedAccess(sourceId, "invalid-access", explain)
504
+ }
505
+ if (source && isActorSource(source)) {
506
+ return deniedAccess(sourceId, "actor-source", explain)
507
+ }
508
+
509
+ const actor =
510
+ principal.type === "actor" &&
511
+ this.actors.findActorById(principal.actor.id) === principal.actor
512
+ ? principal.actor
513
+ : undefined
514
+
515
+ const shared = this.sharedById.get(sourceId)
516
+ if (shared) {
517
+ if (!this.actors.findActorById(shared.owner)) {
518
+ return deniedAccess(sourceId, "invalid-access", explain)
519
+ }
520
+ if (!actor) {
521
+ return deniedAccess(
522
+ sourceId,
523
+ principal.type === "actor"
524
+ ? "stale-actor"
525
+ : "authentication-required",
526
+ explain,
527
+ )
528
+ }
529
+ const capabilities = memberCapabilities(shared, actor.id)
530
+ if (capabilities) {
531
+ return {
532
+ visibility: "private",
533
+ capabilities,
534
+ basis: explain?.({
535
+ type: "exact-grant",
536
+ sourceId,
537
+ actorId: actor.id,
538
+ owner: shared.owner === actor.id,
539
+ }),
540
+ }
541
+ }
542
+ if (actor.type === "user" && actor.role === "staff") {
543
+ return {
544
+ visibility: "private",
545
+ capabilities: READ,
546
+ basis: explain?.({
547
+ type: "staff-review",
548
+ sourceId,
549
+ actorId: actor.id,
550
+ }),
551
+ }
552
+ }
553
+ return deniedAccess(sourceId, "not-granted", explain)
554
+ }
555
+
556
+ const path = source?.id ?? sourceId
557
+ const homeOwner = this.homeOwner(path)
558
+ if (homeOwner) {
559
+ if (!actor) {
560
+ return deniedAccess(
561
+ sourceId,
562
+ principal.type === "actor"
563
+ ? "stale-actor"
564
+ : "authentication-required",
565
+ explain,
566
+ )
567
+ }
568
+ if (actor.id === homeOwner.id) {
569
+ return {
570
+ visibility: "private",
571
+ capabilities: ALL_CAPABILITIES,
572
+ basis: explain?.({
573
+ type: "home-owner",
574
+ sourceId,
575
+ actorId: actor.id,
576
+ home: homeOwner.home,
577
+ }),
578
+ }
579
+ }
580
+ if (actor.type === "user" && actor.role === "staff") {
581
+ return {
582
+ visibility: "private",
583
+ capabilities: READ,
584
+ basis: explain?.({
585
+ type: "staff-review",
586
+ sourceId,
587
+ actorId: actor.id,
588
+ }),
589
+ }
590
+ }
591
+ return deniedAccess(sourceId, "private-home", explain)
592
+ }
593
+ if (reservedHomePath(path)) {
594
+ return deniedAccess(sourceId, "private-home", explain)
595
+ }
596
+ return {
597
+ visibility: "public",
598
+ capabilities: actor ? READ | EXECUTE : READ,
599
+ basis: explain?.({ type: "public-source", sourceId }),
600
+ }
601
+ }
602
+
603
+ readAccess(principal: Principal, target: string | Source): ReadAccess {
604
+ const access = this.resolvedAccess(principal, target)
605
+ if (!(access.capabilities & READ)) return "denied"
606
+ return access.visibility
607
+ }
608
+
609
+ inspect(
610
+ principal: Principal,
611
+ target: string | Source,
612
+ ): SourceAccessInspection {
613
+ const access = this.resolvedAccess(principal, target, true)
614
+ if (!access.basis) throw new Error("Permission inspection basis missing")
615
+ return {
616
+ visibility: access.visibility,
617
+ capabilities: capabilitiesFor(access.capabilities),
618
+ basis: access.basis,
619
+ }
620
+ }
621
+
622
+ hasCapability(
623
+ principal: Principal,
624
+ target: string | Source,
625
+ capability: SourceCapability,
626
+ ) {
627
+ const access = this.resolvedAccess(principal, target)
628
+ return Boolean(access.capabilities & capabilityBits[capability])
629
+ }
630
+
631
+ authorizeChange(actor: Actor, change: ProposedSourceChange): AccessDecision {
632
+ if (this.actors.findActorById(actor.id) !== actor) {
633
+ return denied("actor-not-current")
634
+ }
635
+ if (change.type === "create")
636
+ return this.authorizeCreate(actor, change.after)
637
+
638
+ const beforeAccess = this.accessFromSource(change.before)
639
+ if (change.type === "delete") {
640
+ if (beforeAccess) {
641
+ return beforeAccess.owner === actor.id
642
+ ? allowed
643
+ : denied("shared-delete-owner-only")
644
+ }
645
+ return this.ownsHome(actor, change.before.id)
646
+ ? allowed
647
+ : denied("home-owner-only")
648
+ }
649
+
650
+ if (change.operation === "grants") {
651
+ if (!sameAttachments(change.before, change.after)) {
652
+ return denied("attachment-change-requires-attachment-operation")
653
+ }
654
+ const afterAccess = this.validShared(change.after)
655
+ const owner = beforeAccess?.owner ?? actor.id
656
+ if (owner !== actor.id || afterAccess.owner !== owner) {
657
+ return denied("shared-grants-owner-only")
658
+ }
659
+ return this.membersResolve(afterAccess)
660
+ ? allowed
661
+ : denied("shared-member-not-found")
662
+ }
663
+
664
+ if (change.operation === "attachments") {
665
+ if (
666
+ !sameAccess(change.before, change.after) ||
667
+ !sameExceptAttachments(change.before, change.after)
668
+ ) {
669
+ return denied("attachment-operation-may-only-change-attachments")
670
+ }
671
+ attachmentPaths(change.after)
672
+ if (!this.attachmentsAvailable(change.after)) {
673
+ return denied("attachment-already-bound")
674
+ }
675
+ if (beforeAccess) {
676
+ return beforeAccess.owner === actor.id
677
+ ? allowed
678
+ : denied("shared-attachment-owner-only")
679
+ }
680
+ return this.ownsHome(actor, change.before.id)
681
+ ? allowed
682
+ : denied("home-owner-only")
683
+ }
684
+
685
+ if (!sameAccess(change.before, change.after)) {
686
+ return denied("access-change-requires-grants-operation")
687
+ }
688
+ if (!sameAttachments(change.before, change.after)) {
689
+ return denied("attachment-change-requires-attachment-operation")
690
+ }
691
+ return this.hasCapability({ type: "actor", actor }, change.before, "write")
692
+ ? allowed
693
+ : denied("source-write-required")
694
+ }
695
+
696
+ canEmbed(container: string, dependency: string) {
697
+ const containerSource = this.sourceFor(container)
698
+ const dependencySource = this.sourceFor(dependency)
699
+ if (!containerSource || !dependencySource) return false
700
+ if (isActorSource(dependencySource)) {
701
+ return false
702
+ }
703
+ const containerAccess = this.sharedById.get(containerSource.id)
704
+ const dependencyAccess = this.sharedById.get(dependencySource.id)
705
+ if (containerAccess) {
706
+ if (!dependencyAccess) return !this.homeProtected(dependencySource.id)
707
+ return containerAccess.members.every((member) =>
708
+ Boolean(memberCapabilities(dependencyAccess, member.actor) & READ),
709
+ )
710
+ }
711
+ const containerOwner = this.homeOwner(containerSource.id)
712
+ if (containerOwner) {
713
+ if (!this.homeProtected(dependencySource.id)) return !dependencyAccess
714
+ return within(dependencySource.id, containerOwner.home)
715
+ }
716
+ if (reservedHomePath(containerSource.id)) return false
717
+ return !dependencyAccess && !this.homeProtected(dependencySource.id)
718
+ }
719
+
720
+ private authorizeCreate(actor: Actor, source: Source): AccessDecision {
721
+ attachmentPaths(source)
722
+ if (!this.attachmentsAvailable(source)) {
723
+ return denied("attachment-already-bound")
724
+ }
725
+ const access = this.accessFromSource(source)
726
+ if (access) {
727
+ if (
728
+ access.owner !== actor.id ||
729
+ !(memberCapabilities(access, actor.id) & WRITE)
730
+ ) {
731
+ return denied("shared-create-owner-required")
732
+ }
733
+ return this.membersResolve(access)
734
+ ? allowed
735
+ : denied("shared-member-not-found")
736
+ }
737
+ return this.ownsHome(actor, source.id) ? allowed : denied("home-owner-only")
738
+ }
739
+
740
+ private membersResolve(access: SharedSourceAccess) {
741
+ return access.members.every((member) =>
742
+ this.actors.findActorById(member.actor),
743
+ )
744
+ }
745
+
746
+ private attachmentsAvailable(source: Source) {
747
+ return attachmentPaths(source).every((attachment) => {
748
+ const owner = this.attachments.get(attachment)
749
+ return owner == null || owner === source.id
750
+ })
751
+ }
752
+
753
+ private validShared(source: Source) {
754
+ const access = sharedAccess(source)
755
+ if (!access)
756
+ throw new SourceAccessError(source, `shared access is required`)
757
+ return access
758
+ }
759
+
760
+ private accessFromSource(source: Source) {
761
+ return source.data.access == null ? undefined : this.validShared(source)
762
+ }
763
+
764
+ private sourceFor(target: string) {
765
+ return this.repo.get(target) ?? this.repo.atPath(target)
766
+ }
767
+
768
+ private homeOwner(path: string) {
769
+ return this.actors.findHomeOwner(path)
770
+ }
771
+
772
+ private homeProtected(path: string) {
773
+ return this.homeOwner(path) != null || reservedHomePath(path)
774
+ }
775
+
776
+ private ownsHome(actor: Actor, path: string) {
777
+ return within(path, actor.home)
778
+ }
779
+ }
780
+
781
+ export function SourceAccessPlugin(index: SourceAccessIndex): Plugin {
782
+ return {
783
+ id: "source-access",
784
+ name: "SourceAccessPlugin",
785
+ index(source) {
786
+ index.index(source)
787
+ },
788
+ unindex(sourcePath) {
789
+ index.unindex(sourcePath)
790
+ },
791
+ lint(report) {
792
+ for (const diagnostic of index.diagnostics()) {
793
+ const source = index.sourceForId(diagnostic.sourceId)
794
+ if (!source) continue
795
+ report(
796
+ source,
797
+ `Invalid shared access on ${source.id}: ${diagnostic.message}`,
798
+ )
799
+ }
800
+ },
801
+ invalidate(transition) {
802
+ const targets: { type: "route"; route: string }[] = []
803
+ const routes = new Set<string>()
804
+ for (const source of [transition.before, transition.after]) {
805
+ if (!source) continue
806
+ if (source.data.access != null || source.data.attachments != null) {
807
+ routes.add(publicPath(source))
808
+ }
809
+ if (isActorSource(source)) {
810
+ for (const sourceId of index.sharedSourcesFor(source.id)) {
811
+ const shared = index.sourceForId(sourceId)
812
+ if (shared) routes.add(publicPath(shared))
813
+ }
814
+ }
815
+ }
816
+ for (const route of routes) targets.push({ type: "route", route })
817
+ return targets.length ? { targets } : undefined
818
+ },
819
+ }
820
+ }