@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.
- package/LICENSE.md +172 -0
- package/README.md +111 -0
- package/actions/actions.ts +307 -0
- package/actions/draft.ts +105 -0
- package/actions/transaction.ts +164 -0
- package/assets.ts +149 -0
- package/authority/access.ts +820 -0
- package/authority/actors.ts +271 -0
- package/authority/agents.ts +95 -0
- package/authority/credentials.ts +144 -0
- package/authority/model.ts +71 -0
- package/authority/sharing.ts +89 -0
- package/browser-bundle.ts +61 -0
- package/canonical.ts +41 -0
- package/cli/dev.ts +134 -0
- package/cli/export.ts +74 -0
- package/cli/index.ts +52 -0
- package/cli/lint.ts +164 -0
- package/cli/mv.ts +30 -0
- package/cli/perms.ts +182 -0
- package/cli/plugin-packages.ts +159 -0
- package/cli/plugins.ts +95 -0
- package/cli/query.ts +225 -0
- package/cli/readme.md +95 -0
- package/cli/release.ts +75 -0
- package/cli/rm.ts +22 -0
- package/cli/serve.ts +100 -0
- package/cli/tree.ts +131 -0
- package/cli/update.ts +78 -0
- package/cli/utils.ts +53 -0
- package/core.ts +96 -0
- package/database.ts +189 -0
- package/dependencies.ts +60 -0
- package/dist/pathmx.js +1224 -0
- package/document.ts +194 -0
- package/environment.ts +110 -0
- package/environments/bun-plugins.ts +68 -0
- package/environments/bun-publish.ts +194 -0
- package/environments/bun.ts +223 -0
- package/globals.d.ts +14 -0
- package/graph.ts +153 -0
- package/host/compiler.ts +314 -0
- package/host/create.ts +66 -0
- package/host/engine.ts +630 -0
- package/host/plugin-host.ts +487 -0
- package/host/render.ts +102 -0
- package/host/response.ts +20 -0
- package/host/shell.html +15 -0
- package/host/source-sync.ts +254 -0
- package/html-attributes.ts +15 -0
- package/html.ts +48 -0
- package/icons/LICENSE +43 -0
- package/icons/lucide.ts +54 -0
- package/index.ts +75 -0
- package/invalidation.ts +85 -0
- package/log.ts +84 -0
- package/ops/href.ts +87 -0
- package/ops/index.ts +9 -0
- package/ops/move-source.ts +114 -0
- package/ops/plan.ts +47 -0
- package/ops/remove-source.ts +70 -0
- package/package.json +46 -0
- package/path.ts +75 -0
- package/pattern.ts +314 -0
- package/perf.ts +88 -0
- package/plugins/actor-path.ts +43 -0
- package/plugins/actor-profile.ts +55 -0
- package/plugins/agent-browsing.ts +183 -0
- package/plugins/app-component.ts +23 -0
- package/plugins/block-layout/index.ts +78 -0
- package/plugins/block-layout/theme.css +90 -0
- package/plugins/callout/index.ts +236 -0
- package/plugins/callout/theme.css +138 -0
- package/plugins/canonical-links.ts +144 -0
- package/plugins/code-highlight/client.ts +89 -0
- package/plugins/code-highlight/index.ts +147 -0
- package/plugins/code-highlight/theme.css +89 -0
- package/plugins/context.ts +131 -0
- package/plugins/datatype/Datatype[wdth,wght].woff2 +0 -0
- package/plugins/datatype/OFL.txt +96 -0
- package/plugins/datatype/index.ts +287 -0
- package/plugins/definition-list.ts +236 -0
- package/plugins/directive-definitions.ts +13 -0
- package/plugins/embedding/client.ts +88 -0
- package/plugins/embedding/index.ts +43 -0
- package/plugins/image/index.ts +68 -0
- package/plugins/image/theme.css +43 -0
- package/plugins/include.ts +119 -0
- package/plugins/index.ts +91 -0
- package/plugins/input/authoring.ts +236 -0
- package/plugins/input/config.ts +85 -0
- package/plugins/input/index.ts +359 -0
- package/plugins/input/model.ts +173 -0
- package/plugins/input/state.ts +272 -0
- package/plugins/layout.ts +313 -0
- package/plugins/literate/client.ts +208 -0
- package/plugins/literate/index.ts +742 -0
- package/plugins/math/index.ts +140 -0
- package/plugins/meta.ts +42 -0
- package/plugins/navigation/index.ts +158 -0
- package/plugins/navigation/model.ts +364 -0
- package/plugins/preset.ts +65 -0
- package/plugins/query/index.ts +322 -0
- package/plugins/rss/index.ts +46 -0
- package/plugins/runtime/actions.ts +62 -0
- package/plugins/runtime/client.ts +8 -0
- package/plugins/runtime/document.ts +506 -0
- package/plugins/runtime/error.ts +154 -0
- package/plugins/runtime/heading-links.ts +31 -0
- package/plugins/runtime/index.ts +104 -0
- package/plugins/runtime/live.ts +51 -0
- package/plugins/runtime/navigation.ts +113 -0
- package/plugins/runtime/pending.ts +53 -0
- package/plugins/runtime/publication.ts +19 -0
- package/plugins/runtime/start.ts +177 -0
- package/plugins/runtime/static-client.ts +6 -0
- package/plugins/runtime/static-live.ts +188 -0
- package/plugins/runtime/view-transition.ts +72 -0
- package/plugins/runtime/viewport.ts +110 -0
- package/plugins/schema.ts +24 -0
- package/plugins/scripts.ts +140 -0
- package/plugins/source-preview/client.ts +256 -0
- package/plugins/source-preview/index.ts +132 -0
- package/plugins/source-preview/model.ts +11 -0
- package/plugins/source-preview/preview.css +107 -0
- package/plugins/styles.ts +145 -0
- package/plugins/theme/components.ts +10 -0
- package/plugins/theme/foundation.css +358 -0
- package/plugins/theme/index.ts +333 -0
- package/plugins/theme/prose.css +284 -0
- package/plugins/theme/scheme.js +63 -0
- package/plugins/theme/toggle.css +44 -0
- package/plugins/theme/view-transition.css +68 -0
- package/plugins/toc/client.ts +146 -0
- package/plugins/toc/index.ts +232 -0
- package/plugins/toc/theme.css +172 -0
- package/plugins/types.ts +538 -0
- package/problem.ts +41 -0
- package/project.ts +6 -0
- package/publish/build.ts +402 -0
- package/publish/concurrent.ts +32 -0
- package/publish/identity.ts +63 -0
- package/publish/index.ts +11 -0
- package/publish/location.ts +167 -0
- package/publish/model.ts +58 -0
- package/publish/plan.ts +300 -0
- package/publish/validate.ts +150 -0
- package/query/candidate-index.ts +101 -0
- package/query/dependency-index.ts +99 -0
- package/query/fields.ts +216 -0
- package/query/invalidation.ts +48 -0
- package/query/kernel.ts +776 -0
- package/query/model.ts +79 -0
- package/repository.ts +374 -0
- package/runtime-profile.ts +34 -0
- package/schema/model.ts +92 -0
- package/schema/registry.ts +642 -0
- package/schema/standard.ts +226 -0
- package/server/context.ts +94 -0
- package/server/css-imports.ts +66 -0
- package/server/http.ts +333 -0
- package/server/page.ts +21 -0
- package/server/protocols.ts +12 -0
- package/server/representations.ts +66 -0
- package/server/router.ts +131 -0
- package/server/routes.ts +202 -0
- package/server/serve.ts +5 -0
- package/server/watch.ts +120 -0
- package/source/blocks.ts +71 -0
- package/source/format.ts +164 -0
- package/source/index.ts +67 -0
- package/source/interpolate.ts +63 -0
- package/source/markdown/blocks.ts +46 -0
- package/source/markdown/comments.ts +32 -0
- package/source/markdown/fences.ts +67 -0
- package/source/markdown/headings.ts +88 -0
- package/source/markdown/index.ts +34 -0
- package/source/markdown/inline-code.ts +6 -0
- package/source/markdown/links.ts +326 -0
- package/source/markdown/lists.ts +223 -0
- package/source/markdown/prose.ts +74 -0
- package/source/markdown/scan.ts +79 -0
- package/source/markdown/text.ts +82 -0
- package/source/meta.ts +87 -0
- package/source/source.ts +121 -0
- package/source/topmatter.ts +57 -0
- package/source/utils.ts +21 -0
- package/source-diagnostic.ts +63 -0
- package/text-file.ts +26 -0
- package/util.ts +35 -0
- package/view/components.ts +499 -0
- package/view/model.ts +91 -0
- package/view/projection.ts +162 -0
- package/view/session.ts +146 -0
- 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
|
+
}
|