@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/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
|
+
}
|