@dwk/solid-pod 0.1.0-beta.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 (68) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +108 -0
  3. package/dist/auth.d.ts +33 -0
  4. package/dist/auth.d.ts.map +1 -0
  5. package/dist/auth.js +160 -0
  6. package/dist/auth.js.map +1 -0
  7. package/dist/config.d.ts +181 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +74 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/encoding.d.ts +13 -0
  12. package/dist/encoding.d.ts.map +1 -0
  13. package/dist/encoding.js +31 -0
  14. package/dist/encoding.js.map +1 -0
  15. package/dist/gc.d.ts +22 -0
  16. package/dist/gc.d.ts.map +1 -0
  17. package/dist/gc.js +33 -0
  18. package/dist/gc.js.map +1 -0
  19. package/dist/handler.d.ts +20 -0
  20. package/dist/handler.d.ts.map +1 -0
  21. package/dist/handler.js +155 -0
  22. package/dist/handler.js.map +1 -0
  23. package/dist/index.d.ts +24 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +23 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/jwt.d.ts +36 -0
  28. package/dist/jwt.d.ts.map +1 -0
  29. package/dist/jwt.js +120 -0
  30. package/dist/jwt.js.map +1 -0
  31. package/dist/ldp.d.ts +37 -0
  32. package/dist/ldp.d.ts.map +1 -0
  33. package/dist/ldp.js +85 -0
  34. package/dist/ldp.js.map +1 -0
  35. package/dist/log.d.ts +55 -0
  36. package/dist/log.d.ts.map +1 -0
  37. package/dist/log.js +51 -0
  38. package/dist/log.js.map +1 -0
  39. package/dist/negotiation.d.ts +23 -0
  40. package/dist/negotiation.d.ts.map +1 -0
  41. package/dist/negotiation.js +80 -0
  42. package/dist/negotiation.js.map +1 -0
  43. package/dist/patch.d.ts +80 -0
  44. package/dist/patch.d.ts.map +1 -0
  45. package/dist/patch.js +425 -0
  46. package/dist/patch.js.map +1 -0
  47. package/dist/pod.d.ts +20 -0
  48. package/dist/pod.d.ts.map +1 -0
  49. package/dist/pod.js +860 -0
  50. package/dist/pod.js.map +1 -0
  51. package/dist/wac.d.ts +33 -0
  52. package/dist/wac.d.ts.map +1 -0
  53. package/dist/wac.js +84 -0
  54. package/dist/wac.js.map +1 -0
  55. package/package.json +55 -0
  56. package/src/auth.ts +203 -0
  57. package/src/config.ts +254 -0
  58. package/src/encoding.ts +32 -0
  59. package/src/gc.ts +47 -0
  60. package/src/handler.ts +199 -0
  61. package/src/index.ts +32 -0
  62. package/src/jwt.ts +166 -0
  63. package/src/ldp.ts +99 -0
  64. package/src/log.ts +59 -0
  65. package/src/negotiation.ts +97 -0
  66. package/src/patch.ts +539 -0
  67. package/src/pod.ts +1195 -0
  68. package/src/wac.ts +119 -0
package/src/pod.ts ADDED
@@ -0,0 +1,1195 @@
1
+ /**
2
+ * The per-pod Durable Object: the single-threaded consistency, authz, and
3
+ * notification authority for one Solid Pod.
4
+ *
5
+ * The stateless front door (`handler.ts`) authenticates at the edge and hands
6
+ * the verified agent facts to this object via internal headers; everything that
7
+ * must be strongly consistent — LDP verbs, WAC, N3 Patch, `If-Match` writes,
8
+ * DPoP `jti` replay, R2 copy-on-write through `@dwk/store`, and WebSocket
9
+ * notifications — happens here, where Cloudflare guarantees a single thread per
10
+ * pod. Consumers bind this class as a Durable Object namespace.
11
+ */
12
+
13
+ import { DurableObject } from "cloudflare:workers";
14
+
15
+ import { DEFAULT_MAX_AGE_SECONDS } from "@dwk/dpop";
16
+ import {
17
+ parse as parseRdf,
18
+ serialize as serializeRdf,
19
+ quadToStored,
20
+ storedToQuad,
21
+ formatForMediaType,
22
+ type Quad,
23
+ type StoredQuad,
24
+ type StoredTerm,
25
+ } from "@dwk/rdf";
26
+ import {
27
+ createStore,
28
+ d1OrphanSink,
29
+ forwardOrphans,
30
+ PreconditionFailedError,
31
+ type Store,
32
+ type WriteOptions,
33
+ } from "@dwk/store";
34
+ import { discoverInboxIris, inboxLinkHeader } from "@dwk/ldn/discovery";
35
+ import type { AccessMode } from "@dwk/wac";
36
+
37
+ import { INTERNAL_HEADERS, type SolidPodEnv } from "./config";
38
+ import { PodOutcome } from "./log";
39
+ import {
40
+ ancestorContainers,
41
+ childKey,
42
+ hasReservedAuxiliarySuffix,
43
+ isAclPath,
44
+ isContainer,
45
+ parentContainer,
46
+ resourceForAcl,
47
+ toIri,
48
+ } from "./ldp";
49
+ import { negotiateMediaType, type Negotiated } from "./negotiation";
50
+ import {
51
+ parsePatch,
52
+ PatchConstraintError,
53
+ PatchProblem,
54
+ resolvePatch,
55
+ } from "./patch";
56
+ import { authorize, grantedModes } from "./wac";
57
+
58
+ const LDP = "http://www.w3.org/ns/ldp#";
59
+ const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
60
+ const ACTIVITYSTREAMS = "https://www.w3.org/ns/activitystreams";
61
+
62
+ /**
63
+ * How long a write's DPoP `jti` is remembered for replay rejection. `@dwk/dpop`
64
+ * accepts a proof whose `iat` lands anywhere in `±DEFAULT_MAX_AGE_SECONDS` of
65
+ * now (`auth.ts` does not override `maxAgeSeconds`), so a single proof stays
66
+ * cryptographically acceptable across a span of `2 × DEFAULT_MAX_AGE_SECONDS`.
67
+ * The replay row MUST outlive that full window — otherwise it could be pruned
68
+ * while the proof is still valid, reopening a replay hole — so the TTL is
69
+ * anchored to it rather than a bare 5 minutes.
70
+ */
71
+ const JTI_TTL_SECONDS = 2 * DEFAULT_MAX_AGE_SECONDS;
72
+
73
+ const SERIALIZE_PREFIXES: Record<string, string> = {
74
+ ldp: LDP,
75
+ acl: "http://www.w3.org/ns/auth/acl#",
76
+ foaf: "http://xmlns.com/foaf/0.1/",
77
+ solid: "http://www.w3.org/ns/solid/terms#",
78
+ rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
79
+ };
80
+
81
+ const DEFAULT_GRAPH: StoredTerm = { termType: "DefaultGraph", value: "" };
82
+
83
+ /** Config the front door forwards to the DO (everything else is per-request). */
84
+ interface ForwardedConfig {
85
+ readonly maxInlineBytes?: number;
86
+ /** Owner WebIDs, always granted full access (bootstraps ACL management). */
87
+ readonly owners?: readonly string[];
88
+ /** Permit unauthenticated (proof-less) writes where WAC grants the public. */
89
+ readonly allowAnonymousWrites?: boolean;
90
+ }
91
+
92
+ /** A change to announce to notification subscribers. */
93
+ type ChangeType = "Create" | "Update" | "Delete";
94
+
95
+ function text(
96
+ status: number,
97
+ body: string,
98
+ headers: HeadersInit = {},
99
+ ): Response {
100
+ return new Response(body, {
101
+ status,
102
+ headers: { "content-type": "text/plain; charset=utf-8", ...headers },
103
+ });
104
+ }
105
+
106
+ /** A named-node triple in the default graph. */
107
+ function iriQuad(
108
+ subject: string,
109
+ predicate: string,
110
+ object: string,
111
+ ): StoredQuad {
112
+ return {
113
+ subject: { termType: "NamedNode", value: subject },
114
+ predicate: { termType: "NamedNode", value: predicate },
115
+ object: { termType: "NamedNode", value: object },
116
+ graph: DEFAULT_GRAPH,
117
+ };
118
+ }
119
+
120
+ /** Whether a stored quad is `<subject> ldp:contains <object>`. */
121
+ function isContainsQuad(quad: StoredQuad, subject: string): boolean {
122
+ return (
123
+ quad.subject.value === subject && quad.predicate.value === `${LDP}contains`
124
+ );
125
+ }
126
+
127
+ export class SolidPodObject extends DurableObject<SolidPodEnv> {
128
+ #store: Store | null = null;
129
+ readonly #sql: SqlStorage;
130
+ /** Owner WebIDs (deployment-constant), set from the forwarded config. */
131
+ #owners: readonly string[] = [];
132
+ /** Whether proof-less (anonymous) writes are permitted; see {@link JTI_TTL_SECONDS}. */
133
+ #allowAnonymousWrites = false;
134
+
135
+ constructor(state: DurableObjectState, env: SolidPodEnv) {
136
+ super(state, env);
137
+ this.#sql = state.storage.sql;
138
+ this.#sql.exec(
139
+ `CREATE TABLE IF NOT EXISTS dpop_jti (
140
+ jti TEXT PRIMARY KEY,
141
+ expires_at INTEGER NOT NULL
142
+ )`,
143
+ );
144
+ }
145
+
146
+ /** Lazily build the store with the front-door's offload threshold. */
147
+ #getStore(config: ForwardedConfig): Store {
148
+ if (this.#store === null) {
149
+ this.#store = createStore(this.ctx, this.env, {
150
+ ...(config.maxInlineBytes !== undefined
151
+ ? { maxInlineBytes: config.maxInlineBytes }
152
+ : {}),
153
+ });
154
+ }
155
+ return this.#store;
156
+ }
157
+
158
+ override async fetch(request: Request): Promise<Response> {
159
+ if (request.headers.get("upgrade")?.toLowerCase() === "websocket") {
160
+ return this.#handleWebSocketUpgrade();
161
+ }
162
+
163
+ const config: ForwardedConfig = (() => {
164
+ const raw = request.headers.get(INTERNAL_HEADERS.config);
165
+ if (!raw) return {};
166
+ try {
167
+ return JSON.parse(raw) as ForwardedConfig;
168
+ } catch {
169
+ return {};
170
+ }
171
+ })();
172
+
173
+ this.#owners = config.owners ?? [];
174
+ this.#allowAnonymousWrites = config.allowAnonymousWrites ?? false;
175
+ const store = this.#getStore(config);
176
+ const url = new URL(request.url);
177
+ const origin = url.origin;
178
+ // Keep the path percent-encoded: decoding `%2F` would conflate it with a
179
+ // real path separator and corrupt store keys / resource IRIs.
180
+ const path = url.pathname;
181
+ const webidHeader = request.headers.get(INTERNAL_HEADERS.webid);
182
+ const agent =
183
+ webidHeader && webidHeader.length > 0 ? webidHeader : undefined;
184
+ const jti = request.headers.get(INTERNAL_HEADERS.jti) ?? undefined;
185
+ const requestOrigin = request.headers.get("origin") ?? undefined;
186
+
187
+ const method = request.method.toUpperCase();
188
+ if (method === "OPTIONS") return this.#options(path);
189
+
190
+ try {
191
+ switch (method) {
192
+ case "HEAD":
193
+ case "GET":
194
+ return await this.#authorizeThenAsync(
195
+ store,
196
+ origin,
197
+ path,
198
+ "read",
199
+ agent,
200
+ requestOrigin,
201
+ () =>
202
+ this.#read(
203
+ store,
204
+ origin,
205
+ path,
206
+ request,
207
+ method === "HEAD",
208
+ agent,
209
+ requestOrigin,
210
+ ),
211
+ );
212
+ case "PUT":
213
+ return await this.#authorizeThenAsync(
214
+ store,
215
+ origin,
216
+ path,
217
+ "write",
218
+ agent,
219
+ requestOrigin,
220
+ () => this.#put(store, origin, path, request, jti),
221
+ );
222
+ case "POST":
223
+ return await this.#authorizeThenAsync(
224
+ store,
225
+ origin,
226
+ path,
227
+ "append",
228
+ agent,
229
+ requestOrigin,
230
+ () => this.#post(store, origin, path, request, jti),
231
+ );
232
+ case "PATCH":
233
+ return await this.#patch(
234
+ store,
235
+ origin,
236
+ path,
237
+ request,
238
+ agent,
239
+ jti,
240
+ requestOrigin,
241
+ );
242
+ case "DELETE":
243
+ return await this.#authorizeThenAsync(
244
+ store,
245
+ origin,
246
+ path,
247
+ "write",
248
+ agent,
249
+ requestOrigin,
250
+ () => this.#delete(store, origin, path, request, jti),
251
+ );
252
+ default:
253
+ return text(405, "Method Not Allowed", { allow: ALLOW });
254
+ }
255
+ } catch (error) {
256
+ if (error instanceof PreconditionFailedError) {
257
+ return text(412, "Precondition Failed");
258
+ }
259
+ if (error instanceof LengthRequiredError) {
260
+ return text(411, "Length Required");
261
+ }
262
+ // A `jti` collision surfaces from inside the write transaction (the row
263
+ // and the write commit or roll back together — see `#consumeJti`).
264
+ if (error instanceof DpopReplayError) {
265
+ return this.#replayed();
266
+ }
267
+ throw error;
268
+ }
269
+ }
270
+
271
+ // -- authorization wrappers ------------------------------------------------
272
+
273
+ /** Map an access path/mode through WAC, deferring `.acl` resources to Control. */
274
+ #decide(
275
+ store: Store,
276
+ origin: string,
277
+ path: string,
278
+ mode: AccessMode,
279
+ agent: string | undefined,
280
+ requestOrigin: string | undefined,
281
+ ): { granted: boolean; status: 401 | 403 } | null {
282
+ // The pod owner always has full access; this bootstraps `.acl` management.
283
+ if (agent !== undefined && this.#owners.includes(agent)) return null;
284
+
285
+ const wacPath = isAclPath(path) ? resourceForAcl(path) : path;
286
+ const wacMode: AccessMode = isAclPath(path) ? "control" : mode;
287
+ const decision = authorize(store, origin, wacPath, {
288
+ mode: wacMode,
289
+ ...(agent ? { agent } : {}),
290
+ ...(requestOrigin ? { origin: requestOrigin } : {}),
291
+ });
292
+ if (decision.granted) return null;
293
+ // Unauthenticated denials prompt authentication (401); authenticated
294
+ // denials are a hard 403.
295
+ return { granted: false, status: agent ? 403 : 401 };
296
+ }
297
+
298
+ async #authorizeThenAsync(
299
+ store: Store,
300
+ origin: string,
301
+ path: string,
302
+ mode: AccessMode,
303
+ agent: string | undefined,
304
+ requestOrigin: string | undefined,
305
+ run: () => Promise<Response>,
306
+ ): Promise<Response> {
307
+ const denied = this.#decide(
308
+ store,
309
+ origin,
310
+ path,
311
+ mode,
312
+ agent,
313
+ requestOrigin,
314
+ );
315
+ if (denied) return this.#denied(denied.status);
316
+ return run();
317
+ }
318
+
319
+ #denied(status: 401 | 403): Response {
320
+ return text(status, status === 401 ? "Unauthorized" : "Forbidden", {
321
+ ...(status === 401 ? { "www-authenticate": 'DPoP realm="solid"' } : {}),
322
+ // Signal the WAC denial to the front door, which logs it via the injected
323
+ // observability seams and strips this header before replying to the client.
324
+ [INTERNAL_HEADERS.outcome]: PodOutcome.WacDenied,
325
+ });
326
+ }
327
+
328
+ /**
329
+ * Forward any blob keys the store outboxed (copy-on-write displacements and
330
+ * deletes) into the shared D1 GC tracking table, so the out-of-band cron can
331
+ * reclaim them without ever waking this Durable Object. No-op when `GC_DB` is
332
+ * not bound.
333
+ */
334
+ async #drainOrphans(store: Store): Promise<void> {
335
+ if (!this.env.GC_DB) return;
336
+ await forwardOrphans(store, d1OrphanSink(this.env.GC_DB));
337
+ }
338
+
339
+ // -- DPoP replay -----------------------------------------------------------
340
+
341
+ /**
342
+ * Refuse a proof-less write unless the deployment opted into anonymous writes.
343
+ * A tokenless request reaches a write handler only when WAC granted the public
344
+ * agent class; without a DPoP proof there is no `jti`, hence no replay /
345
+ * anti-abuse control, so "DPoP everywhere" requires we reject by default.
346
+ * Returns a `401` challenge to block the write, or `null` to let it proceed.
347
+ */
348
+ #denyAnonymousWrite(jti: string | undefined): Response | null {
349
+ if (jti !== undefined || this.#allowAnonymousWrites) return null;
350
+ return text(401, "DPoP proof required for writes", {
351
+ "www-authenticate": 'DPoP realm="solid", error="invalid_token"',
352
+ [INTERNAL_HEADERS.outcome]: PodOutcome.AnonymousWriteRefused,
353
+ });
354
+ }
355
+
356
+ /**
357
+ * Build the in-transaction guard that enforces strict single-use of a write's
358
+ * DPoP `jti`. The store runs it inside the write `transactionSync`, *after*
359
+ * the `If-Match` / `solid:where` preconditions pass, so a `jti` is consumed
360
+ * only when the write actually commits: a failed precondition (412/409) rolls
361
+ * the row back and leaves the proof reusable for a legitimate retry, and a
362
+ * replayed `jti` aborts the write atomically. No-op for an anonymous write
363
+ * (it carries no proof; admission is gated by {@link #denyAnonymousWrite}).
364
+ */
365
+ #replayGuard(jti: string | undefined): () => void {
366
+ return () => this.#consumeJti(jti);
367
+ }
368
+
369
+ /** Prune expired rows, reject a duplicate `jti`, then record this one. */
370
+ #consumeJti(jti: string | undefined): void {
371
+ if (!jti) return;
372
+ const nowSec = Math.floor(Date.now() / 1000);
373
+ this.#sql.exec("DELETE FROM dpop_jti WHERE expires_at < ?", nowSec);
374
+ const existing = this.#sql
375
+ .exec<{
376
+ n: number;
377
+ }>("SELECT COUNT(*) AS n FROM dpop_jti WHERE jti = ?", jti)
378
+ .one().n;
379
+ if (existing > 0) throw new DpopReplayError();
380
+ this.#sql.exec(
381
+ "INSERT INTO dpop_jti (jti, expires_at) VALUES (?, ?)",
382
+ jti,
383
+ nowSec + JTI_TTL_SECONDS,
384
+ );
385
+ }
386
+
387
+ // -- read ------------------------------------------------------------------
388
+
389
+ async #read(
390
+ store: Store,
391
+ origin: string,
392
+ path: string,
393
+ request: Request,
394
+ headOnly: boolean,
395
+ agent: string | undefined,
396
+ requestOrigin: string | undefined,
397
+ ): Promise<Response> {
398
+ const meta = store.head(path);
399
+ if (!meta) return text(404, "Not Found");
400
+
401
+ const ifNoneMatch = request.headers.get("if-none-match");
402
+ if (ifNoneMatch && ifNoneMatchSatisfied(ifNoneMatch, meta.etag)) {
403
+ return new Response(null, { status: 304, headers: { etag: meta.etag } });
404
+ }
405
+
406
+ // WAC §5.3.5: GET/HEAD MUST advertise the client's privileges (and the
407
+ // public's) via `WAC-Allow`. Computed once and threaded into the header
408
+ // builder for both the blob and RDF responses.
409
+ const wacAllow = this.#wacAllow(store, origin, path, agent, requestOrigin);
410
+
411
+ if (meta.kind === "blob") {
412
+ // Streamed straight from R2 — never buffered in the DO.
413
+ return this.#readBlobResponse(
414
+ store,
415
+ path,
416
+ meta.etag,
417
+ meta.contentType,
418
+ headOnly,
419
+ wacAllow,
420
+ );
421
+ }
422
+
423
+ const negotiated = negotiateMediaType(request.headers.get("accept"));
424
+ if (negotiated === null) {
425
+ return text(406, "Not Acceptable");
426
+ }
427
+
428
+ const stored = store.readQuads(path);
429
+ const quads = stored.map(storedToQuad);
430
+ // LDN discovery: surface any `ldp:inbox` this resource declares as a
431
+ // `Link rel="…ldp#inbox"` so notification senders can find the inbox without
432
+ // parsing the body. RDF-only — the body must point at its own inbox.
433
+ const inboxIris = discoverInboxIris(stored, toIri(origin, path));
434
+ return this.#serializeResponse(
435
+ quads,
436
+ negotiated,
437
+ origin,
438
+ path,
439
+ meta.etag,
440
+ headOnly,
441
+ inboxIris,
442
+ wacAllow,
443
+ );
444
+ }
445
+
446
+ /**
447
+ * Compute the `WAC-Allow` header value for a read (WAC §5.3.5). The format is
448
+ * `user="…",public="…"`: the privileges the authenticated agent holds and the
449
+ * privileges any unauthenticated agent holds, each a space-separated list of
450
+ * lowercase mode tokens. The pod owner always holds the full set.
451
+ */
452
+ #wacAllow(
453
+ store: Store,
454
+ origin: string,
455
+ path: string,
456
+ agent: string | undefined,
457
+ requestOrigin: string | undefined,
458
+ ): string {
459
+ // The owner always holds the full set, so we never need to evaluate their
460
+ // modes — only the public's. Otherwise resolve the effective ACL once and
461
+ // evaluate the authenticated agent and the public against it together.
462
+ const empty = (): Set<AccessMode> => new Set<AccessMode>();
463
+ if (agent !== undefined && this.#owners.includes(agent)) {
464
+ const [publicModes = empty()] = grantedModes(
465
+ store,
466
+ origin,
467
+ path,
468
+ [undefined],
469
+ requestOrigin,
470
+ );
471
+ const full = new Set<AccessMode>(["read", "write", "append", "control"]);
472
+ return wacAllowHeader(full, publicModes);
473
+ }
474
+ const [userModes = empty(), publicModes = empty()] = grantedModes(
475
+ store,
476
+ origin,
477
+ path,
478
+ [agent, undefined],
479
+ requestOrigin,
480
+ );
481
+ return wacAllowHeader(userModes, publicModes);
482
+ }
483
+
484
+ async #readBlobResponse(
485
+ store: Store,
486
+ path: string,
487
+ etag: string,
488
+ contentType: string,
489
+ headOnly: boolean,
490
+ wacAllow: string,
491
+ ): Promise<Response> {
492
+ const blob = await store.readBlob(path);
493
+ if (!blob) return text(404, "Not Found");
494
+ const headers = baseHeaders(path, etag, blob.contentType || contentType);
495
+ headers.set("content-length", String(blob.size));
496
+ headers.set("wac-allow", wacAllow);
497
+ if (headOnly) {
498
+ await blob.stream.cancel();
499
+ return new Response(null, { status: 200, headers });
500
+ }
501
+ return new Response(blob.stream, { status: 200, headers });
502
+ }
503
+
504
+ async #serializeResponse(
505
+ quads: Quad[],
506
+ negotiated: Negotiated,
507
+ origin: string,
508
+ path: string,
509
+ etag: string,
510
+ headOnly: boolean,
511
+ inboxIris: readonly string[],
512
+ wacAllow: string,
513
+ ): Promise<Response> {
514
+ const body = await serializeRdf(quads, negotiated.mediaType, {
515
+ baseIRI: toIri(origin, path),
516
+ prefixes: SERIALIZE_PREFIXES,
517
+ });
518
+ const headers = baseHeaders(path, etag, negotiated.mediaType, inboxIris);
519
+ headers.set("wac-allow", wacAllow);
520
+ return new Response(headOnly ? null : body, { status: 200, headers });
521
+ }
522
+
523
+ // -- put -------------------------------------------------------------------
524
+
525
+ async #put(
526
+ store: Store,
527
+ origin: string,
528
+ path: string,
529
+ request: Request,
530
+ jti: string | undefined,
531
+ ): Promise<Response> {
532
+ const blocked = this.#denyAnonymousWrite(jti);
533
+ if (blocked) return blocked;
534
+
535
+ const existed = store.head(path) !== null;
536
+ // Both preconditions are threaded into the store write and re-checked
537
+ // inside its transaction, so there is no TOCTOU window here. `existed` is
538
+ // only used to pick the 201/204 status; it is not relied on for safety.
539
+ // The `jti` replay row is consumed in that same transaction (`replayGuard`),
540
+ // so a rejected precondition does not burn the proof.
541
+ await this.#writeBody(store, origin, path, request, {
542
+ ifMatch: ifMatchOf(request),
543
+ ifNoneMatch: ifNoneMatchOf(request),
544
+ guard: this.#replayGuard(jti),
545
+ });
546
+ this.#ensureContainerChain(store, origin, path);
547
+ await this.#drainOrphans(store);
548
+
549
+ this.#broadcast(toIri(origin, path), existed ? "Update" : "Create");
550
+ const meta = store.head(path);
551
+ return new Response(null, {
552
+ status: existed ? 204 : 201,
553
+ headers: meta
554
+ ? { etag: meta.etag, location: toIri(origin, path), allow: ALLOW }
555
+ : { location: toIri(origin, path), allow: ALLOW },
556
+ });
557
+ }
558
+
559
+ // -- post ------------------------------------------------------------------
560
+
561
+ async #post(
562
+ store: Store,
563
+ origin: string,
564
+ path: string,
565
+ request: Request,
566
+ jti: string | undefined,
567
+ ): Promise<Response> {
568
+ if (!isContainer(path)) {
569
+ return text(405, "POST target must be a container", { allow: ALLOW });
570
+ }
571
+ const blocked = this.#denyAnonymousWrite(jti);
572
+ if (blocked) return blocked;
573
+
574
+ const asContainer = linkIndicatesContainer(request.headers.get("link"));
575
+ const slug = request.headers.get("slug");
576
+ // Honor an explicit Slug once; on collision fall back to random names.
577
+ let key = childKey(path, slug, asContainer);
578
+ while (store.head(key) !== null) {
579
+ key = childKey(path, null, asContainer);
580
+ }
581
+
582
+ // A POST always mints a fresh, non-colliding key, so it is unconditional;
583
+ // the `jti` is still consumed inside the write transaction.
584
+ await this.#writeBody(store, origin, key, request, {
585
+ guard: this.#replayGuard(jti),
586
+ });
587
+ this.#ensureContainerChain(store, origin, key);
588
+ await this.#drainOrphans(store);
589
+
590
+ const childIri = toIri(origin, key);
591
+ this.#broadcast(childIri, "Create");
592
+ this.#broadcast(toIri(origin, path), "Update");
593
+ const meta = store.head(key);
594
+ return new Response(null, {
595
+ status: 201,
596
+ headers: meta
597
+ ? { location: childIri, etag: meta.etag, allow: ALLOW }
598
+ : { location: childIri, allow: ALLOW },
599
+ });
600
+ }
601
+
602
+ // -- patch -----------------------------------------------------------------
603
+
604
+ async #patch(
605
+ store: Store,
606
+ origin: string,
607
+ path: string,
608
+ request: Request,
609
+ agent: string | undefined,
610
+ jti: string | undefined,
611
+ requestOrigin: string | undefined,
612
+ ): Promise<Response> {
613
+ const contentType = request.headers.get("content-type") ?? "";
614
+ const body = await request.text();
615
+
616
+ let parsed;
617
+ try {
618
+ parsed = parsePatch(body, contentType, toIri(origin, path));
619
+ } catch (error) {
620
+ // A document-constraint violation (malformed N3, missing type triple,
621
+ // duplicate predicate, blank node or unbound variable in a template) means
622
+ // the patch does not satisfy the N3 Patch document constraints → 422
623
+ // (Solid Protocol §5.3.1, `#server-patch-n3-invalid`).
624
+ if (error instanceof PatchConstraintError) {
625
+ return text(422, `Invalid patch document: ${error.code}`);
626
+ }
627
+ if (error instanceof PatchProblem) {
628
+ return error.code === "unsupported_media_type"
629
+ ? text(415, "Unsupported patch media type")
630
+ : text(400, `Malformed patch: ${error.code}`);
631
+ }
632
+ throw error;
633
+ }
634
+
635
+ // Append authorizes insert-only patches; any delete requires Write.
636
+ const mode: AccessMode = parsed.deletes.length > 0 ? "write" : "append";
637
+ const denied = this.#decide(
638
+ store,
639
+ origin,
640
+ path,
641
+ mode,
642
+ agent,
643
+ requestOrigin,
644
+ );
645
+ if (denied) return this.#denied(denied.status);
646
+
647
+ const blocked = this.#denyAnonymousWrite(jti);
648
+ if (blocked) return blocked;
649
+
650
+ const existed = store.head(path) !== null;
651
+ const current = store.readQuads(path);
652
+ let resolved;
653
+ try {
654
+ resolved = resolvePatch(parsed, current);
655
+ } catch (error) {
656
+ // An unbound template variable surfaced here (the SPARQL path, which has
657
+ // no static check) is a document constraint violation → 422.
658
+ if (error instanceof PatchConstraintError) {
659
+ return text(422, `Invalid patch document: ${error.code}`);
660
+ }
661
+ if (error instanceof PatchProblem) {
662
+ // `where_too_complex` is our own DoS guard, not a Solid-defined
663
+ // condition (a richer-than-supported `where` pattern); we keep it a
664
+ // 400 Bad Request as a non-spec extension. The remaining outcomes —
665
+ // `no_match`, `ambiguous_match`, `delete_not_found` — are binding/state
666
+ // results that mean the patch's precondition does not hold against the
667
+ // current document: 409 Conflict (`#server-patch-n3-semantics`).
668
+ return error.code === "where_too_complex"
669
+ ? text(400, `Patch where pattern too complex: ${error.code}`)
670
+ : text(409, `Patch does not apply: ${error.code}`);
671
+ }
672
+ throw error;
673
+ }
674
+
675
+ try {
676
+ store.patchQuads(
677
+ path,
678
+ { deletes: resolved.deletes, inserts: resolved.inserts },
679
+ {
680
+ ifMatch: ifMatchOf(request),
681
+ contentType: "text/turtle",
682
+ guard: this.#replayGuard(jti),
683
+ },
684
+ );
685
+ } catch (error) {
686
+ if (error instanceof PreconditionFailedError)
687
+ return text(412, "Precondition Failed");
688
+ throw error;
689
+ }
690
+ this.#ensureContainerChain(store, origin, path);
691
+ await this.#drainOrphans(store);
692
+
693
+ this.#broadcast(toIri(origin, path), existed ? "Update" : "Create");
694
+ const meta = store.head(path);
695
+ return new Response(null, {
696
+ status: existed ? 204 : 201,
697
+ headers: meta ? { etag: meta.etag, allow: ALLOW } : { allow: ALLOW },
698
+ });
699
+ }
700
+
701
+ // -- delete ----------------------------------------------------------------
702
+
703
+ async #delete(
704
+ store: Store,
705
+ origin: string,
706
+ path: string,
707
+ request: Request,
708
+ jti: string | undefined,
709
+ ): Promise<Response> {
710
+ const blocked = this.#denyAnonymousWrite(jti);
711
+ if (blocked) return blocked;
712
+
713
+ const meta = store.head(path);
714
+ if (!meta) return text(404, "Not Found");
715
+
716
+ try {
717
+ store.delete(path, {
718
+ ifMatch: ifMatchOf(request),
719
+ // Re-check container emptiness inside the delete transaction so a
720
+ // concurrent child write cannot slip in between the check and the
721
+ // delete (LDP: a non-empty container MUST NOT be deleted), then consume
722
+ // the `jti` in that same transaction. The emptiness check runs first so
723
+ // a 409 leaves the proof reusable; the `jti` is burned only if the
724
+ // delete commits.
725
+ guard: () => {
726
+ if (
727
+ isContainer(path) &&
728
+ store
729
+ .readQuads(path)
730
+ .some((q) => isContainsQuad(q, toIri(origin, path)))
731
+ ) {
732
+ throw new ContainerNotEmptyError();
733
+ }
734
+ this.#consumeJti(jti);
735
+ },
736
+ });
737
+ } catch (error) {
738
+ if (error instanceof ContainerNotEmptyError) {
739
+ return text(409, "Container is not empty");
740
+ }
741
+ throw error;
742
+ }
743
+ this.#removeContainment(store, origin, path);
744
+ await this.#drainOrphans(store);
745
+ this.#broadcast(toIri(origin, path), "Delete");
746
+ return new Response(null, { status: 204, headers: { allow: ALLOW } });
747
+ }
748
+
749
+ // -- options ---------------------------------------------------------------
750
+
751
+ #options(path: string): Response {
752
+ return new Response(null, {
753
+ status: 204,
754
+ headers: {
755
+ allow: ALLOW,
756
+ "accept-patch": "text/n3, application/sparql-update",
757
+ ...(isContainer(path) ? { "accept-post": ACCEPT_POST } : {}),
758
+ },
759
+ });
760
+ }
761
+
762
+ // -- write helpers ---------------------------------------------------------
763
+
764
+ #replayed(): Response {
765
+ return text(401, "DPoP proof replay detected", {
766
+ "www-authenticate": 'DPoP error="invalid_token"',
767
+ [INTERNAL_HEADERS.outcome]: PodOutcome.Replay,
768
+ });
769
+ }
770
+
771
+ /**
772
+ * Parse RDF into the quad store, or offload an opaque/oversized body to R2 —
773
+ * without ever buffering a full blob in the DO.
774
+ *
775
+ * Routing keys off the declared `Content-Length`: a body known to fit the
776
+ * SQLite-cell ceiling is read into memory (bounded) and, if it is RDF, parsed
777
+ * into quads; anything larger is streamed straight to R2 as an opaque blob.
778
+ * When the length is undeclared we read only up to the ceiling to classify it;
779
+ * a body that overflows that probe cannot be sized to stream safely, so it is
780
+ * rejected (411) rather than risk buffering it whole.
781
+ */
782
+ async #writeBody(
783
+ store: Store,
784
+ origin: string,
785
+ path: string,
786
+ request: Request,
787
+ preconditions: Pick<WriteOptions, "ifMatch" | "ifNoneMatch" | "guard">,
788
+ ): Promise<void> {
789
+ const contentType =
790
+ request.headers.get("content-type")?.split(";")[0]?.trim() ||
791
+ (isContainer(path) ? "text/turtle" : "application/octet-stream");
792
+ const rdfFormat = formatForMediaType(contentType);
793
+ const declared = parseContentLength(request.headers.get("content-length"));
794
+
795
+ // Resolve the bytes to keep in memory (small bodies only) versus a body to
796
+ // stream straight to R2. The declared length only fast-paths the
797
+ // known-large case; for everything else we read the *actual* body up to the
798
+ // ceiling rather than trust the header, so a understated `Content-Length`
799
+ // cannot smuggle an oversized body into memory.
800
+ let inlineBytes: Uint8Array | null;
801
+ if (declared !== null && declared > store.maxInlineBytes) {
802
+ // Known-large: stream to R2, never resident in the DO.
803
+ inlineBytes = null;
804
+ } else {
805
+ // Small or undeclared: probe up to the ceiling, trusting nothing.
806
+ const peeked = await readUpToLimit(request.body, store.maxInlineBytes);
807
+ if (peeked.kind === "overflow") {
808
+ // Too big to hold and unsized to stream — demand a Content-Length.
809
+ throw new LengthRequiredError();
810
+ }
811
+ inlineBytes = peeked.bytes;
812
+ }
813
+
814
+ if (inlineBytes === null) {
815
+ // Oversized (binary or RDF): stream the body straight through to R2.
816
+ await store.putBlob(path, request.body ?? new Blob([]), {
817
+ contentType,
818
+ ...preconditions,
819
+ });
820
+ return;
821
+ }
822
+
823
+ if (!rdfFormat) {
824
+ // Small opaque body: already in hand, write it as a blob.
825
+ await store.putBlob(path, inlineBytes, { contentType, ...preconditions });
826
+ return;
827
+ }
828
+
829
+ const bodyText = new TextDecoder().decode(inlineBytes);
830
+ const quads = await parseRdf(bodyText, contentType, {
831
+ baseIRI: toIri(origin, path),
832
+ });
833
+ const stored = quads.map(quadToStored);
834
+ // A container's `ldp:contains` listing is server-managed; clients never
835
+ // send it, so a PUT that replaced all quads would orphan every child.
836
+ // Preserve existing containment (and re-assert the container types).
837
+ const containerIri = toIri(origin, path);
838
+ const preserved =
839
+ isContainer(path) && store.head(path) !== null
840
+ ? store.readQuads(path).filter((q) => isContainsQuad(q, containerIri))
841
+ : [];
842
+ const withType = isContainer(path)
843
+ ? [...stored, ...containerTypeQuads(containerIri), ...preserved]
844
+ : stored;
845
+ await store.putResource(path, inlineBytes, {
846
+ quads: withType,
847
+ contentType,
848
+ ...preconditions,
849
+ });
850
+ }
851
+
852
+ // -- containment -----------------------------------------------------------
853
+
854
+ /** Ensure every ancestor container of `key` exists and contains its child. */
855
+ #ensureContainerChain(store: Store, origin: string, key: string): void {
856
+ const parent = parentContainer(key);
857
+ if (parent === null) return;
858
+
859
+ // `parent` nearest-first up to the root container.
860
+ const chain = [parent, ...ancestorContainers(parent)];
861
+
862
+ // Create any missing containers, root-first.
863
+ for (const container of [...chain].reverse()) {
864
+ if (store.head(container) === null) {
865
+ store.writeQuads(
866
+ container,
867
+ containerTypeQuads(toIri(origin, container)),
868
+ {
869
+ contentType: "text/turtle",
870
+ },
871
+ );
872
+ }
873
+ }
874
+
875
+ // Link each container into its own parent, then the new resource into its
876
+ // immediate parent. `ldp:contains` inserts are idempotent in the store.
877
+ for (const container of chain) {
878
+ const grandparent = parentContainer(container);
879
+ if (grandparent !== null) {
880
+ this.#setContainment(store, origin, grandparent, container, true);
881
+ }
882
+ }
883
+ // Auxiliary resources (`.acl`/`.meta`) are not contained members per Solid;
884
+ // listing them would leak the existence/paths of ACL documents to anyone
885
+ // with container Read. Skip the containment triple for them.
886
+ if (!hasReservedAuxiliarySuffix(key)) {
887
+ this.#setContainment(store, origin, parent, key, true);
888
+ }
889
+ }
890
+
891
+ /** Drop `key` from its parent container's `ldp:contains` listing. */
892
+ #removeContainment(store: Store, origin: string, key: string): void {
893
+ const parent = parentContainer(key);
894
+ if (parent === null || store.head(parent) === null) return;
895
+ this.#setContainment(store, origin, parent, key, false);
896
+ }
897
+
898
+ #setContainment(
899
+ store: Store,
900
+ origin: string,
901
+ parent: string,
902
+ child: string,
903
+ present: boolean,
904
+ ): void {
905
+ const parentIri = toIri(origin, parent);
906
+ const childIri = toIri(origin, child);
907
+ const triple = iriQuad(parentIri, `${LDP}contains`, childIri);
908
+ if (present) {
909
+ store.patchQuads(
910
+ parent,
911
+ { deletes: [], inserts: [triple] },
912
+ { contentType: "text/turtle" },
913
+ );
914
+ } else {
915
+ store.patchQuads(
916
+ parent,
917
+ { deletes: [triple], inserts: [] },
918
+ { contentType: "text/turtle" },
919
+ );
920
+ }
921
+ }
922
+
923
+ // -- notifications ---------------------------------------------------------
924
+
925
+ /**
926
+ * Accept a hibernatable WebSocket subscription. v1 channels carry only the
927
+ * changed resource IRI (no body), and are not WAC-filtered per subscriber —
928
+ * a deliberate, documented simplification.
929
+ */
930
+ #handleWebSocketUpgrade(): Response {
931
+ const pair = new WebSocketPair();
932
+ const [client, server] = [pair[0], pair[1]];
933
+ this.ctx.acceptWebSocket(server);
934
+ return new Response(null, { status: 101, webSocket: client });
935
+ }
936
+
937
+ override async webSocketMessage(
938
+ ws: WebSocket,
939
+ message: string | ArrayBuffer,
940
+ ): Promise<void> {
941
+ // Subscriptions are connection-scoped; a ping/keepalive is echoed, anything
942
+ // else is acknowledged so clients can confirm the channel is live.
943
+ if (typeof message === "string" && message === "ping") ws.send("pong");
944
+ }
945
+
946
+ // No `webSocketClose` override: the runtime closes the hibernatable socket
947
+ // itself, and calling `ws.close()` here throws on reserved codes (1006).
948
+
949
+ /** Fan a change notification out to every connected subscriber. */
950
+ #broadcast(objectIri: string, type: ChangeType): void {
951
+ const notification = JSON.stringify({
952
+ "@context": ACTIVITYSTREAMS,
953
+ type,
954
+ object: objectIri,
955
+ published: new Date().toISOString(),
956
+ });
957
+ for (const ws of this.ctx.getWebSockets()) {
958
+ try {
959
+ ws.send(notification);
960
+ } catch {
961
+ // A socket that is gone mid-broadcast is harmless; skip it.
962
+ }
963
+ }
964
+ }
965
+ }
966
+
967
+ // ---------------------------------------------------------------------------
968
+ // Module-level helpers
969
+ // ---------------------------------------------------------------------------
970
+
971
+ const ALLOW = "GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE";
972
+
973
+ // TODO(`#server-delete-protect-root-container`): the root storage/container
974
+ // MUST NOT be deletable, so its advertised `Allow` should omit DELETE. We do
975
+ // not yet model "is the storage root" here (paths reach the DO already
976
+ // stripped of any mount prefix), so the method list is uniform for now.
977
+
978
+ /**
979
+ * Concrete RDF media types a container `POST` accepts, advertised on `OPTIONS`.
980
+ * A bare catch-all wildcard is uninformative; listing the guaranteed
981
+ * serializations (plus the wildcard for opaque bodies) lets clients pick a body
982
+ * format they can produce.
983
+ */
984
+ const ACCEPT_POST = "text/turtle, application/ld+json, */*";
985
+
986
+ /**
987
+ * Whether a stored representation's `etag` satisfies an `If-None-Match` header
988
+ * per RFC 7232 §3.2. `*` matches any current representation; otherwise the
989
+ * header is a comma-separated list of entity-tags compared with the *weak*
990
+ * comparison function — the `W/` prefix is ignored, so `W/"x"`, `"x"`, and a
991
+ * list like `"a", "x"` all match the stored `"x"`.
992
+ */
993
+ function ifNoneMatchSatisfied(header: string, etag: string): boolean {
994
+ if (header.trim() === "*") return true;
995
+ const opaque = (tag: string) => tag.trim().replace(/^W\//, "");
996
+ const target = opaque(etag);
997
+ return header.split(",").some((tag) => {
998
+ const candidate = opaque(tag);
999
+ return candidate.length > 0 && candidate === target;
1000
+ });
1001
+ }
1002
+
1003
+ /** The outcome of {@link readUpToLimit}: a fully-buffered body or an overflow signal. */
1004
+ type PeekedBody =
1005
+ | { readonly kind: "buffered"; readonly bytes: Uint8Array }
1006
+ | { readonly kind: "overflow" };
1007
+
1008
+ /** Concatenate read chunks into one `Uint8Array` of `total` bytes. */
1009
+ function concatChunks(
1010
+ chunks: readonly Uint8Array[],
1011
+ total: number,
1012
+ ): Uint8Array {
1013
+ const out = new Uint8Array(total);
1014
+ let offset = 0;
1015
+ for (const chunk of chunks) {
1016
+ out.set(chunk, offset);
1017
+ offset += chunk.byteLength;
1018
+ }
1019
+ return out;
1020
+ }
1021
+
1022
+ /**
1023
+ * Parse a `Content-Length` header into a non-negative integer, or `null` when
1024
+ * it is absent or malformed (treated as "length unknown").
1025
+ */
1026
+ function parseContentLength(header: string | null): number | null {
1027
+ // RFC 9110: Content-Length is 1*DIGIT. Reject anything `Number()` would coerce
1028
+ // loosely (whitespace, "", "0x10", "1e3", signs); a value past safe-integer
1029
+ // range is treated as undeclared so the bounded probe still backstops it.
1030
+ if (header === null || !/^\d+$/.test(header)) return null;
1031
+ const value = Number(header);
1032
+ return Number.isSafeInteger(value) ? value : null;
1033
+ }
1034
+
1035
+ /**
1036
+ * Read at most `limit` bytes from `body` to classify an undeclared-length body
1037
+ * without buffering an oversized one. Returns the buffered bytes if the body
1038
+ * ends within `limit`; signals `overflow` (cancelling the body) the moment it
1039
+ * exceeds `limit`. A body of exactly `limit` bytes still fits the cell.
1040
+ */
1041
+ async function readUpToLimit(
1042
+ body: ReadableStream<Uint8Array> | null,
1043
+ limit: number,
1044
+ ): Promise<PeekedBody> {
1045
+ if (!body) return { kind: "buffered", bytes: new Uint8Array(0) };
1046
+ const reader = body.getReader();
1047
+ const chunks: Uint8Array[] = [];
1048
+ let total = 0;
1049
+ for (;;) {
1050
+ const { done, value } = await reader.read();
1051
+ if (done) return { kind: "buffered", bytes: concatChunks(chunks, total) };
1052
+ chunks.push(value);
1053
+ total += value.byteLength;
1054
+ if (total > limit) {
1055
+ await reader.cancel();
1056
+ return { kind: "overflow" };
1057
+ }
1058
+ }
1059
+ }
1060
+
1061
+ /** Thrown from a delete guard to abort the transaction for a non-empty LDP container. */
1062
+ class ContainerNotEmptyError extends Error {}
1063
+
1064
+ /** Thrown by `#writeBody` when an unsized body is too large to buffer or stream (→ 411). */
1065
+ class LengthRequiredError extends Error {}
1066
+
1067
+ /**
1068
+ * Thrown from a write's replay guard when its DPoP `jti` was already seen. It
1069
+ * propagates out of the store's write `transactionSync`, rolling the write back
1070
+ * with it, and is mapped to a `401` by {@link SolidPodObject.fetch}.
1071
+ */
1072
+ class DpopReplayError extends Error {}
1073
+
1074
+ /**
1075
+ * Render a `WAC-Allow` value from the granted-mode sets (WAC §5.3.5):
1076
+ * `user="read write …",public="…"`. `write` implies `append`, so an `append`
1077
+ * token is emitted whenever `write` is granted. Each group is always quoted,
1078
+ * empty or not, in a stable mode order.
1079
+ */
1080
+ function wacAllowHeader(
1081
+ userModes: ReadonlySet<AccessMode>,
1082
+ publicModes: ReadonlySet<AccessMode>,
1083
+ ): string {
1084
+ return `user="${formatModes(userModes)}",public="${formatModes(publicModes)}"`;
1085
+ }
1086
+
1087
+ /** Order and stringify a mode set, expanding the `write ⇒ append` implication. */
1088
+ function formatModes(modes: ReadonlySet<AccessMode>): string {
1089
+ const effective = new Set<AccessMode>(modes);
1090
+ if (effective.has("write")) effective.add("append");
1091
+ const order: AccessMode[] = ["read", "write", "append", "control"];
1092
+ return order.filter((m) => effective.has(m)).join(" ");
1093
+ }
1094
+
1095
+ function baseHeaders(
1096
+ path: string,
1097
+ etag: string,
1098
+ contentType: string,
1099
+ inboxIris: readonly string[] = [],
1100
+ ): Headers {
1101
+ const headers = new Headers({
1102
+ etag,
1103
+ "content-type": contentType,
1104
+ "accept-patch": "text/n3, application/sparql-update",
1105
+ // Solid `#server-allow-methods`: successful responses advertise the methods
1106
+ // the resource supports, the same list used by OPTIONS and the 405 path.
1107
+ allow: ALLOW,
1108
+ });
1109
+ const links = [
1110
+ isContainer(path)
1111
+ ? `<${LDP}BasicContainer>; rel="type", <${LDP}Resource>; rel="type"`
1112
+ : `<${LDP}Resource>; rel="type"`,
1113
+ ...inboxIris.map(inboxLinkHeader),
1114
+ ];
1115
+ headers.set("link", links.join(", "));
1116
+ return headers;
1117
+ }
1118
+
1119
+ /** The `rdf:type` triples that make `iri` an LDP basic container. */
1120
+ function containerTypeQuads(iri: string): StoredQuad[] {
1121
+ return [
1122
+ iriQuad(iri, RDF_TYPE, `${LDP}Resource`),
1123
+ iriQuad(iri, RDF_TYPE, `${LDP}Container`),
1124
+ iriQuad(iri, RDF_TYPE, `${LDP}BasicContainer`),
1125
+ ];
1126
+ }
1127
+
1128
+ function ifMatchOf(request: Request): string | undefined {
1129
+ return request.headers.get("if-match") ?? undefined;
1130
+ }
1131
+
1132
+ function ifNoneMatchOf(request: Request): string | undefined {
1133
+ return request.headers.get("if-none-match") ?? undefined;
1134
+ }
1135
+
1136
+ /** Whether a `Link` header marks the POSTed resource as an LDP container. */
1137
+ function linkIndicatesContainer(link: string | null): boolean {
1138
+ if (!link) return false;
1139
+ // Split into individual link-values at each "," that introduces a new
1140
+ // "<uri>", then require the container type URI and a `rel="type"` parameter
1141
+ // to appear in the *same* entry. Checking the header as a whole would let a
1142
+ // stray `rel="type"` on one link combine with an unrelated container URI on
1143
+ // another to produce a false positive.
1144
+ for (const entry of link.split(/,(?=\s*<)/)) {
1145
+ const start = entry.indexOf("<");
1146
+ const end = entry.indexOf(">", start + 1);
1147
+ if (start === -1 || end === -1) continue;
1148
+ const uri = entry.slice(start + 1, end).trim();
1149
+ if (uri !== `${LDP}Container` && uri !== `${LDP}BasicContainer`) {
1150
+ continue;
1151
+ }
1152
+ if (linkEntryRelIsType(entry.slice(end + 1))) return true;
1153
+ }
1154
+ return false;
1155
+ }
1156
+
1157
+ /**
1158
+ * Whether a link-value's parameter string declares `rel="type"`. Parameters are
1159
+ * split on top-level `;` (respecting quoted values) before matching, so a
1160
+ * `rel=type` substring inside another parameter's quoted value (e.g.
1161
+ * `title="x; rel=type"`) is not mistaken for the rel parameter. `rel` is a
1162
+ * space-separated token list, so `rel="type other"` also counts.
1163
+ */
1164
+ function linkEntryRelIsType(params: string): boolean {
1165
+ for (const param of splitLinkParams(params)) {
1166
+ const match = /^\s*rel\s*=\s*("([^"]*)"|'([^']*)'|[^;\s]+)\s*$/i.exec(
1167
+ param,
1168
+ );
1169
+ if (match === null) continue;
1170
+ const value = match[2] ?? match[3] ?? match[1] ?? "";
1171
+ if (value.toLowerCase().split(/\s+/).includes("type")) return true;
1172
+ }
1173
+ return false;
1174
+ }
1175
+
1176
+ /** Split a link-value parameter string on top-level `;`, respecting quotes. */
1177
+ function splitLinkParams(params: string): string[] {
1178
+ const parts: string[] = [];
1179
+ let inQuotes = false;
1180
+ let current = "";
1181
+ for (let i = 0; i < params.length; i++) {
1182
+ const char = params[i] as string;
1183
+ if (char === '"' && params[i - 1] !== "\\") {
1184
+ inQuotes = !inQuotes;
1185
+ }
1186
+ if (char === ";" && !inQuotes) {
1187
+ parts.push(current);
1188
+ current = "";
1189
+ } else {
1190
+ current += char;
1191
+ }
1192
+ }
1193
+ if (current.trim() !== "") parts.push(current);
1194
+ return parts;
1195
+ }