@sdxc/well-known 2026.10.24 → 2026.10.26

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.
@@ -0,0 +1,294 @@
1
+ import { success } from "@sdxc/result";
2
+ import { jwks } from "./jwks.js";
3
+ import { isObject, pointer } from "./lib/json.js";
4
+ import { defaults, readMetadata, writeMetadata } from "./lib/metadata.js";
5
+ export const NAME = "client-id-metadata-document";
6
+ export const MEDIA_TYPE = "application/json";
7
+ /**
8
+ * The client authentication methods a document may declare (§4.1): `none` and the
9
+ * methods proven with a key pair, since a published document can establish no shared
10
+ * secret.
11
+ */
12
+ export const TOKEN_ENDPOINT_AUTH_METHODS = [
13
+ "none",
14
+ "private_key_jwt",
15
+ "tls_client_auth",
16
+ "self_signed_tls_client_auth",
17
+ ];
18
+ /** The methods whose key the authorization server discovers through `jwks` or `jwks_uri`. */
19
+ const KEYED_METHODS = ["private_key_jwt", "self_signed_tls_client_auth"];
20
+ /** The registered methods built on a shared secret, refused by name (§4.1). */
21
+ const SECRET_METHODS = [
22
+ "client_secret_post",
23
+ "client_secret_basic",
24
+ "client_secret_jwt",
25
+ ];
26
+ /** Members that carry a shared secret, which a document must never hold (§4.1). */
27
+ const SECRET_MEMBERS = ["client_secret", "client_secret_expires_at"];
28
+ /** Grant types that send the user back through a registered redirect URI (§4.2). */
29
+ const REDIRECT_GRANTS = ["authorization_code", "implicit"];
30
+ /** Schemes a redirect URI may never use, since following one runs or embeds content. */
31
+ const UNSAFE_REDIRECT_SCHEMES = ["javascript:", "data:", "vbscript:", "file:"];
32
+ /** The schemes a URL shown to the user or linked from the consent screen may use (§8.6). */
33
+ const WEB_SCHEMES = ["https:", "http:"];
34
+ /** Every RFC 7591 §2 member this document types, by camelCase field. */
35
+ const FIELDS = {
36
+ clientId: { wire: "client_id", kind: "text", required: true },
37
+ redirectUris: { wire: "redirect_uris", kind: "strings" },
38
+ tokenEndpointAuthMethod: { wire: "token_endpoint_auth_method", kind: "text" },
39
+ grantTypes: { wire: "grant_types", kind: "strings", default: ["authorization_code"] },
40
+ responseTypes: { wire: "response_types", kind: "strings", default: ["code"] },
41
+ clientName: { wire: "client_name", kind: "localized" },
42
+ clientUri: { wire: "client_uri", kind: "url" },
43
+ logoUri: { wire: "logo_uri", kind: "url" },
44
+ scope: { wire: "scope", kind: "tokens" },
45
+ contacts: { wire: "contacts", kind: "strings" },
46
+ tosUri: { wire: "tos_uri", kind: "url" },
47
+ policyUri: { wire: "policy_uri", kind: "url" },
48
+ jwksUri: { wire: "jwks_uri", kind: "url" },
49
+ jwks: { wire: "jwks", kind: "object" },
50
+ softwareId: { wire: "software_id", kind: "text" },
51
+ softwareVersion: { wire: "software_version", kind: "text" },
52
+ softwareStatement: { wire: "software_statement", kind: "text" },
53
+ };
54
+ /**
55
+ * Whether a string is a valid Client Identifier URL (§3): `https`, a path, and no
56
+ * userinfo, dot segments or fragment. It reads the raw string, so a dot segment the URL
57
+ * parser would resolve away still fails; a query is allowed, though discouraged.
58
+ *
59
+ * @param value - A `client_id` as presented.
60
+ * @example
61
+ * isClientIdentifierUrl("https://app.example.com/client.json"); // true
62
+ */
63
+ export function isClientIdentifierUrl(value) {
64
+ if (!/^https:\/\//i.test(value) || !URL.canParse(value))
65
+ return false;
66
+ if (value.includes("#"))
67
+ return false;
68
+ let rest = value.slice("https://".length);
69
+ let end = rest.search(/[/?]/);
70
+ if (end === -1 || rest[end] !== "/")
71
+ return false;
72
+ if (rest.slice(0, end).includes("@"))
73
+ return false;
74
+ let path = rest.slice(end).split("?")[0] ?? "";
75
+ return path.split("/").every((segment) => !/^(\.|%2e){1,2}$/i.test(segment));
76
+ }
77
+ /**
78
+ * Whether a redirect URI can be registered: an absolute URL without a fragment (RFC 6749
79
+ * §3.1.2) whose scheme leads somewhere a browser navigates to safely.
80
+ *
81
+ * @param value - One entry of `redirect_uris`.
82
+ */
83
+ function isRedirectUri(value) {
84
+ if (!URL.canParse(value) || value.includes("#"))
85
+ return false;
86
+ return !UNSAFE_REDIRECT_SCHEMES.includes(new URL(value).protocol);
87
+ }
88
+ /**
89
+ * Collects every §4.1 key material problem in `jwks`: no `keys` array, or a key carrying
90
+ * a private or symmetric member.
91
+ *
92
+ * @param set - The decoded `jwks` member.
93
+ * @param issues - Where problems are collected.
94
+ */
95
+ function checkKeys(set, issues) {
96
+ let keys = set.keys;
97
+ if (!Array.isArray(keys)) {
98
+ issues.push({ at: "/jwks/keys", message: '"jwks" has no "keys" array.' });
99
+ return;
100
+ }
101
+ for (let [index, key] of keys.entries()) {
102
+ if (!isObject(key)) {
103
+ issues.push({
104
+ at: pointer("jwks", "keys", index),
105
+ message: `Key ${index} is not an object.`,
106
+ });
107
+ continue;
108
+ }
109
+ for (let member of ["d", "p", "q", "dp", "dq", "qi", "oth", "k"]) {
110
+ if (member in key)
111
+ issues.push({
112
+ at: pointer("jwks", "keys", index, member),
113
+ message: `Key ${index} carries the private member "${member}".`,
114
+ });
115
+ }
116
+ }
117
+ }
118
+ /**
119
+ * Collects the §4.1 authentication problems: a secret member, a method outside
120
+ * {@link TOKEN_ENDPOINT_AUTH_METHODS}, both or neither key source for a keyed method, a
121
+ * non-`https` `jwks_uri` and private key material.
122
+ *
123
+ * @param fields - The read fields.
124
+ * @param issues - Where problems are collected.
125
+ * @param body - The decoded document.
126
+ */
127
+ function checkAuthentication(fields, issues, body) {
128
+ for (let member of SECRET_MEMBERS) {
129
+ if (member in body)
130
+ issues.push({
131
+ at: pointer(member),
132
+ message: `"${member}" is a shared secret, which a Client ID Metadata Document cannot carry.`,
133
+ });
134
+ }
135
+ let method = fields.tokenEndpointAuthMethod ?? "none";
136
+ if (SECRET_METHODS.includes(method)) {
137
+ issues.push({
138
+ at: "/token_endpoint_auth_method",
139
+ message: `"${method}" authenticates with a shared secret, which a Client ID Metadata Document cannot establish.`,
140
+ });
141
+ }
142
+ else if (!TOKEN_ENDPOINT_AUTH_METHODS.includes(method)) {
143
+ issues.push({
144
+ at: "/token_endpoint_auth_method",
145
+ message: `"${method}" is not one of ${TOKEN_ENDPOINT_AUTH_METHODS.join(", ")}.`,
146
+ });
147
+ }
148
+ let jwksUri = fields.jwksUri;
149
+ let set = fields.jwks;
150
+ if (jwksUri !== null && set !== null)
151
+ issues.push({ at: "", message: 'The document sets both "jwks" and "jwks_uri".' });
152
+ if (KEYED_METHODS.includes(method) && jwksUri === null && set === null)
153
+ issues.push({
154
+ at: "/token_endpoint_auth_method",
155
+ message: `"${method}" needs the client's public key in "jwks" or "jwks_uri".`,
156
+ });
157
+ if (jwksUri !== null && jwksUri.protocol !== "https:")
158
+ issues.push({ at: "/jwks_uri", message: '"jwks_uri" is not an https URL.' });
159
+ if (set !== null)
160
+ checkKeys(set, issues);
161
+ }
162
+ /**
163
+ * Collects the §4.2 and §8.6 URL problems: a redirect URI that cannot be registered, no
164
+ * redirect URI for a redirect-based grant, and a display URL outside `http`/`https`.
165
+ *
166
+ * @param fields - The read fields.
167
+ * @param issues - Where problems are collected.
168
+ */
169
+ function checkUrls(fields, issues) {
170
+ let redirectUris = fields.redirectUris;
171
+ for (let [index, uri] of redirectUris.entries()) {
172
+ if (!isRedirectUri(uri))
173
+ issues.push({
174
+ at: pointer("redirect_uris", index),
175
+ message: `"${uri}" is not an absolute redirect URI without a fragment.`,
176
+ });
177
+ }
178
+ let grantTypes = fields.grantTypes;
179
+ if (redirectUris.length === 0 && grantTypes.some((grant) => REDIRECT_GRANTS.includes(grant)))
180
+ issues.push({
181
+ at: "/redirect_uris",
182
+ message: `"redirect_uris" is empty, and grant types ${grantTypes.join(", ")} need one.`,
183
+ });
184
+ for (let name of ["clientUri", "logoUri", "tosUri", "policyUri"]) {
185
+ let url = fields[name];
186
+ if (url !== null && !WEB_SCHEMES.includes(url.protocol))
187
+ issues.push({
188
+ at: pointer(FIELDS[name].wire),
189
+ message: `"${FIELDS[name].wire}" is not an http or https URL.`,
190
+ });
191
+ }
192
+ }
193
+ /**
194
+ * Builds the read's draft check: the `client_id` shape (§3), its match with the fetched
195
+ * URL when one is given (§4), and the credential and URL rules.
196
+ *
197
+ * @param expected - The URL the document was fetched from.
198
+ */
199
+ function documentCheck(expected) {
200
+ return (fields, issues, body) => {
201
+ let clientId = String(fields.clientId);
202
+ if (!isClientIdentifierUrl(clientId))
203
+ issues.push({
204
+ at: "/client_id",
205
+ message: `"${clientId}" is not an https URL with a path and no userinfo, dot segments or fragment.`,
206
+ });
207
+ else if (expected !== undefined && clientId !== expected)
208
+ issues.push({
209
+ at: "/client_id",
210
+ message: `The document names client_id "${clientId}", not "${expected}".`,
211
+ });
212
+ checkAuthentication(fields, issues, body);
213
+ checkUrls(fields, issues);
214
+ };
215
+ }
216
+ /**
217
+ * Reads a document through the field table and the draft's rules.
218
+ *
219
+ * @param text - The served JSON.
220
+ * @param expected - The URL it was fetched from, when the match is checked.
221
+ * @param extensions - The extension schema.
222
+ * @template Extensions - The extension members the schema produces.
223
+ */
224
+ function read(text, expected, extensions) {
225
+ let result = readMetadata(text, {
226
+ format: NAME,
227
+ table: FIELDS,
228
+ extensions,
229
+ check: documentCheck(expected),
230
+ });
231
+ if (result.status === "failure")
232
+ return result;
233
+ return success({
234
+ ...result.data,
235
+ tokenEndpointAuthMethod: result.data.tokenEndpointAuthMethod ?? "none",
236
+ });
237
+ }
238
+ /**
239
+ * Reads a fetched document. It fails on a `client_id` other than `options.clientId`, a
240
+ * `client_secret*` member, a secret-based or unknown `token_endpoint_auth_method`, a
241
+ * keyed method without exactly one key source, private key material, or a redirect URI
242
+ * the client could not register; every RFC 7591 member outside the list lands in `extensions`.
243
+ *
244
+ * @param text - The served JSON.
245
+ * @param options - The fetched URL and an extension schema.
246
+ * @template Extensions - The extension members the schema produces.
247
+ * @example
248
+ * let client = parse(await response.text(), { clientId: url });
249
+ */
250
+ export function parse(text, options) {
251
+ return read(text, options.clientId, options.extensions);
252
+ }
253
+ /**
254
+ * Writes the document as its registered member names, leaving out `null` members, empty
255
+ * optional lists and default grant and response types. `token_endpoint_auth_method` is
256
+ * always written, since RFC 7591 reads an absent one as `client_secret_basic`, and
257
+ * `jwks` carries each key's public half only.
258
+ *
259
+ * @param document - The metadata to publish.
260
+ */
261
+ export function stringify(document) {
262
+ let set = document.jwks === null ? null : JSON.parse(jwks.stringify(document.jwks));
263
+ return writeMetadata({ ...document, jwks: set }, FIELDS);
264
+ }
265
+ /**
266
+ * Builds a document from the members a client sets: `none` authentication, the RFC 7591
267
+ * default grant and response types, empty lists, `null` single values and no extensions.
268
+ *
269
+ * @param document - The members the client publishes.
270
+ * @template Extensions - Members outside the typed list the client also publishes.
271
+ * @example
272
+ * define({ clientId: "https://app.example.com/client.json", redirectUris: ["https://app.example.com/callback"] });
273
+ */
274
+ export function define(document) {
275
+ return {
276
+ ...defaults(FIELDS),
277
+ tokenEndpointAuthMethod: "none",
278
+ extensions: {},
279
+ ...document,
280
+ };
281
+ }
282
+ /**
283
+ * The document as a servable format, for `respond` from `./response` at the route behind
284
+ * the client's own URL. Its `parse` applies every rule but the URL match, which a
285
+ * fetching authorization server checks by calling `parse` with `clientId`.
286
+ */
287
+ export const clientIdMetadataDocument = {
288
+ name: NAME,
289
+ mediaType: MEDIA_TYPE,
290
+ placement: "insert",
291
+ cors: false,
292
+ stringify,
293
+ parse: (text) => read(text, undefined, undefined),
294
+ };
package/dist/format.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The description every well-known document shares: its registered name, media type,
3
- * URL placement and CORS rule beside its reader and writer, which is what lets one
3
+ * URL placement, CORS rule and fixed headers beside its reader and writer, which is what lets one
4
4
  * response helper and one middleware serve any of them.
5
5
  *
6
6
  * @author [Sergio Xalambrí](https://sergiodxa.com)
@@ -27,6 +27,8 @@ export interface WellKnownFormat<Document> {
27
27
  readonly placement: WellKnownPlacement;
28
28
  /** Whether every answer carries `Access-Control-Allow-Origin: *`. */
29
29
  readonly cors: boolean;
30
+ /** Headers every answer carries, `HEAD` included, such as the `Link` an API catalog requires. */
31
+ readonly headers?: Readonly<Record<string, string>>;
30
32
  /** The document as the text served under the name. */
31
33
  stringify(document: Document): string;
32
34
  /** Reads served text back, reporting every place it breaks the specification. */
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The HTTP Message Signatures Directory of Web Bot Auth: the JWK Set an automated client
3
+ * publishes at `/.well-known/http-message-signatures-directory` so any origin can verify
4
+ * the requests it signs, each key labelled with its RFC 7638 thumbprint as `kid`.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { WellKnownFormat } from "./format.js";
11
+ import type { Jwk } from "./jwks.js";
12
+ import { WellKnownParseError } from "./parse-error.js";
13
+ export declare const NAME = "http-message-signatures-directory";
14
+ export declare const MEDIA_TYPE = "application/http-message-signatures-directory+json";
15
+ /** A directory key: a public JWK, optionally bounded by `nbf` and `exp` in epoch seconds. */
16
+ export interface DirectoryKey extends Jwk {
17
+ nbf?: number;
18
+ exp?: number;
19
+ }
20
+ export interface SignaturesDirectory {
21
+ keys: DirectoryKey[];
22
+ }
23
+ export interface ParsedSignaturesDirectory extends SignaturesDirectory {
24
+ /**
25
+ * Entries dropped: unusable as a JWK, symmetric, carrying private members, declaring an
26
+ * `alg` outside the HTTP Signature Algorithms registry, or with a non-numeric `nbf`/`exp`.
27
+ */
28
+ skipped: number;
29
+ }
30
+ /**
31
+ * Reads a directory. A missing or non-array `keys` fails; every entry a verifier could not
32
+ * use is dropped and counted in `skipped`, so one bad key never hides the others.
33
+ *
34
+ * @param text - The served JSON.
35
+ * @example let directory = parse(await response.text());
36
+ */
37
+ export declare function parse(text: string): Result<ParsedSignaturesDirectory, WellKnownParseError>;
38
+ /**
39
+ * Builds a directory from keys, public or private: each entry keeps its public members,
40
+ * `kid`, `alg`, `nbf` and `exp`, and gets `use: "sig"`. Verifiers match a signature's
41
+ * `keyid` against `kid`, so set it to the key's RFC 7638 thumbprint (`JWK.thumbprint`).
42
+ *
43
+ * @param keys - The signing keys; a private JWK publishes only its public half.
44
+ * @returns The directory, or an issue at every symmetric key, every key without `kid`, and
45
+ * every `alg` outside the HTTP Signature Algorithms registry, such as JOSE's `EdDSA`.
46
+ * @example build([{ ...publicJwk, kid: await thumbprintOf(publicJwk) }])
47
+ */
48
+ export declare function build(keys: DirectoryKey[]): Result<SignaturesDirectory, WellKnownParseError>;
49
+ /**
50
+ * A directory as a servable format, answered with the registered media type and
51
+ * `Access-Control-Allow-Origin: *` so a browser-based verifier can read it. Serving keeps
52
+ * the public half of every asymmetric key; pass what `build` answers so each carries its `kid`.
53
+ */
54
+ export declare const httpMessageSignaturesDirectory: WellKnownFormat<SignaturesDirectory>;
@@ -0,0 +1,115 @@
1
+ import { failure, isFailure, success } from "@sdxc/result";
2
+ import { parse as parseJwks } from "./jwks.js";
3
+ import { pointer } from "./lib/json.js";
4
+ import { WellKnownParseError } from "./parse-error.js";
5
+ export const NAME = "http-message-signatures-directory";
6
+ export const MEDIA_TYPE = "application/http-message-signatures-directory+json";
7
+ /** The asymmetric algorithms the HTTP Signature Algorithms registry (RFC 9421 §6.2) lists. */
8
+ const SIGNATURE_ALGORITHMS = new Set([
9
+ "rsa-pss-sha512",
10
+ "rsa-v1_5-sha256",
11
+ "ecdsa-p256-sha256",
12
+ "ecdsa-p384-sha384",
13
+ "ed25519",
14
+ ]);
15
+ /** Members that carry private or symmetric key material (RFC 7518 §6). */
16
+ const PRIVATE_MEMBERS = ["d", "p", "q", "dp", "dq", "qi", "oth", "k"];
17
+ /**
18
+ * Whether a usable JWK may stand in a directory: asymmetric and public, since the draft
19
+ * forbids shared secrets, with a registered `alg` when it names one.
20
+ *
21
+ * @param key - An entry the JWK Set reader kept.
22
+ */
23
+ function isDirectoryKey(key) {
24
+ if (key.kty === "oct")
25
+ return false;
26
+ if (PRIVATE_MEMBERS.some((member) => member in key))
27
+ return false;
28
+ if (key.alg !== undefined && !SIGNATURE_ALGORITHMS.has(key.alg))
29
+ return false;
30
+ let { nbf, exp } = key;
31
+ return [nbf, exp].every((bound) => bound === undefined || typeof bound === "number");
32
+ }
33
+ /**
34
+ * A key without its private members, `key_ops` and `ext`, which a published key never needs, or
35
+ * `null` for a symmetric key, which has no public half.
36
+ *
37
+ * @param key - A key, public or private.
38
+ */
39
+ function publicHalf(key) {
40
+ if (key.kty === "oct")
41
+ return null;
42
+ let half = { ...key };
43
+ for (let member of PRIVATE_MEMBERS)
44
+ delete half[member];
45
+ delete half.key_ops;
46
+ delete half.ext;
47
+ return half;
48
+ }
49
+ /**
50
+ * Reads a directory. A missing or non-array `keys` fails; every entry a verifier could not
51
+ * use is dropped and counted in `skipped`, so one bad key never hides the others.
52
+ *
53
+ * @param text - The served JSON.
54
+ * @example let directory = parse(await response.text());
55
+ */
56
+ export function parse(text) {
57
+ let set = parseJwks(text);
58
+ if (isFailure(set)) {
59
+ return failure(new WellKnownParseError(NAME, set.error.issues));
60
+ }
61
+ let keys = set.data.keys.filter(isDirectoryKey);
62
+ let skipped = set.data.skipped + set.data.keys.length - keys.length;
63
+ return success({ keys, skipped });
64
+ }
65
+ /**
66
+ * Builds a directory from keys, public or private: each entry keeps its public members,
67
+ * `kid`, `alg`, `nbf` and `exp`, and gets `use: "sig"`. Verifiers match a signature's
68
+ * `keyid` against `kid`, so set it to the key's RFC 7638 thumbprint (`JWK.thumbprint`).
69
+ *
70
+ * @param keys - The signing keys; a private JWK publishes only its public half.
71
+ * @returns The directory, or an issue at every symmetric key, every key without `kid`, and
72
+ * every `alg` outside the HTTP Signature Algorithms registry, such as JOSE's `EdDSA`.
73
+ * @example build([{ ...publicJwk, kid: await thumbprintOf(publicJwk) }])
74
+ */
75
+ export function build(keys) {
76
+ let issues = [];
77
+ let published = [];
78
+ for (let [index, key] of keys.entries()) {
79
+ let half = publicHalf(key);
80
+ if (half === null) {
81
+ issues.push({ at: pointer("keys", index), message: `Key ${index} is symmetric.` });
82
+ }
83
+ else if (half.kid === undefined) {
84
+ issues.push({ at: pointer("keys", index, "kid"), message: `Key ${index} has no kid.` });
85
+ }
86
+ else if (half.alg !== undefined && !SIGNATURE_ALGORITHMS.has(half.alg)) {
87
+ issues.push({
88
+ at: pointer("keys", index, "alg"),
89
+ message: `Key ${index}'s alg "${half.alg}" is no HTTP signature algorithm.`,
90
+ });
91
+ }
92
+ else {
93
+ published.push({ ...half, use: "sig" });
94
+ }
95
+ }
96
+ if (issues.length > 0)
97
+ return failure(new WellKnownParseError(NAME, issues));
98
+ return success({ keys: published });
99
+ }
100
+ /**
101
+ * A directory as a servable format, answered with the registered media type and
102
+ * `Access-Control-Allow-Origin: *` so a browser-based verifier can read it. Serving keeps
103
+ * the public half of every asymmetric key; pass what `build` answers so each carries its `kid`.
104
+ */
105
+ export const httpMessageSignaturesDirectory = {
106
+ name: NAME,
107
+ mediaType: MEDIA_TYPE,
108
+ placement: "append",
109
+ cors: true,
110
+ stringify(document) {
111
+ let keys = document.keys.map(publicHalf).filter((key) => key !== null);
112
+ return JSON.stringify({ keys });
113
+ },
114
+ parse,
115
+ };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The field-mapping engine behind the OAuth metadata documents (RFC 8414, OpenID
3
- * Connect Discovery, RFC 9728): one table maps each camelCase field to its registered
4
- * member name and kind, and drives reading, writing and defaults from that one place.
3
+ * Connect Discovery, RFC 9728, RFC 7591 client metadata): one table maps each camelCase
4
+ * field to its member name and kind, and drives reading, writing and defaults from it.
5
5
  *
6
6
  * @author [Sergio Xalambrí](https://sergiodxa.com)
7
7
  * @copyright Sergio Xalambrí 2026
@@ -9,12 +9,14 @@
9
9
  import type { Result } from "@sdxc/result";
10
10
  import type { StandardSchemaV1 } from "@standard-schema/spec";
11
11
  import { WellKnownParseError } from "../parse-error.js";
12
+ import type { JsonObject } from "./json.js";
12
13
  /**
13
14
  * How a member's JSON value maps to its field: `text` is a string, `url` one absolute
14
- * URL, `urls` and `strings` lists, `boolean` a flag, and `localized` a human-readable
15
- * string with `#`-tagged language variants (RFC 9728 §2.1).
15
+ * URL, `urls` and `strings` lists, `tokens` a space-delimited string read as a list (RFC
16
+ * 7591 `scope`), `object` a JSON object kept as decoded, `boolean` a flag, and `localized`
17
+ * a human-readable string with `#`-tagged language variants (RFC 9728 §2.1).
16
18
  */
17
- export type FieldKind = "text" | "url" | "urls" | "strings" | "boolean" | "localized";
19
+ export type FieldKind = "text" | "url" | "urls" | "strings" | "tokens" | "object" | "boolean" | "localized";
18
20
  /** One row of the mapping table. */
19
21
  export interface Field {
20
22
  /** The member name the specification registers. */
@@ -22,8 +24,8 @@ export interface Field {
22
24
  kind: FieldKind;
23
25
  /** A missing member fails the parse instead of taking the default. */
24
26
  required?: boolean;
25
- /** The value an absent boolean member means, which `stringify` leaves unwritten. */
26
- default?: boolean;
27
+ /** The value an absent boolean or `strings` member means, which `stringify` leaves unwritten. */
28
+ default?: boolean | readonly string[];
27
29
  /** The only values a `strings` member may list. */
28
30
  values?: readonly string[];
29
31
  }
@@ -40,8 +42,11 @@ export interface ReadOptions<Extensions extends object> {
40
42
  format: string;
41
43
  table: FieldTable;
42
44
  extensions?: StandardSchemaV1<unknown, Extensions>;
43
- /** Checks the read fields against what the caller expected, adding issues on a mismatch. */
44
- check?: (fields: Record<string, unknown>, issues: WellKnownParseError.Issue[]) => void;
45
+ /**
46
+ * Checks the read fields against what the caller expected, adding issues on a mismatch;
47
+ * `body` is the decoded document, for a rule about a member outside the table.
48
+ */
49
+ check?: (fields: Record<string, unknown>, issues: WellKnownParseError.Issue[], body: JsonObject) => void;
45
50
  }
46
51
  /**
47
52
  * Reads a metadata document through its table. Members outside the table become the
@@ -1,6 +1,6 @@
1
1
  import { failure, success } from "@sdxc/result";
2
2
  import { WellKnownParseError } from "../parse-error.js";
3
- import { pointer, readExtensions, readJsonObject } from "./json.js";
3
+ import { isObject, pointer, readExtensions, readJsonObject } from "./json.js";
4
4
  /**
5
5
  * The value an absent member takes: `null` for single values, an empty list for
6
6
  * lists, and the specification's default for flags.
@@ -8,12 +8,25 @@ import { pointer, readExtensions, readJsonObject } from "./json.js";
8
8
  * @param field - The row to default.
9
9
  */
10
10
  function defaultOf(field) {
11
- if (field.kind === "urls" || field.kind === "strings")
12
- return [];
11
+ if (field.kind === "urls" || field.kind === "strings" || field.kind === "tokens")
12
+ return Array.isArray(field.default) ? [...field.default] : [];
13
13
  if (field.kind === "boolean")
14
14
  return field.default ?? false;
15
15
  return null;
16
16
  }
17
+ /**
18
+ * Whether a list holds exactly a field's default list, in order, so the writer leaves a
19
+ * member out only when reading it back gives the same value.
20
+ *
21
+ * @param value - The field's list.
22
+ * @param field - The row it belongs to.
23
+ */
24
+ function isDefaultList(value, field) {
25
+ if (!Array.isArray(field.default))
26
+ return false;
27
+ let list = field.default;
28
+ return value.length === list.length && value.every((entry, index) => entry === list[index]);
29
+ }
17
30
  /**
18
31
  * Every field of a table at its default, for `define` to lay the app's values over.
19
32
  *
@@ -81,6 +94,18 @@ function readField(body, field, issues) {
81
94
  issues.push({ at, message: `"${field.wire}" is not an absolute URL.` });
82
95
  return null;
83
96
  }
97
+ case "tokens": {
98
+ if (typeof value === "string")
99
+ return value.split(" ").filter(Boolean);
100
+ issues.push({ at, message: `"${field.wire}" is not a space-delimited string.` });
101
+ return [];
102
+ }
103
+ case "object": {
104
+ if (isObject(value))
105
+ return value;
106
+ issues.push({ at, message: `"${field.wire}" is not a JSON object.` });
107
+ return null;
108
+ }
84
109
  case "boolean": {
85
110
  if (typeof value === "boolean")
86
111
  return value;
@@ -184,7 +209,7 @@ export function readMetadata(text, options) {
184
209
  for (let [name, field] of Object.entries(options.table))
185
210
  fields[name] = readField(body, field, issues);
186
211
  if (issues.length === 0)
187
- options.check?.(fields, issues);
212
+ options.check?.(fields, issues, body);
188
213
  let members = {};
189
214
  for (let [member, value] of Object.entries(body)) {
190
215
  if (!isStandardMember(options.table, member))
@@ -219,8 +244,14 @@ export function writeMetadata(document, table) {
219
244
  if (value === null || value === undefined)
220
245
  continue;
221
246
  if (Array.isArray(value)) {
222
- if (value.length === 0 && !field.required)
247
+ if (isDefaultList(value, field))
248
+ continue;
249
+ if (value.length === 0 && !field.required && !Array.isArray(field.default))
250
+ continue;
251
+ if (field.kind === "tokens") {
252
+ output[field.wire] = value.join(" ");
223
253
  continue;
254
+ }
224
255
  output[field.wire] = value.map((entry) => (entry instanceof URL ? entry.href : entry));
225
256
  continue;
226
257
  }