@sdxc/well-known 2026.10.9 → 2026.10.13

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 (2) hide show
  1. package/README.md +345 -200
  2. package/package.json +4 -4
package/README.md CHANGED
@@ -2,202 +2,192 @@
2
2
 
3
3
  Typed documents for well-known URIs: security.txt, WebFinger, NodeInfo, OAuth and OIDC metadata, JWKS and more.
4
4
 
5
+ [RFC 8615](https://www.rfc-editor.org/rfc/rfc8615) reserves `/.well-known/` for documents a client
6
+ finds on any origin without being told where to look. Each registered name is one subpath of this
7
+ package that reads, writes and describes its document, with camelCase fields and `URL` values; the
8
+ specification's member names appear only in the serialized text.
9
+
5
10
  ## Installation
6
11
 
7
- ```bash
12
+ ```sh
8
13
  npm add @sdxc/well-known
9
14
  ```
10
15
 
11
- Readers return [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result) values, which
12
- install alongside this package. `./response` and `./middleware` also need
16
+ Readers return [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result) values, installed
17
+ alongside. Serving through `./response` and `./middleware` also needs
13
18
  [`@sdxc/http`](https://www.npmjs.com/package/@sdxc/http) and
14
- [`remix`](https://www.npmjs.com/package/remix), optional peer dependencies you add when you
15
- serve documents; the document subpaths work without them.
16
-
17
- [RFC 8615](https://www.rfc-editor.org/rfc/rfc8615) reserves `/.well-known/` for documents a
18
- client finds on any origin without being told where to look. Each registered name is one
19
- subpath, with camelCase fields and `URL` values; the specification's member names appear only
20
- in the serialized text. Every reader returns a `Result` whose failure, `WellKnownParseError`,
21
- lists every issue with a JSON Pointer (or a line number for security.txt).
19
+ [`remix`](https://www.npmjs.com/package/remix).
22
20
 
23
21
  ## Usage
24
22
 
25
- ### Serve security.txt And WebFinger From A Router
23
+ ### Find a document's URL
24
+
25
+ ```typescript
26
+ import { wellKnownUrl } from "@sdxc/well-known";
27
+
28
+ wellKnownUrl("https://auth.example.com/tenant", "oauth-authorization-server");
29
+ // https://auth.example.com/.well-known/oauth-authorization-server/tenant
30
+ wellKnownUrl("https://auth.example.com/tenant", "openid-configuration", "append");
31
+ // https://auth.example.com/tenant/.well-known/openid-configuration
32
+ ```
33
+
34
+ OAuth metadata inserts the suffix between the host and the path; OpenID Connect Discovery appends
35
+ it to the issuer.
36
+
37
+ ### Read a fetched document
26
38
 
27
39
  ```typescript
28
40
  import { isFailure } from "@sdxc/result";
29
- import { serve, wellKnown } from "@sdxc/well-known/middleware";
30
- import { securityTxt } from "@sdxc/well-known/security-txt";
31
- import { readQuery, select, webFinger } from "@sdxc/well-known/webfinger";
32
- import type { SecurityTxt } from "@sdxc/well-known/security-txt";
33
- import { createRouter } from "remix/router";
41
+ import { wellKnownUrl } from "@sdxc/well-known";
42
+ import { parse } from "@sdxc/well-known/oauth-authorization-server";
34
43
 
35
- const SECURITY_TXT: SecurityTxt = {
36
- contact: [new URL("mailto:security@example.com")],
37
- expires: new Date("2027-06-30T00:00:00Z"),
38
- encryption: [],
39
- acknowledgments: [],
40
- preferredLanguages: ["en"],
41
- canonical: [new URL("https://example.com/.well-known/security.txt")],
42
- policy: [],
43
- hiring: [],
44
- extensions: {},
45
- };
44
+ let issuer = "https://auth.example.com";
45
+ let response = await fetch(wellKnownUrl(issuer, "oauth-authorization-server"));
46
+ let metadata = parse(await response.text(), { issuer });
46
47
 
47
- let router = createRouter({
48
- middleware: [
49
- wellKnown({
50
- "security.txt": serve(securityTxt, () => SECURITY_TXT),
51
- webfinger: serve(webFinger, (ctx) => {
52
- let query = readQuery(ctx.url);
53
- if (isFailure(query)) return null;
54
- return select(profileFor(query.data.resource), query.data.rels);
55
- }),
56
- }),
57
- ],
58
- });
48
+ if (isFailure(metadata)) {
49
+ metadata.error.issues; // [{ at: "/token_endpoint", message: "..." }, ...]
50
+ } else {
51
+ metadata.data.tokenEndpoint; // URL | null
52
+ }
59
53
  ```
60
54
 
61
- ### Publish OpenID Connect Discovery From An Action
55
+ Every reader returns a `Result`. Its failure, `WellKnownParseError`, lists every issue at once,
56
+ each located by a JSON Pointer (or a line number for security.txt).
57
+
58
+ ### Write a document
62
59
 
63
60
  ```typescript
64
- import { define, openIdConfiguration } from "@sdxc/well-known/openid-configuration";
65
- import { respond } from "@sdxc/well-known/response";
66
- import { createAction } from "remix/router";
61
+ import { define, stringify } from "@sdxc/well-known/oauth-protected-resource";
67
62
 
68
- const DISCOVERY = define({
69
- issuer: "https://auth.example.com",
70
- authorizationEndpoint: new URL("https://auth.example.com/authorize"),
71
- jwksUri: new URL("https://auth.example.com/.well-known/jwks.json"),
72
- responseTypesSupported: ["code"],
73
- subjectTypesSupported: ["public"],
74
- idTokenSigningAlgValuesSupported: ["ES256"],
63
+ let metadata = define({
64
+ resource: new URL("https://api.example.com/v1"),
65
+ authorizationServers: [new URL("https://auth.example.com")],
66
+ scopesSupported: ["read", "write"],
75
67
  });
76
68
 
77
- export default createAction(routes.discovery, (ctx) =>
78
- respond(openIdConfiguration, DISCOVERY, { request: ctx.request }),
79
- );
69
+ stringify(metadata);
70
+ // {"resource":"https://api.example.com/v1","authorization_servers":[...],...}
80
71
  ```
81
72
 
82
- ### Read A Provider's Metadata
73
+ `define` fills every member you leave out with its default, and `stringify` writes only what
74
+ differs from it.
75
+
76
+ ### Serve it
83
77
 
84
78
  ```typescript
85
- import { isFailure } from "@sdxc/result";
86
- import { wellKnownUrl } from "@sdxc/well-known";
87
- import { parse } from "@sdxc/well-known/oauth-authorization-server";
79
+ import { securityTxt } from "@sdxc/well-known/security-txt";
80
+ import { serve, wellKnown } from "@sdxc/well-known/middleware";
81
+ import { createRouter } from "remix/router";
88
82
 
89
- let issuer = "https://auth.example.com";
90
- let url = wellKnownUrl(issuer, "oauth-authorization-server");
91
- let metadata = parse(await (await fetch(url)).text(), { issuer });
92
- if (isFailure(metadata)) return metadata;
93
- metadata.data.tokenEndpoint; // URL | null
83
+ let router = createRouter({
84
+ middleware: [
85
+ wellKnown({
86
+ "security.txt": serve(securityTxt, () => ({
87
+ contact: [new URL("mailto:security@example.com")],
88
+ expires: new Date("2027-06-30T00:00:00Z"),
89
+ encryption: [],
90
+ acknowledgments: [],
91
+ preferredLanguages: ["en"],
92
+ canonical: [new URL("https://example.com/.well-known/security.txt")],
93
+ policy: [],
94
+ hiring: [],
95
+ extensions: {},
96
+ })),
97
+ }),
98
+ ],
99
+ });
94
100
  ```
95
101
 
102
+ `wellKnown` answers the names it lists with the media type, a cache policy, an `ETag`, CORS where
103
+ the specification requires it and a 304 for a current copy; every other path reaches your routes.
104
+
96
105
  ## API
97
106
 
98
107
  ### `@sdxc/well-known`
99
108
 
100
- #### `wellKnownUrl(identifier, name, placement = "insert")`
101
-
102
- The URL a document is served at for an identifier. `"insert"` (RFC 8414, RFC 9728) puts the
103
- suffix between the host and the path, keeping the query; `"append"` (OIDC Discovery) adds it
104
- after the path.
109
+ #### `wellKnownUrl(identifier: URL | string, name: string, placement?: WellKnownPlacement): URL`
105
110
 
106
- ```typescript
107
- wellKnownUrl("https://as.example/tenant", "oauth-authorization-server");
108
- // https://as.example/.well-known/oauth-authorization-server/tenant
109
- wellKnownUrl("https://op.example/tenant", "openid-configuration", "append");
110
- // https://op.example/tenant/.well-known/openid-configuration
111
- ```
111
+ The URL a document is served at for an identifier. `"insert"`, the default, puts the suffix
112
+ between the host and the path and keeps the query; `"append"` adds it after the path.
112
113
 
113
114
  #### `WellKnownParseError`
114
115
 
115
- The failure every reader returns: `format` (the registered name) and `issues`, each with `at`
116
- (a JSON Pointer, `""` for the whole document; or a 1-based line number, `0` for the whole
117
- file) and `message`.
116
+ The failure every reader returns, with `format` (the registered name) and `issues`, each an `at`
117
+ (a JSON Pointer, `""` for the whole document; or a 1-based line, `0` for the whole file) and a
118
+ `message`.
118
119
 
119
- #### `WellKnownFormat<Document>` and `WellKnownPlacement`
120
+ #### `WellKnownFormat<Document>`
120
121
 
121
122
  The descriptor each document subpath exports: `name`, `mediaType`, `placement`, `cors`,
122
- `stringify` and `parse`. `respond` and `serve` take one.
123
+ `stringify` and `parse`. `respond` and `serve` take one; its `parse` reads structure only, so a
124
+ client checking a fetched document's issuer or resource calls the subpath's own `parse`.
123
125
 
124
- ### Every Document Subpath
126
+ ### Every document subpath
125
127
 
126
- Each exports `NAME`, `MEDIA_TYPE`, `parse(text)` returning a `Result`, `stringify(document)`
127
- and a descriptor. The descriptor's `parse` reads structure only; a client reading a fetched
128
- discovery document calls the subpath's `parse` with the identifier it expected.
128
+ Each exports `NAME`, `MEDIA_TYPE`, `parse(text)`, `stringify(document)` and a descriptor.
129
129
 
130
130
  ### `@sdxc/well-known/security-txt` (RFC 9116)
131
131
 
132
- - `SecurityTxt` has `contact` (at least one), `expires`, `encryption`, `acknowledgments`,
133
- `preferredLanguages`, `canonical`, `policy`, `hiring`, and `extensions` for other fields,
134
- keyed by lowercased name.
135
- - `parse(text)` returns a `ParsedSecurityTxt`, with `signed` set when the text arrived inside
136
- an OpenPGP cleartext signature (read from its signed body; the signature is left
137
- unverified). It fails on a missing `Contact`, a missing or repeated `Expires`, a repeated
138
- `Preferred-Languages`, an `http` URI, and a line that is neither a field, a comment nor
139
- blank. An expired file parses.
140
- - `stringify(document, options?)` writes unsigned text with LF endings; `StringifyOptions`
141
- takes `comments` to write at the top.
142
- - `isExpired(document, now?)` answers staleness (§2.5.5).
132
+ - `parse(text): Result<ParsedSecurityTxt, WellKnownParseError>` reads signed or unsigned text,
133
+ setting `signed` for an OpenPGP cleartext signature, which it leaves unverified. It fails on a
134
+ missing `Contact`, a missing or repeated `Expires`, a repeated `Preferred-Languages`, an `http`
135
+ URI and a malformed line; an expired file parses.
136
+ - `stringify(document, options?: StringifyOptions): string` writes unsigned text with LF endings,
137
+ with `options.comments` at the top.
138
+ - `isExpired(document, now?: Date): boolean` answers whether `expires` has passed.
143
139
  - `securityTxt` is the descriptor.
144
140
 
145
141
  ### `@sdxc/well-known/webfinger` (RFC 7033)
146
142
 
147
- - `Jrd` and `JrdLink` are the descriptor and its links; absent members read as `null`, `[]`
148
- or `{}`.
149
- - `readQuery(url)` returns a `WebFingerQuery` (`{ resource, rels }`), or a
150
- `MissingResourceError` to answer with a 400.
151
- - `select(document, rels)` keeps only the links whose `rel` was asked for; an empty list keeps
152
- them all.
153
- - `webFinger` is the descriptor, with `cors: true` as §5 requires.
143
+ - `readQuery(url: URL): Result<WebFingerQuery, MissingResourceError>` reads `resource` and every
144
+ `rel`; the failure is the request to answer with a 400.
145
+ - `select(document: Jrd, rels: string[]): Jrd` keeps only the links whose `rel` was asked for; an
146
+ empty list keeps them all.
147
+ - `parse` and `stringify` read and write a `Jrd`; `webFinger` is the descriptor, served with CORS.
154
148
 
155
149
  ### `@sdxc/well-known/oauth-authorization-server` (RFC 8414)
156
150
 
157
- - `AuthorizationServerMetadata<Extensions>` has every §2 member plus RFC 8628, RFC 9126,
158
- RFC 9207 and RFC 9728 §4 members. `issuer` is a string, compared as published.
159
- - `parse(text, options?)` takes `ParseOptions` (`issuer`, `extensions`) and fails on a missing
160
- `issuer` or `response_types_supported`, a malformed member, or a different issuer (§3.3).
161
- Unknown members land in `extensions`, validated by a synchronous
162
- [Standard Schema](https://standardschema.dev/) when one is given.
163
- - `define(document)` defaults lists to `[]`, URLs to `null` and flags to `false`.
164
- - `stringify(document)` leaves out `null` members, empty lists and flags at their default; the
165
- standard member wins over an extension of the same name.
151
+ - `parse(text, options?: ParseOptions)` fails on a missing `issuer` or `response_types_supported`,
152
+ a malformed member, or an `issuer` other than `options.issuer`. Members outside the
153
+ specification land in `extensions` under their serialized names, validated by
154
+ `options.extensions`, any synchronous [Standard Schema](https://standardschema.dev/).
155
+ - `define(document)` takes `issuer` and `responseTypesSupported` and defaults lists to `[]`, URLs
156
+ to `null` and flags to `false`.
157
+ - `stringify(document): string` writes extensions beside the standard members, which win a name
158
+ clash.
166
159
  - `authorizationServerMetadata` is the descriptor.
167
160
 
168
161
  ### `@sdxc/well-known/openid-configuration` (OpenID Connect Discovery 1.0)
169
162
 
170
- `OpenIdProviderMetadata` extends the RFC 8414 document with the §3 members, and makes
171
- `authorizationEndpoint` and `jwksUri` required. `parse`, `define`, `stringify` and
172
- `ParseOptions` work as above; `requestUriParameterSupported` defaults to `true`, as §3 states.
173
- `openIdConfiguration` is the descriptor, with `placement: "append"`.
163
+ `OpenIdProviderMetadata` adds the Discovery members to the RFC 8414 document. `define` also
164
+ requires `authorizationEndpoint`, `jwksUri`, `subjectTypesSupported` and
165
+ `idTokenSigningAlgValuesSupported`, and defaults `requestUriParameterSupported` to `true`. `parse`,
166
+ `stringify` and `ParseOptions` work as above; `openIdConfiguration` is the descriptor, with
167
+ `placement: "append"`.
174
168
 
175
169
  ### `@sdxc/well-known/oauth-protected-resource` (RFC 9728)
176
170
 
177
- - `ProtectedResourceMetadata<Extensions>` has every §2 member; `resourceName` is a
178
- `LocalizedString` (`value` plus `#`-tagged `translations`), and `BearerMethod` is
179
- `"header" | "body" | "query"`.
180
- - `parse(text, options)` requires `ParseOptions.resource`: the document's `resource` must
181
- equal it, or with `match: "prefix"` be a same-origin, segment-aligned prefix of it (§3.3).
182
- - `define(document)` defaults `bearerMethodsSupported` to `["header"]`.
183
- - `metadataUrl(resource)` inserts the suffix before the resource's path.
184
- - `protectedResourceMetadata` is the descriptor.
185
-
186
- ### `@sdxc/well-known/jwks` (RFC 7517 §5)
187
-
188
- - `Jwk` keeps the registered member names Web Crypto imports; `JwkSet` is `{ keys }`.
189
- - `parse(text)` returns a `ParsedJwkSet`, dropping entries that are not objects, lack `kty`,
190
- or lack a member their `EC`, `RSA`, `OKP` or `oct` key type requires, and counting them in
191
- `skipped`.
192
- - `stringify(document)` returns a `Result` and fails on any private member (`d`, `p`, `q`,
193
- `dp`, `dq`, `qi`, `oth`, `k`).
194
- - `jwks` is the descriptor; serving through it publishes each key's public half and leaves
195
- symmetric keys out.
171
+ - `parse(text, options: ParseOptions)` requires the expected `options.resource`: the document's
172
+ `resource` must equal it, or with `match: "prefix"` be a same-origin, segment-aligned prefix of
173
+ it. `extensions` works as for RFC 8414.
174
+ - `define(document)` takes `resource` and defaults `bearerMethodsSupported` to `["header"]`.
175
+ - `metadataUrl(resource: URL | string): URL` is where a resource's metadata is served.
176
+ - `stringify` writes the document; `protectedResourceMetadata` is the descriptor.
177
+
178
+ ### `@sdxc/well-known/jwks` (RFC 7517)
179
+
180
+ - `parse(text): Result<ParsedJwkSet, WellKnownParseError>` drops entries without a `kty` or the
181
+ members their `EC`, `RSA`, `OKP` or `oct` key type requires, counting them in `skipped`.
182
+ - `stringify(document: JwkSet): Result<string, WellKnownParseError>` fails on any private member
183
+ (`d`, `p`, `q`, `dp`, `dq`, `qi`, `oth`, `k`).
184
+ - `jwks` is the descriptor. Serving through it publishes each key's public half and leaves
185
+ symmetric keys out, so private keys can go in directly.
196
186
 
197
187
  ### `@sdxc/well-known/change-password` (W3C)
198
188
 
199
- `redirect(target)` is a 302 to the page where a signed-in person changes their password.
200
- `NAME` is `"change-password"`.
189
+ `redirect(target: URL | string): Response` is a 302 to the page where a signed-in person changes
190
+ their password. The name has no document, so it exports `NAME` and no descriptor.
201
191
 
202
192
  ### `@sdxc/well-known/passkey-endpoints` (W3C)
203
193
 
@@ -206,47 +196,146 @@ object is a valid document. `passkeyEndpoints` is the descriptor.
206
196
 
207
197
  ### `@sdxc/well-known/nodeinfo` (NodeInfo 2.0 and 2.1)
208
198
 
209
- - `NodeInfoLinks` is the `/.well-known/nodeinfo` document, `{ links }`, each `NodeInfoLink`
210
- a `rel` (the schema URI, `NODEINFO_2_0` or `NODEINFO_2_1`) and an absolute `href`.
211
- `parseLinks(text)` and `stringifyLinks(document)` read and write it; `nodeInfoLinks` is its
212
- descriptor, served as `application/json` with `cors: true`.
213
- - `NodeInfo` has `version`, `software` (`name`, `version`, and 2.1's `repository` and
214
- `homepage`, `null` when absent), `protocols`, `services` (`inbound`, `outbound`),
215
- `openRegistrations`, `usage` (`users` with `total`, `activeMonth`, `activeHalfyear`, plus
216
- `localPosts` and `localComments`, each `number | null`) and `metadata`.
217
- - `parse(text)` reads 2.0 and 2.1 and checks the schema: `software.name` uses lowercase
218
- letters, digits and hyphens, at least one protocol, every protocol and service a non-empty
219
- string, and non-negative integer counts. Names outside the schema's registry, such as
220
- `atproto`, are kept; the types autocomplete the registered ones. A 2.0 document reads with `repository` and `homepage`
221
- `null`.
222
- - `stringify(document)` writes 2.1 whatever `version` says, leaving out `null` members.
223
- - `nodeInfo` is the descriptor for the document itself, served at the path the links
224
- document names with `DOCUMENT_MEDIA_TYPE`, the 2.1 profile.
199
+ - `parseLinks(text)` and `stringifyLinks(document)` read and write the `/.well-known/nodeinfo`
200
+ discovery document, `{ links }`, each link a schema `rel` (`NODEINFO_2_0` or `NODEINFO_2_1`) and
201
+ an absolute `href`. `nodeInfoLinks` is its descriptor, served with CORS.
202
+ - `parse(text): Result<NodeInfo, WellKnownParseError>` reads 2.0 and 2.1 against the schema, a 2.0
203
+ document with `software.repository` and `software.homepage` as `null`. Protocol and service
204
+ names outside the schema's registry are kept.
205
+ - `stringify(document: NodeInfo): string` always writes 2.1, leaving out `null` members.
206
+ - `nodeInfo` is the descriptor for the document itself, served at the `href` the links name with
207
+ `DOCUMENT_MEDIA_TYPE`, the 2.1 profile.
225
208
 
226
209
  ### `@sdxc/well-known/response`
227
210
 
228
- #### `respond(format, document, options?)`
211
+ #### `respond(format, document, options?: RespondOptions): Promise<Response>`
229
212
 
230
- A `Promise<Response>` with the document's media type, a `Cache-Control` (default
231
- `public, max-age=3600`), an `ETag` over the body, CORS for a CORS format, a 304 when
232
- `options.request`'s `If-None-Match` still matches, and an empty body for `HEAD`.
233
- `RespondOptions` takes `request`, `cache` and `headers`, which apply last.
213
+ The document with its media type, a `Cache-Control` (`options.cache`, default public for one hour),
214
+ an `ETag` over the body and CORS for a CORS format. With `options.request` it answers 304 when
215
+ `If-None-Match` still matches and an empty body for `HEAD`. `options.headers` apply last.
234
216
 
235
217
  ### `@sdxc/well-known/middleware`
236
218
 
237
- #### `serve(format, produce, options?)`
219
+ #### `serve(format, produce: (ctx) => Document | null, options?): WellKnownEntry`
220
+
221
+ Answers with `produce(ctx)`'s document through `respond`, built per request; `null` falls through
222
+ to the next middleware. `produce` may be async, and `options` takes `cache` and `headers`.
223
+
224
+ #### `wellKnown(entries: Record<string, WellKnownEntry>): Middleware`
238
225
 
239
- A `WellKnownEntry` answering with `produce(ctx)`'s document through `respond`; a `null`
240
- document falls through to the next middleware.
226
+ Answers `GET` and `HEAD` on `/.well-known/<name>` for each name given, and on
227
+ `/.well-known/<name>/<path>` for a `serve`d format that inserts its suffix. A CORS format also
228
+ answers `OPTIONS`; any other method gets a 405 with `Allow`. Every other path goes to `next()`.
241
229
 
242
- #### `wellKnown(entries)`
230
+ ### Types
243
231
 
244
- Answers `GET` and `HEAD` on `/.well-known/<name>` for the names given (and on
245
- `/.well-known/<name>/<path>` for a `serve`d format that inserts its suffix), `OPTIONS` for a
246
- CORS format, and 405 with `Allow` for other methods. Every other path goes to `next()`, so a
247
- name of your own stays an ordinary route. An entry is any `(ctx) => Response | null`.
232
+ `WellKnownPlacement` is `"insert" | "append"`. `WellKnownEntry` is any
233
+ `(ctx) => Response | null`, sync or async. Each document subpath exports its document type
234
+ (`SecurityTxt`, `Jrd`, `AuthorizationServerMetadata<Extensions>`, `OpenIdProviderMetadata<Extensions>`,
235
+ `ProtectedResourceMetadata<Extensions>`, `JwkSet`, `PasskeyEndpoints`, `NodeInfo`,
236
+ `NodeInfoLinks`) and the types of its members (`ParsedSecurityTxt`, `JrdLink`, `WebFingerQuery`,
237
+ `LocalizedString`, `BearerMethod`, `Jwk`, `ParsedJwkSet`, `NodeInfoSoftware`, `NodeInfoUsage` and
238
+ the rest).
248
239
 
249
- ## Pattern: Metadata For An API Accepting Bearer Tokens
240
+ ## Pattern: security.txt, WebFinger And Change-Password On One Router
241
+
242
+ ```typescript
243
+ import { isFailure } from "@sdxc/result";
244
+ import { redirect } from "@sdxc/well-known/change-password";
245
+ import { serve, wellKnown } from "@sdxc/well-known/middleware";
246
+ import { isExpired, securityTxt } from "@sdxc/well-known/security-txt";
247
+ import type { SecurityTxt } from "@sdxc/well-known/security-txt";
248
+ import { readQuery, select, webFinger } from "@sdxc/well-known/webfinger";
249
+ import type { Jrd } from "@sdxc/well-known/webfinger";
250
+ import { createRouter } from "remix/router";
251
+
252
+ export const SECURITY_TXT: SecurityTxt = {
253
+ contact: [new URL("mailto:security@example.com")],
254
+ expires: new Date("2027-06-30T00:00:00Z"),
255
+ encryption: [],
256
+ acknowledgments: [],
257
+ preferredLanguages: ["en"],
258
+ canonical: [new URL("https://example.com/.well-known/security.txt")],
259
+ policy: [new URL("https://example.com/security-policy")],
260
+ hiring: [],
261
+ extensions: {},
262
+ };
263
+
264
+ declare function findProfile(resource: string): Promise<Jrd | null>;
265
+
266
+ let router = createRouter({
267
+ middleware: [
268
+ wellKnown({
269
+ "security.txt": serve(securityTxt, () => SECURITY_TXT),
270
+ webfinger: async (ctx) => {
271
+ let query = readQuery(ctx.url);
272
+ if (isFailure(query)) return new Response(query.error.message, { status: 400 });
273
+ return serve(webFinger, async () => {
274
+ let profile = await findProfile(query.data.resource);
275
+ return profile === null ? null : select(profile, query.data.rels);
276
+ })(ctx);
277
+ },
278
+ "change-password": () => redirect("/account/password"),
279
+ }),
280
+ ],
281
+ });
282
+
283
+ let inThirtyDays = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
284
+ isExpired(SECURITY_TXT, inThirtyDays); // assert false in a test
285
+ ```
286
+
287
+ Keep `expires` a literal date and assert in a test that it is more than 30 days away: a date
288
+ computed from the clock keeps the file fresh while its contact goes stale. A `null` profile falls
289
+ through to your 404.
290
+
291
+ ## Pattern: OpenID Connect Discovery With Its Key Set
292
+
293
+ ```typescript
294
+ import { serve, wellKnown } from "@sdxc/well-known/middleware";
295
+ import { jwks } from "@sdxc/well-known/jwks";
296
+ import type { Jwk } from "@sdxc/well-known/jwks";
297
+ import { define, openIdConfiguration } from "@sdxc/well-known/openid-configuration";
298
+ import { respond } from "@sdxc/well-known/response";
299
+ import { createRouter } from "remix/router";
300
+
301
+ let issuer = "https://auth.example.com";
302
+
303
+ let discovery = define({
304
+ issuer,
305
+ authorizationEndpoint: new URL("/authorize", issuer),
306
+ tokenEndpoint: new URL("/token", issuer),
307
+ jwksUri: new URL("/.well-known/jwks.json", issuer),
308
+ responseTypesSupported: ["code"],
309
+ subjectTypesSupported: ["public"],
310
+ idTokenSigningAlgValuesSupported: ["ES256"],
311
+ codeChallengeMethodsSupported: ["S256"],
312
+ });
313
+
314
+ declare function signingKeys(): Promise<Jwk[]>;
315
+
316
+ let router = createRouter({
317
+ middleware: [
318
+ wellKnown({
319
+ "openid-configuration": serve(openIdConfiguration, () => discovery),
320
+ "jwks.json": serve(jwks, async () => ({ keys: await signingKeys() })),
321
+ }),
322
+ ],
323
+ });
324
+
325
+ // an issuer with a path serves discovery after it, from its own route
326
+ router.get("/tenant/.well-known/openid-configuration", (ctx) =>
327
+ respond(openIdConfiguration, define({ ...discovery, issuer: `${issuer}/tenant` }), {
328
+ request: ctx.request,
329
+ cache: { visibility: "public", maxAge: "5 minutes" },
330
+ }),
331
+ );
332
+ ```
333
+
334
+ `signingKeys()` may answer private JWKs: the `jwks` descriptor serves each key's public half and
335
+ leaves symmetric keys out. Call `stringify` from `./jwks` instead when a private member should be
336
+ reported as an error.
337
+
338
+ ## Pattern: Protected Resource Metadata For A Bearer-Token API
250
339
 
251
340
  ```typescript
252
341
  import { serve, wellKnown } from "@sdxc/well-known/middleware";
@@ -256,34 +345,104 @@ import {
256
345
  protectedResourceMetadata,
257
346
  } from "@sdxc/well-known/oauth-protected-resource";
258
347
 
259
- const API_METADATA = define({
348
+ let api = define({
260
349
  resource: new URL("https://api.example.com/v1"),
261
350
  authorizationServers: [new URL("https://auth.example.com")],
262
351
  scopesSupported: ["read", "write"],
352
+ resourceName: { value: "Example API", translations: { es: "API de ejemplo" } },
263
353
  });
264
354
 
265
355
  let middleware = wellKnown({
266
- "oauth-protected-resource": serve(protectedResourceMetadata, () => API_METADATA),
356
+ "oauth-protected-resource": serve(protectedResourceMetadata, () => api),
267
357
  });
268
358
 
269
- let challenge = `Bearer resource_metadata="${metadataUrl(API_METADATA.resource)}"`;
359
+ function unauthorized() {
360
+ return new Response(null, {
361
+ status: 401,
362
+ headers: {
363
+ "WWW-Authenticate": `Bearer resource_metadata="${metadataUrl(api.resource)}"`,
364
+ },
365
+ });
366
+ }
270
367
  ```
271
368
 
369
+ The document is served at `/.well-known/oauth-protected-resource/v1`, which `wellKnown` matches
370
+ because the format inserts its suffix before the resource's path.
371
+
372
+ ## Pattern: Discovering An Authorization Server From A Resource
373
+
374
+ A client handed only an API URL follows the resource's metadata to its authorization server,
375
+ checking each document names the identifier it was fetched for.
376
+
377
+ ```typescript
378
+ import { isFailure } from "@sdxc/result";
379
+ import { wellKnownUrl } from "@sdxc/well-known";
380
+ import * as authorizationServer from "@sdxc/well-known/oauth-authorization-server";
381
+ import * as protectedResource from "@sdxc/well-known/oauth-protected-resource";
382
+ import * as s from "remix/data-schema";
383
+
384
+ let resource = "https://api.example.com/v1/items";
385
+
386
+ let resourceResponse = await fetch(protectedResource.metadataUrl("https://api.example.com/v1"));
387
+ let api = protectedResource.parse(await resourceResponse.text(), { resource, match: "prefix" });
388
+ if (isFailure(api)) throw api.error;
389
+
390
+ let issuer = api.data.authorizationServers[0];
391
+ if (issuer === undefined) throw new Error("The API names no authorization server");
392
+
393
+ let serverResponse = await fetch(wellKnownUrl(issuer, "oauth-authorization-server"));
394
+ let server = authorizationServer.parse(await serverResponse.text(), {
395
+ issuer,
396
+ extensions: s.object({ userinfo_endpoint: s.optional(s.string()) }),
397
+ });
398
+ if (isFailure(server)) throw server.error;
399
+
400
+ server.data.tokenEndpoint; // URL | null
401
+ server.data.extensions.userinfo_endpoint; // string | undefined
402
+ ```
403
+
404
+ ## Pattern: Importing A Fetched Key Set
405
+
406
+ ```typescript
407
+ import { isFailure } from "@sdxc/result";
408
+ import { parse } from "@sdxc/well-known/jwks";
409
+
410
+ let response = await fetch("https://auth.example.com/.well-known/jwks.json");
411
+ let set = parse(await response.text());
412
+ if (isFailure(set)) throw set.error;
413
+
414
+ let keys = new Map<string, CryptoKey>();
415
+ for (let key of set.data.keys) {
416
+ if (key.kty !== "EC" || key.kid === undefined) continue;
417
+ let algorithm = { name: "ECDSA", namedCurve: key.crv ?? "P-256" };
418
+ keys.set(key.kid, await crypto.subtle.importKey("jwk", key, algorithm, false, ["verify"]));
419
+ }
420
+ ```
421
+
422
+ A malformed entry is dropped and counted in `set.data.skipped`, so one bad key leaves the others
423
+ usable.
424
+
272
425
  ## Pattern: Advertising A Server Through NodeInfo
273
426
 
274
427
  ```typescript
275
428
  import { serve, wellKnown } from "@sdxc/well-known/middleware";
276
429
  import { NODEINFO_2_1, nodeInfo, nodeInfoLinks } from "@sdxc/well-known/nodeinfo";
277
430
  import { respond } from "@sdxc/well-known/response";
278
- import { createAction } from "remix/router";
431
+ import { createRouter } from "remix/router";
279
432
 
280
- let middleware = wellKnown({
281
- nodeinfo: serve(nodeInfoLinks, () => ({
282
- links: [{ rel: NODEINFO_2_1, href: "https://example.com/nodeinfo/2.1" }],
283
- })),
433
+ declare function countUsers(): Promise<number>;
434
+
435
+ let router = createRouter({
436
+ middleware: [
437
+ wellKnown({
438
+ nodeinfo: serve(nodeInfoLinks, () => ({
439
+ links: [{ rel: NODEINFO_2_1, href: "https://example.com/nodeinfo/2.1" }],
440
+ })),
441
+ }),
442
+ ],
284
443
  });
285
444
 
286
- export default createAction(routes.nodeInfo, (ctx) =>
445
+ router.get("/nodeinfo/2.1", async (ctx) =>
287
446
  respond(
288
447
  nodeInfo,
289
448
  {
@@ -293,8 +452,8 @@ export default createAction(routes.nodeInfo, (ctx) =>
293
452
  services: { inbound: [], outbound: ["rss2.0"] },
294
453
  openRegistrations: false,
295
454
  usage: {
296
- users: { total: 1, activeMonth: 1, activeHalfyear: 1 },
297
- localPosts: 42,
455
+ users: { total: await countUsers(), activeMonth: null, activeHalfyear: null },
456
+ localPosts: null,
298
457
  localComments: null,
299
458
  },
300
459
  metadata: {},
@@ -304,38 +463,24 @@ export default createAction(routes.nodeInfo, (ctx) =>
304
463
  );
305
464
  ```
306
465
 
307
- ## Pattern: A Change-Password Redirect Beside Documents
466
+ The links document lives under `/.well-known/`; the document it points to is an ordinary route,
467
+ answered through `respond`.
468
+
469
+ ## Pattern: Passkey Endpoints For Password Managers
308
470
 
309
471
  ```typescript
310
- import { redirect } from "@sdxc/well-known/change-password";
311
472
  import { serve, wellKnown } from "@sdxc/well-known/middleware";
312
- import { securityTxt } from "@sdxc/well-known/security-txt";
473
+ import { passkeyEndpoints } from "@sdxc/well-known/passkey-endpoints";
313
474
 
314
475
  let middleware = wellKnown({
315
- "security.txt": serve(securityTxt, () => SECURITY_TXT),
316
- "change-password": () => redirect("/account/password"),
476
+ "passkey-endpoints": serve(passkeyEndpoints, () => ({
477
+ enroll: new URL("https://example.com/account/passkeys/new"),
478
+ manage: new URL("https://example.com/account/passkeys"),
479
+ prfUsageDetails: null,
480
+ })),
317
481
  });
318
482
  ```
319
483
 
320
- Give security.txt a literal `expires` date and a test that fails when it is under 30 days
321
- away; a date computed from the clock keeps the file fresh while its contact goes stale.
322
-
323
- ## Pattern: Importing A Fetched Key Set
324
-
325
- ```typescript
326
- import { isFailure } from "@sdxc/result";
327
- import { parse } from "@sdxc/well-known/jwks";
328
-
329
- let response = await fetch("https://auth.example.com/.well-known/jwks.json");
330
- let set = parse(await response.text());
331
- if (isFailure(set)) return set;
332
-
333
- let algorithm = { name: "ECDSA", namedCurve: "P-256" };
334
- for (let key of set.data.keys) {
335
- await crypto.subtle.importKey("jwk", key, algorithm, false, ["verify"]);
336
- }
337
- ```
338
-
339
484
  ## Versioning
340
485
 
341
486
  Releases are dated rather than semantic. A version is the UTC date it was published,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/well-known",
3
- "version": "2026.10.9",
3
+ "version": "2026.10.13",
4
4
  "description": "Typed documents for well-known URIs: security.txt, WebFinger, NodeInfo, OAuth and OIDC metadata, JWKS and more",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,11 +20,11 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@remix-run/data-schema": "^1.0.0",
23
- "@sdxc/result": "2026.10.6",
23
+ "@sdxc/result": "2026.10.13",
24
24
  "@standard-schema/spec": "^1.1.0"
25
25
  },
26
26
  "peerDependencies": {
27
- "@sdxc/http": "2026.10.9",
27
+ "@sdxc/http": "2026.10.13",
28
28
  "remix": "3.0.0"
29
29
  },
30
30
  "peerDependenciesMeta": {
@@ -35,7 +35,7 @@
35
35
  "optional": true
36
36
  }
37
37
  },
38
- "gitHead": "64fa732ed0a8ca8693a35aeef6345ae5be2534ef",
38
+ "gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
39
39
  "publishConfig": {
40
40
  "access": "public"
41
41
  },