@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.
- package/LICENSE +15 -0
- package/README.md +108 -0
- package/dist/auth.d.ts +33 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +160 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +181 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +74 -0
- package/dist/config.js.map +1 -0
- package/dist/encoding.d.ts +13 -0
- package/dist/encoding.d.ts.map +1 -0
- package/dist/encoding.js +31 -0
- package/dist/encoding.js.map +1 -0
- package/dist/gc.d.ts +22 -0
- package/dist/gc.d.ts.map +1 -0
- package/dist/gc.js +33 -0
- package/dist/gc.js.map +1 -0
- package/dist/handler.d.ts +20 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +155 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt.d.ts +36 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +120 -0
- package/dist/jwt.js.map +1 -0
- package/dist/ldp.d.ts +37 -0
- package/dist/ldp.d.ts.map +1 -0
- package/dist/ldp.js +85 -0
- package/dist/ldp.js.map +1 -0
- package/dist/log.d.ts +55 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +51 -0
- package/dist/log.js.map +1 -0
- package/dist/negotiation.d.ts +23 -0
- package/dist/negotiation.d.ts.map +1 -0
- package/dist/negotiation.js +80 -0
- package/dist/negotiation.js.map +1 -0
- package/dist/patch.d.ts +80 -0
- package/dist/patch.d.ts.map +1 -0
- package/dist/patch.js +425 -0
- package/dist/patch.js.map +1 -0
- package/dist/pod.d.ts +20 -0
- package/dist/pod.d.ts.map +1 -0
- package/dist/pod.js +860 -0
- package/dist/pod.js.map +1 -0
- package/dist/wac.d.ts +33 -0
- package/dist/wac.d.ts.map +1 -0
- package/dist/wac.js +84 -0
- package/dist/wac.js.map +1 -0
- package/package.json +55 -0
- package/src/auth.ts +203 -0
- package/src/config.ts +254 -0
- package/src/encoding.ts +32 -0
- package/src/gc.ts +47 -0
- package/src/handler.ts +199 -0
- package/src/index.ts +32 -0
- package/src/jwt.ts +166 -0
- package/src/ldp.ts +99 -0
- package/src/log.ts +59 -0
- package/src/negotiation.ts +97 -0
- package/src/patch.ts +539 -0
- package/src/pod.ts +1195 -0
- 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
|
+
}
|
package/src/encoding.ts
ADDED
|
@@ -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";
|