@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/config.ts ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Configuration, the declared Cloudflare `Env` fragment, and config resolution
3
+ * for `@dwk/solid-pod`.
4
+ *
5
+ * Per the composition contract, the package never reads the global environment
6
+ * directly: all tunables (base URL, token issuer/JWKS, accepted audience,
7
+ * offload threshold, replay/GC windows) are passed into {@link createSolidPod}
8
+ * and {@link createSolidPodGc}, so a pod can be instantiated multiple times and
9
+ * unit-tested in isolation. The Cloudflare bindings — the per-pod Durable
10
+ * Object namespace and the R2 blob bucket — are the only runtime coupling, and
11
+ * a missing one fails loudly at startup.
12
+ */
13
+
14
+ import { noopLogger, noopMetrics, type Logger, type Metrics } from "@dwk/log";
15
+
16
+ import type { SolidPodObject } from "./pod";
17
+
18
+ /** Cloudflare bindings required by the Solid Pod handler and Durable Object. */
19
+ export interface SolidPodEnv {
20
+ /** Durable Object namespace for the per-pod class ({@link SolidPodObject}). */
21
+ readonly POD: DurableObjectNamespace<SolidPodObject>;
22
+ /** R2 bucket holding blob bodies. */
23
+ readonly BLOBS: R2Bucket;
24
+ /**
25
+ * Shared D1 database tracking orphaned blob keys for the out-of-band GC cron.
26
+ * Optional: when bound, the DO opportunistically forwards its transactional
27
+ * orphan outbox here after writes so {@link createSolidPodGc} can reclaim them
28
+ * without ever waking a Durable Object.
29
+ */
30
+ readonly GC_DB?: D1Database;
31
+ }
32
+
33
+ /** Cloudflare bindings required by the out-of-band R2 garbage-collection cron. */
34
+ export interface SolidPodGcEnv {
35
+ /** R2 bucket holding blob bodies. */
36
+ readonly BLOBS: R2Bucket;
37
+ /** Shared D1 database tracking orphaned blob keys (see `@dwk/store`). */
38
+ readonly GC_DB: D1Database;
39
+ }
40
+
41
+ /**
42
+ * A verification key set used to validate issuer-signed access tokens. Supply
43
+ * either static {@link SolidPodConfig.jwks} (hermetic, no network) or a
44
+ * {@link SolidPodConfig.jwksUri} the handler fetches and caches.
45
+ */
46
+ export type Jwks = readonly JsonWebKey[];
47
+
48
+ /** Configuration passed to {@link createSolidPod}. */
49
+ export interface SolidPodConfig {
50
+ /**
51
+ * The pod's identity root / base URL, e.g. `https://pod.example`. Used as the
52
+ * origin for absolute resource IRIs in WAC and content negotiation, and as
53
+ * the default token audience. No trailing slash.
54
+ */
55
+ readonly baseUrl: string;
56
+
57
+ /**
58
+ * The pod owner's WebID(s). An owner is always granted full access
59
+ * (`Read`/`Write`/`Append`/`Control`) regardless of ACLs, which bootstraps
60
+ * ACL management — without it, no one could create the first `.acl`.
61
+ */
62
+ readonly owner?: string | readonly string[];
63
+
64
+ /**
65
+ * Accepted access-token issuer (`iss`). When set, a token whose `iss` differs
66
+ * is rejected. Required unless a custom {@link SolidPodConfig.authenticate}
67
+ * hook is supplied.
68
+ */
69
+ readonly issuer?: string;
70
+
71
+ /**
72
+ * Accepted token audience(s) (`aud`). A token is accepted when its `aud`
73
+ * intersects this set. Defaults to `["solid", baseUrl]`.
74
+ */
75
+ readonly audience?: string | readonly string[];
76
+
77
+ /**
78
+ * Required access-token header `typ`. Solid-OIDC / RFC 9068 access tokens
79
+ * carry `typ: at+jwt`; enforcing it stops an ID token or other issuer-signed
80
+ * JWT sharing the same `iss`/`aud`/`webid` from being replayed as an access
81
+ * token (token-type confusion). Compared case-insensitively, tolerating the
82
+ * `application/at+jwt` media-type form. Set to `null` to skip the check for
83
+ * issuers that omit `typ`. Defaults to `"at+jwt"`.
84
+ */
85
+ readonly accessTokenType?: string | null;
86
+
87
+ /** Static JWK verification keys for the issuer (hermetic alternative to {@link jwksUri}). */
88
+ readonly jwks?: Jwks;
89
+
90
+ /** Issuer JWKS endpoint; fetched and cached when {@link jwks} is not given. */
91
+ readonly jwksUri?: string;
92
+
93
+ /**
94
+ * Bodies larger than this (bytes) are offloaded to R2 as opaque blobs instead
95
+ * of the DO SQLite quad store. Defaults to the ~2 MB DO-cell ceiling.
96
+ */
97
+ readonly maxInlineBytes?: number;
98
+
99
+ /**
100
+ * The documented read-replay tradeoff: reads MAY reuse a DPoP proof within
101
+ * this many seconds (edge-cached window) rather than enforcing strict
102
+ * single-use `jti`. Writes are always strict. Defaults to `0` (strict reads).
103
+ */
104
+ readonly readReplayWindowSeconds?: number;
105
+
106
+ /**
107
+ * Allow **unauthenticated** writes (`PUT`/`POST`/`PATCH`/`DELETE`) when WAC
108
+ * grants the public agent class (`acl:agentClass foaf:Agent`) the needed
109
+ * mode. Such requests carry no DPoP proof, so they get **no `jti` replay /
110
+ * anti-abuse protection** — the "DPoP everywhere" guarantee does not hold for
111
+ * them. Defaults to `false`: a tokenless write is refused `401` even where a
112
+ * public-write ACL would otherwise permit it. Set `true` to opt into
113
+ * public write as an explicit, documented tradeoff.
114
+ */
115
+ readonly allowAnonymousWrites?: boolean;
116
+
117
+ /**
118
+ * GC safety window (ms) advertised to the cron handler; an orphaned R2 object
119
+ * is only reclaimed once it is older than this. MUST be ≥ the maximum write
120
+ * duration. Defaults to five minutes.
121
+ */
122
+ readonly gcSafetyWindowMs?: number;
123
+
124
+ /** Injectable clock (epoch ms) for deterministic tests. Defaults to `Date.now`. */
125
+ readonly now?: () => number;
126
+
127
+ /** `fetch` implementation used to resolve {@link jwksUri}; defaults to global `fetch`. */
128
+ readonly fetch?: typeof fetch;
129
+
130
+ /**
131
+ * Override the edge authentication step entirely. When provided, the handler
132
+ * calls this instead of the built-in JWKS + DPoP validation — useful for
133
+ * tests and for issuers the default verifier does not cover. Returning
134
+ * `null` means "no/invalid credentials" and the request proceeds
135
+ * unauthenticated (WAC then decides public access).
136
+ */
137
+ readonly authenticate?: (
138
+ request: Request,
139
+ ) => Promise<AuthContext | null> | AuthContext | null;
140
+
141
+ /**
142
+ * Logger for auth/authz events; defaults to a no-op. Wired once here at the
143
+ * composition boundary (see `@dwk/log`) to surface edge-authentication
144
+ * rejections and the Durable Object's WAC denials, anonymous-write refusals,
145
+ * and DPoP replay rejections instead of swallowing them.
146
+ */
147
+ readonly logger?: Logger;
148
+ /**
149
+ * Metrics sink for the same events; defaults to a no-op. Wire an adapter (e.g.
150
+ * `analyticsEngineMetrics` from `@dwk/log`) to chart what the logger names —
151
+ * auth rejections by reason, WAC denials/min, replay rejections.
152
+ */
153
+ readonly metrics?: Metrics;
154
+ }
155
+
156
+ /** The authenticated facts the front door hands to the Durable Object. */
157
+ export interface AuthContext {
158
+ /** The authenticated agent's WebID. */
159
+ readonly webid: string;
160
+ /** The verified DPoP proof's `jti`, for write replay enforcement in the DO. */
161
+ readonly jti: string;
162
+ /** The DPoP key thumbprint the token is bound to (`cnf.jkt`). */
163
+ readonly jkt: string;
164
+ }
165
+
166
+ /** Fully-resolved configuration with defaults applied. */
167
+ export interface ResolvedConfig {
168
+ readonly baseUrl: string;
169
+ readonly origin: string;
170
+ readonly owners: readonly string[];
171
+ readonly issuer?: string;
172
+ readonly audience: readonly string[];
173
+ readonly accessTokenType: string | null;
174
+ readonly jwks?: Jwks;
175
+ readonly jwksUri?: string;
176
+ readonly maxInlineBytes?: number;
177
+ readonly readReplayWindowSeconds: number;
178
+ readonly allowAnonymousWrites: boolean;
179
+ readonly gcSafetyWindowMs: number;
180
+ readonly now: () => number;
181
+ readonly fetch: typeof fetch;
182
+ readonly authenticate?: SolidPodConfig["authenticate"];
183
+ readonly logger: Logger;
184
+ readonly metrics: Metrics;
185
+ }
186
+
187
+ /** Default GC safety window: five minutes, comfortably above any write. */
188
+ const DEFAULT_GC_SAFETY_WINDOW_MS = 5 * 60 * 1000;
189
+
190
+ /** Internal headers the trusted front door uses to hand auth facts to the DO. */
191
+ export const INTERNAL_HEADERS = {
192
+ /** Authenticated agent WebID (absent ⇒ unauthenticated request). */
193
+ webid: "x-solid-webid",
194
+ /** Verified DPoP `jti` (present on authenticated writes for replay control). */
195
+ jti: "x-solid-jti",
196
+ /** Verified DPoP key thumbprint (`cnf.jkt`). */
197
+ jkt: "x-solid-jkt",
198
+ /** JSON-encoded subset of config the DO needs (offload threshold, etc.). */
199
+ config: "x-solid-config",
200
+ /**
201
+ * DO→front-door: a machine-readable authorization outcome (see `PodOutcome`)
202
+ * the composition boundary logs via the injected seams, then strips before the
203
+ * response reaches the client.
204
+ */
205
+ outcome: "x-solid-outcome",
206
+ } as const;
207
+
208
+ /** Strip the trailing slash from a base URL so path joins are unambiguous. */
209
+ function normalizeBaseUrl(baseUrl: string): string {
210
+ return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
211
+ }
212
+
213
+ /** Apply defaults and derived values to raw {@link SolidPodConfig}. */
214
+ export function resolveConfig(config: SolidPodConfig): ResolvedConfig {
215
+ if (!config.baseUrl) {
216
+ throw new Error("@dwk/solid-pod: `baseUrl` is required");
217
+ }
218
+ const baseUrl = normalizeBaseUrl(config.baseUrl);
219
+ const origin = new URL(baseUrl).origin;
220
+ const audience =
221
+ config.audience === undefined
222
+ ? ["solid", baseUrl]
223
+ : typeof config.audience === "string"
224
+ ? [config.audience]
225
+ : [...config.audience];
226
+
227
+ const owners =
228
+ config.owner === undefined
229
+ ? []
230
+ : typeof config.owner === "string"
231
+ ? [config.owner]
232
+ : [...config.owner];
233
+
234
+ return {
235
+ baseUrl,
236
+ origin,
237
+ owners,
238
+ issuer: config.issuer,
239
+ audience,
240
+ accessTokenType:
241
+ config.accessTokenType === undefined ? "at+jwt" : config.accessTokenType,
242
+ jwks: config.jwks,
243
+ jwksUri: config.jwksUri,
244
+ maxInlineBytes: config.maxInlineBytes,
245
+ readReplayWindowSeconds: config.readReplayWindowSeconds ?? 0,
246
+ allowAnonymousWrites: config.allowAnonymousWrites ?? false,
247
+ gcSafetyWindowMs: config.gcSafetyWindowMs ?? DEFAULT_GC_SAFETY_WINDOW_MS,
248
+ now: config.now ?? (() => Date.now()),
249
+ fetch: config.fetch ?? fetch,
250
+ authenticate: config.authenticate,
251
+ logger: config.logger ?? noopLogger,
252
+ metrics: config.metrics ?? noopMetrics,
253
+ };
254
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * base64url + UTF-8 helpers for the edge auth path (JWT/JWKS, DPoP hashing).
3
+ *
4
+ * Dependency-free and runtime-agnostic (Web Crypto / `atob` / `btoa` only) so
5
+ * the surrounding modules unit-test without a Workers runtime.
6
+ */
7
+
8
+ /** Encode bytes as unpadded base64url (RFC 4648 §5). */
9
+ export function bytesToBase64url(bytes: Uint8Array): string {
10
+ let binary = "";
11
+ for (const byte of bytes) binary += String.fromCharCode(byte);
12
+ return btoa(binary)
13
+ .replace(/\+/g, "-")
14
+ .replace(/\//g, "_")
15
+ .replace(/=+$/, "");
16
+ }
17
+
18
+ /** Decode unpadded (or padded) base64url to bytes. */
19
+ export function base64urlToBytes(segment: string): Uint8Array {
20
+ const b64 = segment.replace(/-/g, "+").replace(/_/g, "/");
21
+ const padded =
22
+ b64.length % 4 === 0 ? b64 : b64 + "=".repeat(4 - (b64.length % 4));
23
+ const binary = atob(padded);
24
+ const bytes = new Uint8Array(binary.length);
25
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
26
+ return bytes;
27
+ }
28
+
29
+ /** Decode unpadded base64url to a UTF-8 string. */
30
+ export function base64urlToText(segment: string): string {
31
+ return new TextDecoder().decode(base64urlToBytes(segment));
32
+ }
package/src/gc.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The R2 garbage-collection cron handler.
3
+ *
4
+ * Each pod's Durable Object forwards its orphaned blob keys (copy-on-write
5
+ * displacements and deletes) into a shared D1 table as it writes. This handler
6
+ * — wired to a Cloudflare cron trigger — reclaims those R2 objects once they
7
+ * are older than a safety window ≥ the maximum write duration, touching only D1
8
+ * and R2 and never waking a Durable Object. The reclamation logic lives in
9
+ * `@dwk/store`; this is the thin endpoint wiring.
10
+ */
11
+
12
+ import { collectGarbage, ensureGcSchema } from "@dwk/store";
13
+
14
+ import {
15
+ resolveConfig,
16
+ type SolidPodConfig,
17
+ type SolidPodGcEnv,
18
+ } from "./config";
19
+
20
+ /** A `scheduled`-compatible cron handler for R2 blob garbage collection. */
21
+ export type SolidPodGcHandler = (
22
+ event: ScheduledController,
23
+ env: SolidPodGcEnv,
24
+ ctx: ExecutionContext,
25
+ ) => Promise<void>;
26
+
27
+ /**
28
+ * Create the cron handler that reclaims orphaned R2 blobs. Bind it to a
29
+ * `scheduled` trigger alongside the pod Worker; it shares the same `BLOBS`
30
+ * bucket and the `GC_DB` D1 database the DO forwards orphans into.
31
+ *
32
+ * Fails loudly when the required `BLOBS` / `GC_DB` bindings are missing.
33
+ */
34
+ export function createSolidPodGc(config: SolidPodConfig): SolidPodGcHandler {
35
+ const resolved = resolveConfig(config);
36
+
37
+ return async (_event, env, _ctx) => {
38
+ if (!env.BLOBS) {
39
+ throw new Error("@dwk/solid-pod: GC requires the `BLOBS` R2 binding");
40
+ }
41
+ if (!env.GC_DB) {
42
+ throw new Error("@dwk/solid-pod: GC requires the `GC_DB` D1 binding");
43
+ }
44
+ await ensureGcSchema(env.GC_DB);
45
+ await collectGarbage(env, { safetyWindowMs: resolved.gcSafetyWindowMs });
46
+ };
47
+ }
package/src/handler.ts ADDED
@@ -0,0 +1,199 @@
1
+ /**
2
+ * The stateless Solid Pod front door.
3
+ *
4
+ * It authenticates DPoP-bound bearer tokens at the edge (`auth.ts`), then hands
5
+ * the request — augmented with the verified agent facts via internal headers —
6
+ * to the per-pod Durable Object, which owns all consistency, authorization, and
7
+ * notification logic. v1 runs a single Durable Object per `baseUrl` (no
8
+ * sharding). The handler is mountable under any path prefix because it routes
9
+ * purely on the request URL.
10
+ */
11
+
12
+ import { hostFromUrl, type LogFields } from "@dwk/log";
13
+
14
+ import {
15
+ INTERNAL_HEADERS,
16
+ resolveConfig,
17
+ type ResolvedConfig,
18
+ type SolidPodConfig,
19
+ type SolidPodEnv,
20
+ } from "./config";
21
+ import { authenticate } from "./auth";
22
+ import { PodOutcome, SolidPodLogEvent } from "./log";
23
+
24
+ /** A `fetch`-compatible Worker handler. */
25
+ export type SolidPodHandler = (
26
+ request: Request,
27
+ env: SolidPodEnv,
28
+ ctx: ExecutionContext,
29
+ ) => Promise<Response>;
30
+
31
+ /** Client headers that are safe to forward verbatim to the Durable Object. */
32
+ const FORWARDED_HEADERS = [
33
+ "accept",
34
+ "content-type",
35
+ "content-length",
36
+ "if-match",
37
+ "if-none-match",
38
+ "slug",
39
+ "link",
40
+ "origin",
41
+ "upgrade",
42
+ "sec-websocket-key",
43
+ "sec-websocket-version",
44
+ "sec-websocket-protocol",
45
+ "sec-websocket-extensions",
46
+ ] as const;
47
+
48
+ /** Fail loudly if a required Cloudflare binding is missing (no silent degradation). */
49
+ function assertBindings(env: SolidPodEnv): void {
50
+ if (!env.POD) {
51
+ throw new Error(
52
+ "@dwk/solid-pod: missing required Durable Object binding `POD`",
53
+ );
54
+ }
55
+ if (!env.BLOBS) {
56
+ throw new Error("@dwk/solid-pod: missing required R2 binding `BLOBS`");
57
+ }
58
+ }
59
+
60
+ /** Build the internal DO request, stripping any client-supplied auth headers. */
61
+ function internalRequest(
62
+ request: Request,
63
+ config: ResolvedConfig,
64
+ auth: { webid: string; jti: string; jkt: string } | null,
65
+ ): Request {
66
+ const headers = new Headers();
67
+ for (const name of FORWARDED_HEADERS) {
68
+ const value = request.headers.get(name);
69
+ if (value !== null) headers.set(name, value);
70
+ }
71
+ // The DO trusts these headers, so they are always set by us — never passed
72
+ // through from the client.
73
+ if (auth) {
74
+ headers.set(INTERNAL_HEADERS.webid, auth.webid);
75
+ headers.set(INTERNAL_HEADERS.jti, auth.jti);
76
+ headers.set(INTERNAL_HEADERS.jkt, auth.jkt);
77
+ }
78
+ headers.set(
79
+ INTERNAL_HEADERS.config,
80
+ JSON.stringify({
81
+ owners: config.owners,
82
+ allowAnonymousWrites: config.allowAnonymousWrites,
83
+ ...(config.maxInlineBytes !== undefined
84
+ ? { maxInlineBytes: config.maxInlineBytes }
85
+ : {}),
86
+ }),
87
+ );
88
+
89
+ const method = request.method.toUpperCase();
90
+ const hasBody = method !== "GET" && method !== "HEAD";
91
+ return new Request(request.url, {
92
+ method: request.method,
93
+ headers,
94
+ ...(hasBody ? { body: request.body } : {}),
95
+ });
96
+ }
97
+
98
+ /** A `401` with the DPoP challenge and a machine-readable failure reason. */
99
+ function unauthorized(reason: string): Response {
100
+ return new Response("Unauthorized", {
101
+ status: 401,
102
+ headers: {
103
+ "content-type": "text/plain; charset=utf-8",
104
+ "www-authenticate": `DPoP realm="solid", error="invalid_token", error_description="${reason}"`,
105
+ },
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Emit a structured event on both the logger and the metrics seam, which share
111
+ * one event vocabulary (see `@dwk/log`): `warn` for handled-but-notable
112
+ * rejections, `info` for normal outcomes. Honors the redaction policy — callers
113
+ * pass only reason codes, HTTP method/status, and sanitized hosts.
114
+ */
115
+ function emit(
116
+ config: ResolvedConfig,
117
+ level: "info" | "warn",
118
+ event: string,
119
+ fields?: LogFields,
120
+ ): void {
121
+ config.logger[level](event, fields);
122
+ config.metrics.count(event, fields);
123
+ }
124
+
125
+ /**
126
+ * Translate the Durable Object's internal authorization-outcome header into the
127
+ * matching {@link SolidPodLogEvent} on the injected seams, then strip the header
128
+ * before the response reaches the client. The DO cannot hold the injected
129
+ * logger/metrics (they do not cross the isolate boundary), so it signals its WAC
130
+ * denial / anonymous-write refusal / replay rejection here, at the composition
131
+ * boundary, where the seams are wired. Responses without the header (including
132
+ * WebSocket upgrades) pass through untouched.
133
+ */
134
+ function logPodOutcome(
135
+ config: ResolvedConfig,
136
+ request: Request,
137
+ response: Response,
138
+ ): Response {
139
+ const outcome = response.headers.get(INTERNAL_HEADERS.outcome);
140
+ if (!outcome) return response;
141
+
142
+ const method = request.method;
143
+ switch (outcome) {
144
+ case PodOutcome.WacDenied:
145
+ emit(config, "warn", SolidPodLogEvent.AccessDenied, {
146
+ method,
147
+ status: response.status,
148
+ });
149
+ break;
150
+ case PodOutcome.AnonymousWriteRefused:
151
+ emit(config, "warn", SolidPodLogEvent.AnonymousWriteRefused, { method });
152
+ break;
153
+ case PodOutcome.Replay:
154
+ emit(config, "warn", SolidPodLogEvent.ReplayRejected, { method });
155
+ break;
156
+ }
157
+
158
+ const headers = new Headers(response.headers);
159
+ headers.delete(INTERNAL_HEADERS.outcome);
160
+ return new Response(response.body, {
161
+ status: response.status,
162
+ statusText: response.statusText,
163
+ headers,
164
+ });
165
+ }
166
+
167
+ /**
168
+ * Create the stateless Solid Pod front-door handler. Writes funnel through the
169
+ * per-pod {@link SolidPodObject}; reads and notifications likewise route through
170
+ * it so the DO remains the single consistency authority.
171
+ */
172
+ export function createSolidPod(config: SolidPodConfig): SolidPodHandler {
173
+ const resolved = resolveConfig(config);
174
+
175
+ return async (request, env, _ctx) => {
176
+ assertBindings(env);
177
+
178
+ const result = await authenticate(request, resolved);
179
+ if (result.kind === "rejected") {
180
+ emit(resolved, "warn", SolidPodLogEvent.AuthRejected, {
181
+ reason: result.reason,
182
+ });
183
+ return unauthorized(result.reason);
184
+ }
185
+
186
+ const auth = result.kind === "authenticated" ? result.context : null;
187
+ if (auth) {
188
+ emit(resolved, "info", SolidPodLogEvent.AuthAccepted, {
189
+ agentHost: hostFromUrl(auth.webid),
190
+ });
191
+ }
192
+ const forwarded = internalRequest(request, resolved, auth);
193
+
194
+ // One Durable Object per pod, keyed by the identity root (no sharding).
195
+ const id = env.POD.idFromName(resolved.baseUrl);
196
+ const response = await env.POD.get(id).fetch(forwarded);
197
+ return logPodOutcome(resolved, request, response);
198
+ };
199
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `@dwk/solid-pod` — edge-native Solid Pod.
3
+ *
4
+ * A stateless Worker front door over a per-pod Durable Object that is the
5
+ * consistency, authz, and notification authority, with R2 for blob bodies. This
6
+ * is the only `@dwk` package that ships a Durable Object.
7
+ *
8
+ * The package is **not** protocol-agnostic: it is the Solid Protocol endpoint,
9
+ * composing the reusable libs `@dwk/dpop` (edge DPoP validation), `@dwk/rdf`
10
+ * (Turtle/JSON-LD content negotiation), `@dwk/wac` (access control), and
11
+ * `@dwk/store` (DO-SQLite quads + R2 copy-on-write blobs). v1 is a **Resource
12
+ * Server only** (no OIDC OP) and runs **one Durable Object per pod** (no
13
+ * sharding).
14
+ *
15
+ * @see spec/packages/solid-pod.md
16
+ * @packageDocumentation
17
+ */
18
+
19
+ export { createSolidPod, type SolidPodHandler } from "./handler";
20
+ export { createSolidPodGc, type SolidPodGcHandler } from "./gc";
21
+ export { SolidPodObject } from "./pod";
22
+
23
+ export {
24
+ type SolidPodConfig,
25
+ type SolidPodEnv,
26
+ type SolidPodGcEnv,
27
+ type AuthContext,
28
+ type Jwks,
29
+ } from "./config";
30
+
31
+ export { SolidPodLogEvent } from "./log";
32
+ export type { Logger, Metrics } from "@dwk/log";