@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,271 @@
1
+ import type { Plugin } from "../plugins/types.ts"
2
+ import { SourceDiagnosticError } from "../source-diagnostic.ts"
3
+ import type { Source } from "../source/index.ts"
4
+ import type {
5
+ Actor,
6
+ ActorProfile,
7
+ ActorProfileRef,
8
+ ActorProfiles,
9
+ AgentActor,
10
+ KnownActorIndex,
11
+ UserActor,
12
+ } from "./model.ts"
13
+
14
+ const USERNAME = /^[a-z0-9][a-z0-9._-]*$/
15
+
16
+ export function isActorSource(source: Source) {
17
+ return (
18
+ source.type === "user" ||
19
+ (source.type === "agent" && source.data.actor === true)
20
+ )
21
+ }
22
+
23
+ export class ActorSourceError extends SourceDiagnosticError {
24
+ readonly sourceId: string
25
+
26
+ constructor(source: Source, message: string) {
27
+ super({
28
+ source: source.path,
29
+ plugin: "actors",
30
+ message: `Invalid Actor Source ${source.id}: ${message}`,
31
+ })
32
+ this.name = "ActorSourceError"
33
+ this.sourceId = source.id
34
+ }
35
+ }
36
+
37
+ export function normalizeEmail(value: string) {
38
+ return value.trim().toLowerCase()
39
+ }
40
+
41
+ function requiredString(source: Source, field: string) {
42
+ const value = source.data[field]
43
+ if (typeof value !== "string" || !value.trim()) {
44
+ throw new ActorSourceError(source, `${field} is required`)
45
+ }
46
+ return value.trim()
47
+ }
48
+
49
+ function optionalString(source: Source, field: string) {
50
+ const value = source.data[field]
51
+ if (value == null || value === "") return
52
+ if (typeof value !== "string" || !value.trim()) {
53
+ throw new ActorSourceError(source, `${field} must be a non-empty string`)
54
+ }
55
+ return value.trim()
56
+ }
57
+
58
+ function stringList(source: Source, field: string) {
59
+ const value = source.data[field]
60
+ if (!Array.isArray(value) || !value.length) {
61
+ throw new ActorSourceError(source, `${field} must be a non-empty list`)
62
+ }
63
+ const strings = value.map((item) =>
64
+ typeof item === "string" ? item.trim() : "",
65
+ )
66
+ if (strings.some((item) => !item)) {
67
+ throw new ActorSourceError(source, `${field} must contain only strings`)
68
+ }
69
+ return strings
70
+ }
71
+
72
+ function profileFor(source: Source): ActorProfile {
73
+ const username = optionalString(source, "username") ?? source.name
74
+ if (!USERNAME.test(username)) {
75
+ throw new ActorSourceError(source, `invalid username: ${username}`)
76
+ }
77
+ const avatar = optionalString(source, "avatar")
78
+ return Object.freeze({
79
+ name: requiredString(source, "name"),
80
+ username,
81
+ ...(avatar ? { avatar } : {}),
82
+ })
83
+ }
84
+
85
+ function homeFor(source: Source) {
86
+ const suffix = `.${source.type}`
87
+ if (!source.id.endsWith(suffix)) {
88
+ throw new ActorSourceError(
89
+ source,
90
+ `id must end in ${suffix} so its Home can be derived`,
91
+ )
92
+ }
93
+ return source.id.slice(0, -suffix.length)
94
+ }
95
+
96
+ function actorFor(source: Source): {
97
+ actor: Actor
98
+ emails: readonly string[]
99
+ } {
100
+ const profile = profileFor(source)
101
+ const base = {
102
+ id: source.id,
103
+ profile,
104
+ home: homeFor(source),
105
+ }
106
+ if (source.type === "agent") {
107
+ const actor: AgentActor = Object.freeze({ ...base, type: "agent" })
108
+ return { actor, emails: [] }
109
+ }
110
+ if (source.type !== "user") {
111
+ throw new ActorSourceError(source, `unsupported Actor type: ${source.type}`)
112
+ }
113
+ const authoredRole = source.data.role
114
+ const role =
115
+ authoredRole == null || authoredRole === "" ? "member" : authoredRole
116
+ if (role !== "member" && role !== "staff") {
117
+ throw new ActorSourceError(source, `role must be member or staff`)
118
+ }
119
+ const emails = stringList(source, "emails").map(normalizeEmail)
120
+ if (new Set(emails).size !== emails.length) {
121
+ throw new ActorSourceError(source, `emails must be unique`)
122
+ }
123
+ const actor: UserActor = Object.freeze({ ...base, type: "user", role })
124
+ return { actor, emails }
125
+ }
126
+
127
+ export class ActorDirectory implements KnownActorIndex {
128
+ private actors = new Map<string, Actor>()
129
+ private usernames = new Map<string, Actor>()
130
+ private emails = new Map<string, UserActor>()
131
+ private homes = new Map<string, Actor>()
132
+ private sourceActors = new Map<string, string>()
133
+ private actorEmails = new Map<string, readonly string[]>()
134
+
135
+ readonly profiles: ActorProfiles = Object.freeze({
136
+ findById: (id: string) => this.profileRef(this.actors.get(id)),
137
+ findByUsername: (username: string) =>
138
+ this.profileRef(this.usernames.get(username)),
139
+ })
140
+
141
+ findActorById(id: string) {
142
+ return this.actors.get(id)
143
+ }
144
+
145
+ findHomeOwner(path: string) {
146
+ let candidate = path.replace(/\/+$/, "") || "/"
147
+ while (candidate) {
148
+ const actor = this.homes.get(candidate)
149
+ if (actor) return actor
150
+ const slash = candidate.lastIndexOf("/")
151
+ if (slash <= 0) return this.homes.get("/")
152
+ candidate = candidate.slice(0, slash)
153
+ }
154
+ }
155
+
156
+ findById(id: string) {
157
+ return this.profileRef(this.actors.get(id))
158
+ }
159
+
160
+ findByUsername(username: string) {
161
+ return this.profileRef(this.usernames.get(username))
162
+ }
163
+
164
+ findUserByEmail(email: string) {
165
+ return this.emails.get(normalizeEmail(email))
166
+ }
167
+
168
+ credentialUsers() {
169
+ return this.all()
170
+ .filter((actor): actor is UserActor => actor.type === "user")
171
+ .map((actor) => ({
172
+ actor,
173
+ emails: this.actorEmails.get(actor.id) ?? [],
174
+ }))
175
+ }
176
+
177
+ all() {
178
+ return [...this.actors.values()].sort((a, b) => a.id.localeCompare(b.id))
179
+ }
180
+
181
+ index(source: Source) {
182
+ if (!isActorSource(source)) return
183
+ const { actor, emails } = actorFor(source)
184
+ const idOwner = this.actors.get(actor.id)
185
+ if (idOwner) {
186
+ throw new ActorSourceError(source, `duplicate Actor id: ${actor.id}`)
187
+ }
188
+ const usernameOwner = this.usernames.get(actor.profile.username)
189
+ if (usernameOwner) {
190
+ throw new ActorSourceError(
191
+ source,
192
+ `duplicate username: ${actor.profile.username}`,
193
+ )
194
+ }
195
+ if (this.homes.has(actor.home)) {
196
+ throw new ActorSourceError(source, `duplicate Home: ${actor.home}`)
197
+ }
198
+ for (const email of emails) {
199
+ if (this.emails.has(email)) {
200
+ throw new ActorSourceError(
201
+ source,
202
+ `duplicate normalized email: ${email}`,
203
+ )
204
+ }
205
+ }
206
+
207
+ this.actors.set(actor.id, actor)
208
+ this.usernames.set(actor.profile.username, actor)
209
+ this.homes.set(actor.home, actor)
210
+ this.sourceActors.set(source.path, actor.id)
211
+ this.actorEmails.set(actor.id, emails)
212
+ if (actor.type === "user") {
213
+ for (const email of emails) this.emails.set(email, actor)
214
+ }
215
+ }
216
+
217
+ unindex(sourcePath: string) {
218
+ const actorId = this.sourceActors.get(sourcePath)
219
+ if (!actorId) return
220
+ const actor = this.actors.get(actorId)
221
+ this.sourceActors.delete(sourcePath)
222
+ this.actors.delete(actorId)
223
+ if (actor) this.usernames.delete(actor.profile.username)
224
+ if (actor) this.homes.delete(actor.home)
225
+ for (const email of this.actorEmails.get(actorId) ?? []) {
226
+ this.emails.delete(email)
227
+ }
228
+ this.actorEmails.delete(actorId)
229
+ }
230
+
231
+ private profileRef(actor?: Actor): ActorProfileRef | undefined {
232
+ return actor ? { id: actor.id, profile: actor.profile } : undefined
233
+ }
234
+ }
235
+
236
+ export function ActorDirectoryPlugin(directory: ActorDirectory): Plugin {
237
+ return {
238
+ id: "actors",
239
+ name: "ActorDirectoryPlugin",
240
+ schema: {
241
+ sources: {
242
+ user: {
243
+ label: "User",
244
+ icon: "circle-user",
245
+ color: "blue",
246
+ },
247
+ agent: {
248
+ label: "Agent",
249
+ icon: "bot",
250
+ color: "purple",
251
+ },
252
+ },
253
+ },
254
+ index(source) {
255
+ directory.index(source)
256
+ },
257
+ unindex(sourcePath) {
258
+ directory.unindex(sourcePath)
259
+ },
260
+ invalidate(transition) {
261
+ const actors = new Set<string>()
262
+ if (transition.before && isActorSource(transition.before)) {
263
+ actors.add(transition.before.id)
264
+ }
265
+ if (transition.after && isActorSource(transition.after)) {
266
+ actors.add(transition.after.id)
267
+ }
268
+ return actors.size ? { targets: [{ type: "actor", actors }] } : undefined
269
+ },
270
+ }
271
+ }
@@ -0,0 +1,95 @@
1
+ import { AccessDeniedError } from "./access.ts"
2
+ import { createTransaction } from "../actions/transaction.ts"
3
+ import type { BlockDraft, BlockUpdate } from "../source/index.ts"
4
+ import type { Engine } from "../host/engine.ts"
5
+ import type { AgentActor, Principal } from "./model.ts"
6
+ import type { KnownActorIndex } from "./model.ts"
7
+
8
+ export class AgentAdmissionError extends Error {
9
+ constructor(message = "Agent job admission denied.") {
10
+ super(message)
11
+ this.name = "AgentAdmissionError"
12
+ }
13
+ }
14
+
15
+ export type AgentJobContext = Readonly<{
16
+ actor: AgentActor
17
+ principal: Principal
18
+ sourceId: string
19
+ appendBlock(block: BlockDraft): void
20
+ updateBlock(id: string, update: BlockUpdate): void
21
+ }>
22
+
23
+ export type AgentJob = (context: AgentJobContext) => void | Promise<void>
24
+
25
+ export type AgentAdmission = Readonly<{
26
+ agentId: string
27
+ targetId: string
28
+ operationId: string
29
+ }>
30
+
31
+ export async function runAgentJob(
32
+ engine: Engine,
33
+ admission: AgentAdmission,
34
+ job: AgentJob,
35
+ ) {
36
+ return engine.runAgentJob(admission, job)
37
+ }
38
+
39
+ export async function runAgentJobWithIndex(
40
+ engine: Engine,
41
+ actors: KnownActorIndex,
42
+ admission: AgentAdmission,
43
+ job: AgentJob,
44
+ ) {
45
+ const { agentId, targetId, operationId } = admission
46
+ if (!operationId.trim()) throw new AgentAdmissionError()
47
+ const agent = actors.findActorById(agentId)
48
+ if (!agent || agent.type !== "agent") throw new AgentAdmissionError()
49
+
50
+ return engine.commitPrepared(async () => {
51
+ const target = engine.repo.get(targetId)
52
+ if (!target) throw new AgentAdmissionError()
53
+ const principal: Principal = { type: "actor", actor: agent }
54
+ if (engine.readAccess(principal, target) === "denied") {
55
+ throw new AgentAdmissionError()
56
+ }
57
+ if (
58
+ target.blocks.some((block) => block.data.operation_id === operationId)
59
+ ) {
60
+ return { changes: [] }
61
+ }
62
+
63
+ const tx = createTransaction(engine, agent)
64
+ const source = tx.source(target.id)
65
+ await job({
66
+ actor: agent,
67
+ principal,
68
+ sourceId: target.id,
69
+ appendBlock(block) {
70
+ source.appendBlock({
71
+ ...block,
72
+ data: { ...block.data, operation_id: operationId },
73
+ })
74
+ },
75
+ updateBlock(id, update) {
76
+ source.updateBlock(id, (current) => {
77
+ const patch = typeof update === "function" ? update(current) : update
78
+ return {
79
+ ...patch,
80
+ data: { ...patch.data, operation_id: operationId },
81
+ }
82
+ })
83
+ },
84
+ })
85
+ const changes = await tx.changeSet()
86
+ if (
87
+ changes.changes.some(
88
+ (change) => engine.repo.pathToId(change.path) !== target.id,
89
+ )
90
+ ) {
91
+ throw new AccessDeniedError("agent-target-only")
92
+ }
93
+ return changes
94
+ })
95
+ }
@@ -0,0 +1,144 @@
1
+ import type { CredentialSession, KnownActorIndex, UserActor } from "./model.ts"
2
+
3
+ export type CredentialLink<Method extends "get" | "post" = "get" | "post"> =
4
+ Readonly<{
5
+ method: Method
6
+ href: string
7
+ }>
8
+
9
+ export type ExternalIdentity = Readonly<{
10
+ provider: string
11
+ subject: string
12
+ email?: string
13
+ claims?: Readonly<Record<string, unknown>>
14
+ }>
15
+
16
+ export type ExternalIdentityHint = Readonly<{
17
+ provider: string
18
+ email?: string
19
+ }>
20
+
21
+ export type CredentialCandidate = Readonly<{
22
+ identity: ExternalIdentityHint
23
+ profile: Readonly<{ name: string; avatar?: string }>
24
+ }>
25
+
26
+ export type CredentialDirectory = Readonly<{
27
+ candidates(): readonly CredentialCandidate[]
28
+ allows(identity: ExternalIdentityHint): Promise<boolean>
29
+ admit(identity: ExternalIdentity): Promise<UserActor | undefined>
30
+ }>
31
+
32
+ export type ActorAdmission = Readonly<{
33
+ bind(actors: KnownActorIndex): CredentialDirectory
34
+ }>
35
+
36
+ export type CredentialProvider = Readonly<{
37
+ signIn: CredentialLink<"get"> | null
38
+ ready(directory: CredentialDirectory): Promise<void>
39
+ resolve(request: Request): Promise<
40
+ | Readonly<{
41
+ identity: ExternalIdentity
42
+ signOut: CredentialLink<"post"> | null
43
+ }>
44
+ | undefined
45
+ >
46
+ fetch(request: Request, directory: CredentialDirectory): Promise<Response>
47
+ close(): Promise<void>
48
+ }>
49
+
50
+ export type CredentialHost = Readonly<{
51
+ ready(actors: KnownActorIndex): Promise<void>
52
+ resolve(request: Request, actors: KnownActorIndex): Promise<CredentialSession>
53
+ fetch(request: Request, actors: KnownActorIndex): Promise<Response>
54
+ close(): Promise<void>
55
+ }>
56
+
57
+ /** Map verified provider identities to pre-authored User Actors by email. */
58
+ export function rosteredActorAdmission(): ActorAdmission {
59
+ return {
60
+ bind(actors) {
61
+ return Object.freeze({
62
+ candidates: () =>
63
+ actors.credentialUsers().flatMap(({ actor, emails }) =>
64
+ emails.map((email) => ({
65
+ identity: { provider: "email", email },
66
+ profile: {
67
+ name: actor.profile.name,
68
+ ...(actor.profile.avatar
69
+ ? { avatar: actor.profile.avatar }
70
+ : {}),
71
+ },
72
+ })),
73
+ ),
74
+ async allows(identity) {
75
+ return Boolean(
76
+ identity.email && actors.findUserByEmail(identity.email),
77
+ )
78
+ },
79
+ async admit(identity) {
80
+ return identity.email
81
+ ? actors.findUserByEmail(identity.email)
82
+ : undefined
83
+ },
84
+ })
85
+ },
86
+ }
87
+ }
88
+
89
+ /** Compose authentication protocol mechanics with application Actor policy. */
90
+ export function createCredentialHost(
91
+ provider: CredentialProvider,
92
+ admission: ActorAdmission = rosteredActorAdmission(),
93
+ ): CredentialHost {
94
+ let directory: CredentialDirectory | undefined
95
+ const bound = () => {
96
+ if (!directory) throw new Error("CredentialHost is not ready.")
97
+ return directory
98
+ }
99
+ return {
100
+ async ready(actors) {
101
+ directory = admission.bind(actors)
102
+ await provider.ready(directory)
103
+ },
104
+ async resolve(request) {
105
+ const authenticated = await provider.resolve(request)
106
+ if (!authenticated) {
107
+ return { type: "anonymous", signIn: provider.signIn }
108
+ }
109
+ const actor = await bound().admit(authenticated.identity)
110
+ return actor
111
+ ? {
112
+ type: "authenticated",
113
+ actor,
114
+ signOut: authenticated.signOut,
115
+ }
116
+ : { type: "anonymous", signIn: provider.signIn }
117
+ },
118
+ fetch(request) {
119
+ return provider.fetch(request, bound())
120
+ },
121
+ async close() {
122
+ await provider.close()
123
+ directory = undefined
124
+ },
125
+ }
126
+ }
127
+
128
+ const anonymousSession: CredentialSession = Object.freeze({
129
+ type: "anonymous",
130
+ signIn: null,
131
+ })
132
+
133
+ export function anonymousCredentialHost(): CredentialHost {
134
+ return {
135
+ async ready() {},
136
+ async resolve() {
137
+ return anonymousSession
138
+ },
139
+ async fetch() {
140
+ return new Response("Not Found", { status: 404 })
141
+ },
142
+ async close() {},
143
+ }
144
+ }
@@ -0,0 +1,71 @@
1
+ export type ActorProfile = Readonly<{
2
+ name: string
3
+ username: string
4
+ avatar?: string
5
+ }>
6
+
7
+ type ActorBase = Readonly<{
8
+ id: string
9
+ profile: ActorProfile
10
+ home: string
11
+ }>
12
+
13
+ // Actor should be a schema
14
+
15
+ export type UserActor = ActorBase &
16
+ Readonly<{
17
+ type: "user"
18
+ role: "member" | "staff"
19
+ }>
20
+
21
+ export type AgentActor = ActorBase & Readonly<{ type: "agent" }>
22
+
23
+ export type Actor = UserActor | AgentActor
24
+
25
+ export type Principal =
26
+ Readonly<{ type: "anonymous" }> | Readonly<{ type: "actor"; actor: Actor }>
27
+
28
+ export type CredentialSession =
29
+ | Readonly<{
30
+ type: "anonymous"
31
+ signIn: Readonly<{ method: "get"; href: string }> | null
32
+ }>
33
+ | Readonly<{
34
+ type: "authenticated"
35
+ actor: UserActor
36
+ signOut: Readonly<{ method: "post"; href: string }> | null
37
+ }>
38
+
39
+ export type ActorProfileRef = Readonly<{
40
+ id: string
41
+ profile: ActorProfile
42
+ }>
43
+
44
+ export type CredentialUser = Readonly<{
45
+ actor: UserActor
46
+ emails: readonly string[]
47
+ }>
48
+
49
+ export type ActorProfiles = Readonly<{
50
+ findById(id: string): ActorProfileRef | undefined
51
+ findByUsername(username: string): ActorProfileRef | undefined
52
+ }>
53
+
54
+ export type KnownActorIndex = ActorProfiles &
55
+ Readonly<{
56
+ findActorById(id: string): Actor | undefined
57
+ findHomeOwner(path: string): Actor | undefined
58
+ findUserByEmail(email: string): UserActor | undefined
59
+ credentialUsers(): readonly CredentialUser[]
60
+ all(): readonly Actor[]
61
+ }>
62
+
63
+ export const anonymousPrincipal: Principal = Object.freeze({
64
+ type: "anonymous",
65
+ })
66
+
67
+ export function principalFor(session: CredentialSession): Principal {
68
+ return session.type === "authenticated"
69
+ ? { type: "actor", actor: session.actor }
70
+ : anonymousPrincipal
71
+ }
@@ -0,0 +1,89 @@
1
+ import { AccessDeniedError, type SharedSourceGrant } from "./access.ts"
2
+ import { canonicalPath, canonicalSourceId } from "../canonical.ts"
3
+ import type { Engine } from "../host/engine.ts"
4
+ import { createSource, patchFrontmatter } from "../source/index.ts"
5
+ import type { Actor } from "./model.ts"
6
+
7
+ export function setSharedGrants(
8
+ engine: Engine,
9
+ actor: Actor,
10
+ sourceId: string,
11
+ grants: readonly SharedSourceGrant[],
12
+ ) {
13
+ canonicalSourceId(sourceId)
14
+ return engine.commitPrepared(async () => {
15
+ const before = engine.repo.get(sourceId)
16
+ if (!before) throw new AccessDeniedError()
17
+ const current = before.data.access
18
+ if (!current || typeof current !== "object" || Array.isArray(current)) {
19
+ throw new AccessDeniedError("shared-source-required")
20
+ }
21
+ const owner = (current as Record<string, unknown>).owner
22
+ if (typeof owner !== "string") {
23
+ throw new AccessDeniedError("shared-source-required")
24
+ }
25
+ canonicalSourceId(owner)
26
+ const canonicalGrants = grants.map((grant) => ({
27
+ actor: canonicalSourceId(grant.actor),
28
+ can: [...grant.can],
29
+ }))
30
+ const canonicalActors = canonicalGrants.map((grant) => grant.actor)
31
+ if (new Set(canonicalActors).size !== canonicalActors.length) {
32
+ throw new AccessDeniedError("shared-members-must-be-unique")
33
+ }
34
+ const content = patchFrontmatter(
35
+ before.content,
36
+ { access: { type: "shared", owner, members: canonicalGrants } },
37
+ engine.environment.yaml,
38
+ )
39
+ const after = createSource(before.path, content, engine.environment.yaml)
40
+ const decision = engine.authorizeChange(actor, {
41
+ type: "update",
42
+ operation: "grants",
43
+ sourceId,
44
+ before,
45
+ after,
46
+ })
47
+ if (decision.type === "denied") throw new AccessDeniedError(decision.code)
48
+ return {
49
+ changes: [{ type: "replace" as const, path: before.path, content }],
50
+ }
51
+ })
52
+ }
53
+
54
+ export function setSourceAttachments(
55
+ engine: Engine,
56
+ actor: Actor,
57
+ sourceId: string,
58
+ attachments: readonly string[],
59
+ ) {
60
+ canonicalSourceId(sourceId)
61
+ const canonicalAttachments = attachments.map(canonicalPath)
62
+ if (
63
+ canonicalAttachments.some((attachment) => attachment.endsWith(".md")) ||
64
+ new Set(canonicalAttachments).size !== canonicalAttachments.length
65
+ ) {
66
+ throw new AccessDeniedError("invalid-attachment-binding")
67
+ }
68
+ return engine.commitPrepared(async () => {
69
+ const before = engine.repo.get(sourceId)
70
+ if (!before) throw new AccessDeniedError()
71
+ const content = patchFrontmatter(
72
+ before.content,
73
+ { attachments: canonicalAttachments },
74
+ engine.environment.yaml,
75
+ )
76
+ const after = createSource(before.path, content, engine.environment.yaml)
77
+ const decision = engine.authorizeChange(actor, {
78
+ type: "update",
79
+ operation: "attachments",
80
+ sourceId,
81
+ before,
82
+ after,
83
+ })
84
+ if (decision.type === "denied") throw new AccessDeniedError(decision.code)
85
+ return {
86
+ changes: [{ type: "replace" as const, path: before.path, content }],
87
+ }
88
+ })
89
+ }