@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/jwt.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Minimal JWT decode and JWKS-based signature verification for the Resource
3
+ * Server edge.
4
+ *
5
+ * Solid-OIDC access tokens are asymmetrically signed by the issuer (the OP), so
6
+ * — unlike `@dwk/indieauth`'s HS256 self-issued tokens — verification needs the
7
+ * issuer's public JWKS rather than a shared secret. This module decodes the
8
+ * compact JWS, selects the key by `kid`/`kty`, and verifies the signature with
9
+ * Web Crypto. It performs no claim policy (issuer/audience/expiry) itself; that
10
+ * lives in `auth.ts`.
11
+ *
12
+ * Only asymmetric algorithms are accepted. `HS*` and `none` are refused: an
13
+ * access token must be signed by the issuer's private key, never a symmetric
14
+ * secret an RS could not safely hold.
15
+ */
16
+
17
+ import { base64urlToBytes, base64urlToText } from "./encoding";
18
+
19
+ /** Asymmetric JOSE algorithms accepted for issuer-signed access tokens. */
20
+ export type JwtAlgorithm = "ES256" | "ES384" | "RS256" | "PS256";
21
+
22
+ interface ImportAlg {
23
+ name: string;
24
+ namedCurve?: string;
25
+ hash?: string;
26
+ }
27
+ interface VerifyAlg {
28
+ name: string;
29
+ hash?: string;
30
+ saltLength?: number;
31
+ }
32
+ interface AlgSpec {
33
+ kty: "EC" | "RSA";
34
+ crv?: string;
35
+ importParams: ImportAlg;
36
+ verifyParams: VerifyAlg;
37
+ }
38
+
39
+ // We avoid the DOM lib's algorithm-parameter type names (not declared by
40
+ // @cloudflare/workers-types); these objects are accepted structurally by
41
+ // crypto.subtle.importKey / verify, mirroring the approach in `@dwk/dpop`.
42
+ const ALGS: Record<JwtAlgorithm, AlgSpec> = {
43
+ ES256: {
44
+ kty: "EC",
45
+ crv: "P-256",
46
+ importParams: { name: "ECDSA", namedCurve: "P-256" },
47
+ verifyParams: { name: "ECDSA", hash: "SHA-256" },
48
+ },
49
+ ES384: {
50
+ kty: "EC",
51
+ crv: "P-384",
52
+ importParams: { name: "ECDSA", namedCurve: "P-384" },
53
+ verifyParams: { name: "ECDSA", hash: "SHA-384" },
54
+ },
55
+ RS256: {
56
+ kty: "RSA",
57
+ importParams: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
58
+ verifyParams: { name: "RSASSA-PKCS1-v1_5" },
59
+ },
60
+ PS256: {
61
+ kty: "RSA",
62
+ importParams: { name: "RSA-PSS", hash: "SHA-256" },
63
+ verifyParams: { name: "RSA-PSS", saltLength: 32 },
64
+ },
65
+ };
66
+
67
+ /** A decoded JWT: its header, claims, and the raw segments for verification. */
68
+ export interface DecodedJwt {
69
+ readonly header: Record<string, unknown>;
70
+ readonly payload: Record<string, unknown>;
71
+ readonly signingInput: string;
72
+ readonly signature: Uint8Array;
73
+ }
74
+
75
+ function isObject(value: unknown): value is Record<string, unknown> {
76
+ return typeof value === "object" && value !== null && !Array.isArray(value);
77
+ }
78
+
79
+ /**
80
+ * Decode a compact JWS into its header, payload, and signature. Returns `null`
81
+ * for anything that is not three base64url segments with JSON header/payload.
82
+ */
83
+ export function decodeJwt(token: string): DecodedJwt | null {
84
+ if (typeof token !== "string") return null;
85
+ const segments = token.split(".");
86
+ if (segments.length !== 3) return null;
87
+ const [headerSeg, payloadSeg, signatureSeg] = segments as [
88
+ string,
89
+ string,
90
+ string,
91
+ ];
92
+ try {
93
+ const header = JSON.parse(base64urlToText(headerSeg)) as unknown;
94
+ const payload = JSON.parse(base64urlToText(payloadSeg)) as unknown;
95
+ if (!isObject(header) || !isObject(payload)) return null;
96
+ return {
97
+ header,
98
+ payload,
99
+ signingInput: `${headerSeg}.${payloadSeg}`,
100
+ signature: base64urlToBytes(signatureSeg),
101
+ };
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /** Whether a JWK could verify a signature under the given algorithm. */
108
+ function keyMatchesAlg(jwk: JsonWebKey, spec: AlgSpec): boolean {
109
+ if (jwk.kty !== spec.kty) return false;
110
+ if (spec.crv !== undefined && jwk.crv !== spec.crv) return false;
111
+ return true;
112
+ }
113
+
114
+ /**
115
+ * Verify `decoded`'s signature against a JWKS. Selects the verification key by
116
+ * `kid` when the header carries one, otherwise tries every key compatible with
117
+ * the header `alg`. Returns `true` on the first key that verifies.
118
+ */
119
+ export async function verifyJwtSignature(
120
+ decoded: DecodedJwt,
121
+ jwks: readonly JsonWebKey[],
122
+ ): Promise<boolean> {
123
+ const alg = decoded.header.alg;
124
+ const spec = typeof alg === "string" ? ALGS[alg as JwtAlgorithm] : undefined;
125
+ if (!spec) return false;
126
+
127
+ const kid = decoded.header.kid;
128
+ const compatible = jwks.filter((jwk) => keyMatchesAlg(jwk, spec));
129
+
130
+ // When the token names a `kid`, pin verification to the key(s) carrying it.
131
+ // A token that names a `kid` matching no JWKS key is rejected rather than
132
+ // verified against unrelated keys: trying other keys would silently weaken
133
+ // `kid` pinning (a token claiming an unknown key still passing). Only when
134
+ // the header omits `kid` do we try every alg-compatible key.
135
+ let candidates = compatible;
136
+ if (typeof kid === "string") {
137
+ // `kid` is not in the standard JsonWebKey type but is carried by JWKS keys.
138
+ candidates = compatible.filter(
139
+ (jwk) => (jwk as { kid?: unknown }).kid === kid,
140
+ );
141
+ if (candidates.length === 0) return false;
142
+ }
143
+
144
+ for (const jwk of candidates) {
145
+ try {
146
+ const key = await crypto.subtle.importKey(
147
+ "jwk",
148
+ jwk,
149
+ spec.importParams,
150
+ false,
151
+ ["verify"],
152
+ );
153
+ const ok = await crypto.subtle.verify(
154
+ spec.verifyParams,
155
+ key,
156
+ decoded.signature,
157
+ new TextEncoder().encode(decoded.signingInput),
158
+ );
159
+ if (ok) return true;
160
+ } catch {
161
+ // A key that fails to import (wrong shape) simply does not match; try
162
+ // the next candidate rather than aborting the whole verification.
163
+ }
164
+ }
165
+ return false;
166
+ }
package/src/ldp.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Path and IRI helpers for the LDP resource/container model.
3
+ *
4
+ * Resources are keyed in the store by their URL pathname. Containers are the
5
+ * keys that end in `/`; everything else is a plain resource. ACL documents
6
+ * follow the Solid convention `<resource>.acl` (and `<container>.acl`). These
7
+ * helpers are pure string math so they unit-test without a Workers runtime.
8
+ */
9
+
10
+ /** Whether a store key denotes an LDP container (trailing slash). */
11
+ export function isContainer(path: string): boolean {
12
+ return path.endsWith("/");
13
+ }
14
+
15
+ /** Whether a path is an ACL document (`*.acl`). */
16
+ export function isAclPath(path: string): boolean {
17
+ return path.endsWith(".acl");
18
+ }
19
+
20
+ /**
21
+ * Suffixes of server-governed auxiliary resources. Writing one requires
22
+ * `acl:Control` on the resource it governs (WAC), so a client `Slug` must
23
+ * never be allowed to mint one through a container `POST` (which is only
24
+ * authorized for `Append`/`Write` on the parent).
25
+ */
26
+ const RESERVED_AUXILIARY_SUFFIXES = [".acl", ".meta"] as const;
27
+
28
+ /** Whether a name/path ends in a reserved auxiliary suffix (`.acl`/`.meta`). */
29
+ export function hasReservedAuxiliarySuffix(value: string): boolean {
30
+ return RESERVED_AUXILIARY_SUFFIXES.some((suffix) => value.endsWith(suffix));
31
+ }
32
+
33
+ /** The ACL document path that governs `path` (`<path>.acl`). */
34
+ export function aclPath(path: string): string {
35
+ return `${path}.acl`;
36
+ }
37
+
38
+ /** The resource an ACL document governs (`<resource>.acl` → `<resource>`). */
39
+ export function resourceForAcl(aclPathValue: string): string {
40
+ return aclPathValue.slice(0, -".acl".length);
41
+ }
42
+
43
+ /**
44
+ * The immediate parent container of `path`, or `null` for the root container.
45
+ * `"/a/b"` → `"/a/"`, `"/a/b/"` → `"/a/"`, `"/"` → `null`.
46
+ */
47
+ export function parentContainer(path: string): string | null {
48
+ if (path === "/") return null;
49
+ const trimmed = isContainer(path) ? path.slice(0, -1) : path;
50
+ const slash = trimmed.lastIndexOf("/");
51
+ return slash < 0 ? "/" : trimmed.slice(0, slash + 1);
52
+ }
53
+
54
+ /**
55
+ * The ancestor containers of `path`, nearest first, up to and including the
56
+ * root container `"/"`.
57
+ */
58
+ export function ancestorContainers(path: string): string[] {
59
+ const ancestors: string[] = [];
60
+ let current = parentContainer(path);
61
+ while (current !== null) {
62
+ ancestors.push(current);
63
+ current = parentContainer(current);
64
+ }
65
+ return ancestors;
66
+ }
67
+
68
+ /** Resolve a store key to its absolute resource IRI under `origin`. */
69
+ export function toIri(origin: string, path: string): string {
70
+ return `${origin}${path}`;
71
+ }
72
+
73
+ const SLUG_UNSAFE = /[^A-Za-z0-9._~-]+/g;
74
+
75
+ /**
76
+ * Pick a child key for a `POST` to `container`, honoring a client `Slug` when
77
+ * present and safe, and falling back to a random name. A trailing slash is
78
+ * appended when the new resource is itself a container.
79
+ */
80
+ export function childKey(
81
+ container: string,
82
+ slug: string | null,
83
+ asContainer: boolean,
84
+ ): string {
85
+ const cleaned = slug
86
+ ?.trim()
87
+ .replace(SLUG_UNSAFE, "-")
88
+ .replace(/^-+|-+$/g, "");
89
+ // A Slug must never mint a reserved auxiliary resource (`.acl`/`.meta`):
90
+ // those govern a sibling's access/metadata and require `acl:Control` to
91
+ // write, which a container POST (authorized only for Append/Write on the
92
+ // parent) does not confer. Treat such a Slug as unusable and fall back to a
93
+ // random name, closing a privilege-escalation path (issue #28).
94
+ const name =
95
+ cleaned && cleaned.length > 0 && !hasReservedAuxiliarySuffix(cleaned)
96
+ ? cleaned
97
+ : crypto.randomUUID();
98
+ return `${container}${name}${asContainer ? "/" : ""}`;
99
+ }
package/src/log.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `@dwk/solid-pod` — structured observability event taxonomy.
3
+ *
4
+ * A Solid Pod authenticates DPoP-bound tokens at the edge and enforces WAC in
5
+ * the Durable Object; an auth/authz decision that is silently swallowed is an
6
+ * operational blind spot. Logging and metrics are opt-in via an injected
7
+ * {@link Logger} and {@link Metrics} (see `@dwk/log`) wired **once at the
8
+ * composition boundary** — the stateless front door. They **share this one
9
+ * vocabulary**: the same dotted event name is passed to the logger and the
10
+ * metrics sink so a log line and its counter line up.
11
+ *
12
+ * Because the Durable Object cannot receive injected functions across the
13
+ * isolate boundary, it signals its authorization outcomes back to the front door
14
+ * via an internal response header ({@link INTERNAL_HEADERS.outcome}); the front
15
+ * door — which holds the injected seams — emits the events and strips the header.
16
+ * This keeps the DO free of any logger and honors the composition contract.
17
+ *
18
+ * Fields follow the redaction policy: only machine-readable reason codes, HTTP
19
+ * method/status, and a sanitized agent host (`hostFromUrl`) — never tokens,
20
+ * proofs, resource paths, or bodies. See `spec/observability.md`.
21
+ *
22
+ * @packageDocumentation
23
+ */
24
+
25
+ /** Stable event names emitted by `@dwk/solid-pod`. */
26
+ export const SolidPodLogEvent = {
27
+ /**
28
+ * A presented credential failed edge authentication. Field: `reason` (an
29
+ * {@link AuthFailureReason}, e.g. `signature_invalid`, `dpop_invalid`).
30
+ */
31
+ AuthRejected: "solid.auth.rejected",
32
+ /** A request authenticated successfully at the edge. Field: `agentHost`. */
33
+ AuthAccepted: "solid.auth.accepted",
34
+ /** WAC refused the request in the Durable Object. Fields: `method`, `status` (401/403). */
35
+ AccessDenied: "solid.wac.denied",
36
+ /** A proof-less write was refused (DPoP required). Field: `method`. */
37
+ AnonymousWriteRefused: "solid.write.anonymous_refused",
38
+ /** A write's DPoP `jti` was replayed and the write was refused. Field: `method`. */
39
+ ReplayRejected: "solid.dpop.replay_rejected",
40
+ } as const;
41
+
42
+ /** Union of the event-name string literals in {@link SolidPodLogEvent}. */
43
+ export type SolidPodLogEvent =
44
+ (typeof SolidPodLogEvent)[keyof typeof SolidPodLogEvent];
45
+
46
+ /**
47
+ * Machine-readable outcome values the Durable Object sets on
48
+ * {@link INTERNAL_HEADERS.outcome} so the front door can emit the matching
49
+ * {@link SolidPodLogEvent}. Internal to the DO↔front-door contract; stripped
50
+ * before the response reaches the client.
51
+ */
52
+ export const PodOutcome = {
53
+ WacDenied: "wac_denied",
54
+ AnonymousWriteRefused: "anon_write_refused",
55
+ Replay: "replay",
56
+ } as const;
57
+
58
+ /** Union of the outcome string literals in {@link PodOutcome}. */
59
+ export type PodOutcome = (typeof PodOutcome)[keyof typeof PodOutcome];
@@ -0,0 +1,97 @@
1
+ /**
2
+ * RDF content negotiation for reads.
3
+ *
4
+ * Maps an `Accept` header to a concrete RDF serialization `@dwk/rdf` can write,
5
+ * preferring the client's highest-q acceptable type and falling back to Turtle
6
+ * (the Solid default). Turtle and JSON-LD are the guaranteed minimum; the other
7
+ * Turtle-family types `@dwk/rdf` supports are offered too.
8
+ */
9
+
10
+ import { formatForMediaType, type RdfFormat } from "@dwk/rdf";
11
+
12
+ /** A negotiated serialization: the concrete media type and `@dwk/rdf` format. */
13
+ export interface Negotiated {
14
+ readonly mediaType: string;
15
+ readonly format: RdfFormat;
16
+ }
17
+
18
+ /** Canonical media types offered on read, in server-preference order. */
19
+ const OFFERED: readonly string[] = [
20
+ "text/turtle",
21
+ "application/ld+json",
22
+ "application/n-triples",
23
+ "application/trig",
24
+ "application/n-quads",
25
+ ];
26
+
27
+ const DEFAULT: Negotiated = { mediaType: "text/turtle", format: "Turtle" };
28
+
29
+ interface AcceptEntry {
30
+ readonly type: string;
31
+ readonly q: number;
32
+ }
33
+
34
+ /** Parse an `Accept` header into entries sorted by descending q-value. */
35
+ function parseAccept(accept: string): AcceptEntry[] {
36
+ return accept
37
+ .split(",")
38
+ .map((part) => {
39
+ const [type, ...params] = part.split(";").map((s) => s.trim());
40
+ let q = 1;
41
+ for (const param of params) {
42
+ const m = /^q=([0-9.]+)$/.exec(param);
43
+ if (m) q = Number.parseFloat(m[1] as string);
44
+ }
45
+ return {
46
+ type: (type ?? "").toLowerCase(),
47
+ q: Number.isFinite(q) ? q : 0,
48
+ };
49
+ })
50
+ .filter((e) => e.type.length > 0)
51
+ .sort((a, b) => b.q - a.q);
52
+ }
53
+
54
+ /**
55
+ * Choose the RDF serialization to write for an `Accept` header, or `null` when
56
+ * the header is present but lists nothing this server can serve (the caller MUST
57
+ * then answer `406 Not Acceptable`). An absent/empty `Accept` or a `*` `/` `*`
58
+ * wildcard yields the Turtle default; concrete RDF media types and family
59
+ * wildcards (`text/*`, `application/*`) negotiate to a supported type.
60
+ */
61
+ export function negotiateMediaType(accept: string | null): Negotiated | null {
62
+ if (!accept || accept.trim() === "") return DEFAULT;
63
+
64
+ for (const entry of parseAccept(accept)) {
65
+ if (entry.q === 0) continue;
66
+ if (entry.type === "*/*") return DEFAULT;
67
+
68
+ const essence = entry.type.split(";")[0]?.trim() ?? "";
69
+
70
+ // Read-only opt-in: a client asking for `application/json` gets JSON-LD.
71
+ // This alias lives here, not in `@dwk/rdf`'s RDF media-type registry, so an
72
+ // incoming `application/json` *request body* is never misparsed as RDF.
73
+ if (essence === "application/json") {
74
+ return { mediaType: "application/ld+json", format: "JSON-LD" };
75
+ }
76
+
77
+ const format = formatForMediaType(entry.type);
78
+ if (format) {
79
+ return { mediaType: essence || "text/turtle", format };
80
+ }
81
+
82
+ // Wildcard subtypes (`text/*`, `application/*`): pick the first offered
83
+ // type in that family.
84
+ const slash = entry.type.indexOf("/");
85
+ if (slash > 0 && entry.type.endsWith("/*")) {
86
+ const family = entry.type.slice(0, slash);
87
+ const offered = OFFERED.find((m) => m.startsWith(`${family}/`));
88
+ if (offered) {
89
+ return { mediaType: offered, format: formatForMediaType(offered)! };
90
+ }
91
+ }
92
+ }
93
+
94
+ // An `Accept` was supplied but nothing in it is acceptable: signal 406 rather
95
+ // than silently serving Turtle the client said it could not handle.
96
+ return null;
97
+ }