@sdxc/well-known 2026.10.25 → 2026.11.3
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/README.md +30 -0
- package/dist/cli-contract.d.ts +32 -0
- package/dist/cli-contract.js +70 -0
- package/dist/cli-fetch.d.ts +22 -0
- package/dist/cli-fetch.js +174 -0
- package/dist/cli-input.d.ts +56 -0
- package/dist/cli-input.js +89 -0
- package/dist/cli-security-txt-check.d.ts +19 -0
- package/dist/cli-security-txt-check.js +66 -0
- package/dist/cli.d.ts +29 -0
- package/dist/cli.js +18 -0
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -102,6 +102,36 @@ let router = createRouter({
|
|
|
102
102
|
`wellKnown` answers the names it lists with the media type, a cache policy, an `ETag`, CORS where
|
|
103
103
|
the specification requires it and a 304 for a current copy; every other path reaches your routes.
|
|
104
104
|
|
|
105
|
+
## Command line
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
bunx @sdxc/well-known --help
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
With [`@sdxc/kit`](https://www.npmjs.com/package/@sdxc/kit) installed, `sdxc pkg well-known …` runs the same command line, using the version your project installs.
|
|
112
|
+
|
|
113
|
+
Check a site's security.txt, or a file before you deploy it; it exits 1 when a required field is
|
|
114
|
+
missing, a line is malformed or `Expires` has passed:
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
bunx @sdxc/well-known security-txt check https://example.com
|
|
118
|
+
bunx @sdxc/well-known security-txt check public/.well-known/security.txt --json
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Fetch, validate and print a document; OAuth and OpenID Connect metadata must name the URL you
|
|
122
|
+
pass as their issuer, and NodeInfo follows its links to the newest schema:
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
bunx @sdxc/well-known fetch https://auth.example.com openid-configuration
|
|
126
|
+
bunx @sdxc/well-known fetch https://example.com webfinger --resource acct:me@example.com
|
|
127
|
+
bunx @sdxc/well-known fetch https://example.com jwks.json --json
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`fetch` reads `webfinger`, `nodeinfo`, `oauth-authorization-server`, `openid-configuration`,
|
|
131
|
+
`oauth-protected-resource`, `jwks.json`, `passkey-endpoints`, `api-catalog` and
|
|
132
|
+
`http-message-signatures-directory`. Exit codes are 0 valid, 1 problems found, 2 usage or an
|
|
133
|
+
unreadable file or origin.
|
|
134
|
+
|
|
105
135
|
## API
|
|
106
136
|
|
|
107
137
|
### `@sdxc/well-known`
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `sdxc-well-known` command line as a contract: `security-txt check` for a site's or a
|
|
3
|
+
* file's security.txt, and `fetch` for any JSON document the package reads. Help, usage
|
|
4
|
+
* errors and each handler's typed `ctx.flags` all come from this one declaration.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/** The documents `fetch` reads, by the registered name they are served under. */
|
|
10
|
+
export declare const DOCUMENTS: readonly ["webfinger", "nodeinfo", "oauth-authorization-server", "openid-configuration", "oauth-protected-resource", "jwks.json", "passkey-endpoints", "api-catalog", "http-message-signatures-directory"];
|
|
11
|
+
/** A document name `fetch` accepts. */
|
|
12
|
+
export type DocumentName = (typeof DOCUMENTS)[number];
|
|
13
|
+
/** Every command `sdxc-well-known` takes. */
|
|
14
|
+
export declare const WELL_KNOWN_COMMANDS: import("@sdxc/cli").Group<Omit<{
|
|
15
|
+
readonly description: "sdxc-well-known — read and check /.well-known/ documents";
|
|
16
|
+
readonly "security-txt": import("@sdxc/cli").Group<Omit<{
|
|
17
|
+
readonly description: "security.txt (RFC 9116)";
|
|
18
|
+
readonly check: import("@sdxc/cli").Command<{
|
|
19
|
+
readonly target: import("@sdxc/cli").Argument<string>;
|
|
20
|
+
}, {
|
|
21
|
+
readonly json: import("@sdxc/cli").Flag<boolean>;
|
|
22
|
+
}>;
|
|
23
|
+
}, "description" | "hidden" | "arguments" | "flags" | "tasks">, Record<never, never>, Record<never, never>>;
|
|
24
|
+
readonly fetch: import("@sdxc/cli").Command<{
|
|
25
|
+
readonly identifier: import("@sdxc/cli").Argument<string>;
|
|
26
|
+
readonly document: import("@sdxc/cli").Argument<"api-catalog" | "webfinger" | "nodeinfo" | "oauth-authorization-server" | "openid-configuration" | "oauth-protected-resource" | "jwks.json" | "passkey-endpoints" | "http-message-signatures-directory">;
|
|
27
|
+
}, {
|
|
28
|
+
readonly resource: import("@sdxc/cli").ValueFlag<string, false, false, false>;
|
|
29
|
+
readonly rel: import("@sdxc/cli").ValueFlag<string, false, true, false>;
|
|
30
|
+
readonly json: import("@sdxc/cli").Flag<boolean>;
|
|
31
|
+
}>;
|
|
32
|
+
}, "description" | "hidden" | "arguments" | "flags" | "tasks">, Record<never, never>, Record<never, never>>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `sdxc-well-known` command line as a contract: `security-txt check` for a site's or a
|
|
3
|
+
* file's security.txt, and `fetch` for any JSON document the package reads. Help, usage
|
|
4
|
+
* errors and each handler's typed `ctx.flags` all come from this one declaration.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import * as s from "@remix-run/data-schema";
|
|
10
|
+
import { argument, command, commands, flag } from "@sdxc/cli";
|
|
11
|
+
/** The documents `fetch` reads, by the registered name they are served under. */
|
|
12
|
+
export const DOCUMENTS = [
|
|
13
|
+
"webfinger",
|
|
14
|
+
"nodeinfo",
|
|
15
|
+
"oauth-authorization-server",
|
|
16
|
+
"openid-configuration",
|
|
17
|
+
"oauth-protected-resource",
|
|
18
|
+
"jwks.json",
|
|
19
|
+
"passkey-endpoints",
|
|
20
|
+
"api-catalog",
|
|
21
|
+
"http-message-signatures-directory",
|
|
22
|
+
];
|
|
23
|
+
/** Every command `sdxc-well-known` takes. */
|
|
24
|
+
export const WELL_KNOWN_COMMANDS = commands({
|
|
25
|
+
description: "sdxc-well-known — read and check /.well-known/ documents",
|
|
26
|
+
"security-txt": commands({
|
|
27
|
+
description: "security.txt (RFC 9116)",
|
|
28
|
+
check: command({
|
|
29
|
+
description: "Check a security.txt: required fields, field syntax and expiry. An http(s) origin fetches its /.well-known/security.txt; anything else is a file, - or nothing reads stdin. Exits 1 and lists every problem.",
|
|
30
|
+
arguments: {
|
|
31
|
+
target: argument(s.string(), {
|
|
32
|
+
default: "-",
|
|
33
|
+
description: "An origin (https://example.com), a file, or - for stdin",
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
flags: {
|
|
37
|
+
json: flag.boolean("json", { description: "Print the report as JSON" }),
|
|
38
|
+
},
|
|
39
|
+
examples: [
|
|
40
|
+
"security-txt check https://example.com",
|
|
41
|
+
"security-txt check public/.well-known/security.txt --json",
|
|
42
|
+
],
|
|
43
|
+
}),
|
|
44
|
+
}),
|
|
45
|
+
fetch: command({
|
|
46
|
+
description: "Fetch one well-known document from an origin, validate it and print it. OAuth and OpenID Connect metadata must name the identifier as their issuer, and protected resource metadata as its resource. Exits 1 and lists every problem on stderr.",
|
|
47
|
+
arguments: {
|
|
48
|
+
identifier: argument(s.string(), {
|
|
49
|
+
description: "The origin, issuer or resource URL (https://example.com)",
|
|
50
|
+
}),
|
|
51
|
+
document: argument(s.enum_(DOCUMENTS), {
|
|
52
|
+
description: `The document to read: ${DOCUMENTS.join(", ")}`,
|
|
53
|
+
}),
|
|
54
|
+
},
|
|
55
|
+
flags: {
|
|
56
|
+
resource: flag.string("resource", {
|
|
57
|
+
description: "The WebFinger resource to look up (acct:user@example.com)",
|
|
58
|
+
}),
|
|
59
|
+
rel: flag.string("rel", { description: "A WebFinger link relation to ask for" }).multiple(),
|
|
60
|
+
json: flag.boolean("json", {
|
|
61
|
+
description: "Print { document, url, valid, issues, data } with the parsed document",
|
|
62
|
+
}),
|
|
63
|
+
},
|
|
64
|
+
examples: [
|
|
65
|
+
"fetch https://auth.example.com openid-configuration",
|
|
66
|
+
"fetch https://mastodon.social webfinger --resource acct:Gargron@mastodon.social",
|
|
67
|
+
"fetch https://example.com jwks.json --json",
|
|
68
|
+
],
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `sdxc-well-known fetch` handler: builds the URL a document lives at for an identifier,
|
|
3
|
+
* fetches it, validates it with the document's own reader and prints it. NodeInfo follows its
|
|
4
|
+
* links document to the newest schema it names, so the printed document is the server's own.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Prints the validated body on stdout, or each problem as `<pointer>: message` on stderr;
|
|
11
|
+
* `--json` prints `{ document, url, valid, issues, data }` with the parsed, camelCase document.
|
|
12
|
+
* NodeInfo answers 2.1 when its links name it, else 2.0.
|
|
13
|
+
*/
|
|
14
|
+
declare const _default: import("@sdxc/cli").CommandAction<import("@sdxc/cli").Command<{
|
|
15
|
+
readonly identifier: import("@sdxc/cli").Argument<string>;
|
|
16
|
+
readonly document: import("@sdxc/cli").Argument<"api-catalog" | "webfinger" | "nodeinfo" | "oauth-authorization-server" | "openid-configuration" | "oauth-protected-resource" | "jwks.json" | "passkey-endpoints" | "http-message-signatures-directory">;
|
|
17
|
+
}, {
|
|
18
|
+
readonly resource: import("@sdxc/cli").ValueFlag<string, false, false, false>;
|
|
19
|
+
readonly rel: import("@sdxc/cli").ValueFlag<string, false, true, false>;
|
|
20
|
+
readonly json: import("@sdxc/cli").Flag<boolean>;
|
|
21
|
+
}>, []>;
|
|
22
|
+
export default _default;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `sdxc-well-known fetch` handler: builds the URL a document lives at for an identifier,
|
|
3
|
+
* fetches it, validates it with the document's own reader and prints it. NodeInfo follows its
|
|
4
|
+
* links document to the newest schema it names, so the printed document is the server's own.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { createCommand, exit, success, usage } from "@sdxc/cli";
|
|
10
|
+
import { isFailure } from "@sdxc/result";
|
|
11
|
+
import * as apiCatalog from "./api-catalog.js";
|
|
12
|
+
import { WELL_KNOWN_COMMANDS } from "./cli-contract.js";
|
|
13
|
+
import { fetchDocument, readWebUrl, statusIssue } from "./cli-input.js";
|
|
14
|
+
import * as signaturesDirectory from "./http-message-signatures-directory.js";
|
|
15
|
+
import * as jwks from "./jwks.js";
|
|
16
|
+
import * as nodeinfo from "./nodeinfo.js";
|
|
17
|
+
import * as authorizationServer from "./oauth-authorization-server.js";
|
|
18
|
+
import * as protectedResource from "./oauth-protected-resource.js";
|
|
19
|
+
import * as openIdConfiguration from "./openid-configuration.js";
|
|
20
|
+
import * as passkeyEndpoints from "./passkey-endpoints.js";
|
|
21
|
+
import * as webfinger from "./webfinger.js";
|
|
22
|
+
import { wellKnownUrl } from "./well-known-url.js";
|
|
23
|
+
/**
|
|
24
|
+
* The document `name` serves at the root of the identifier's origin.
|
|
25
|
+
*
|
|
26
|
+
* @param name - The registered name
|
|
27
|
+
*/
|
|
28
|
+
function atOrigin(name) {
|
|
29
|
+
return (identifier) => success(wellKnownUrl(new URL(identifier).origin, name));
|
|
30
|
+
}
|
|
31
|
+
/** Every document `fetch` reads; NodeInfo's entry reads the links document it starts from. */
|
|
32
|
+
const READERS = {
|
|
33
|
+
webfinger: {
|
|
34
|
+
accept: webfinger.MEDIA_TYPE,
|
|
35
|
+
url(identifier, flags) {
|
|
36
|
+
if (flags.resource === undefined) {
|
|
37
|
+
return usage("webfinger needs --resource, such as --resource acct:user@example.com");
|
|
38
|
+
}
|
|
39
|
+
let url = wellKnownUrl(new URL(identifier).origin, webfinger.NAME);
|
|
40
|
+
url.searchParams.set("resource", flags.resource);
|
|
41
|
+
for (let rel of flags.rel)
|
|
42
|
+
url.searchParams.append("rel", rel);
|
|
43
|
+
return success(url);
|
|
44
|
+
},
|
|
45
|
+
parse: (text) => webfinger.parse(text),
|
|
46
|
+
},
|
|
47
|
+
nodeinfo: {
|
|
48
|
+
accept: nodeinfo.MEDIA_TYPE,
|
|
49
|
+
url: atOrigin(nodeinfo.NAME),
|
|
50
|
+
parse: (text) => nodeinfo.parseLinks(text),
|
|
51
|
+
},
|
|
52
|
+
"oauth-authorization-server": {
|
|
53
|
+
accept: authorizationServer.MEDIA_TYPE,
|
|
54
|
+
url: (identifier) => success(wellKnownUrl(identifier, authorizationServer.NAME)),
|
|
55
|
+
parse: (text, issuer) => authorizationServer.parse(text, { issuer }),
|
|
56
|
+
},
|
|
57
|
+
"openid-configuration": {
|
|
58
|
+
accept: openIdConfiguration.MEDIA_TYPE,
|
|
59
|
+
url: (identifier) => success(wellKnownUrl(identifier, openIdConfiguration.NAME, "append")),
|
|
60
|
+
parse: (text, issuer) => openIdConfiguration.parse(text, { issuer }),
|
|
61
|
+
},
|
|
62
|
+
"oauth-protected-resource": {
|
|
63
|
+
accept: protectedResource.MEDIA_TYPE,
|
|
64
|
+
url: (identifier) => success(protectedResource.metadataUrl(identifier)),
|
|
65
|
+
parse: (text, resource) => protectedResource.parse(text, { resource }),
|
|
66
|
+
},
|
|
67
|
+
"jwks.json": {
|
|
68
|
+
accept: jwks.MEDIA_TYPE,
|
|
69
|
+
url: atOrigin(jwks.NAME),
|
|
70
|
+
parse: (text) => jwks.parse(text),
|
|
71
|
+
},
|
|
72
|
+
"passkey-endpoints": {
|
|
73
|
+
accept: passkeyEndpoints.MEDIA_TYPE,
|
|
74
|
+
url: atOrigin(passkeyEndpoints.NAME),
|
|
75
|
+
parse: (text) => passkeyEndpoints.parse(text),
|
|
76
|
+
},
|
|
77
|
+
"api-catalog": {
|
|
78
|
+
accept: apiCatalog.MEDIA_TYPE,
|
|
79
|
+
url: atOrigin(apiCatalog.NAME),
|
|
80
|
+
parse: (text) => apiCatalog.parse(text),
|
|
81
|
+
},
|
|
82
|
+
"http-message-signatures-directory": {
|
|
83
|
+
accept: signaturesDirectory.MEDIA_TYPE,
|
|
84
|
+
url: atOrigin(signaturesDirectory.NAME),
|
|
85
|
+
parse: (text) => signaturesDirectory.parse(text),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Reads a fetched body, turning a non-2xx status into the outcome's only issue.
|
|
90
|
+
*
|
|
91
|
+
* @param retrieved - The fetched document
|
|
92
|
+
* @param parse - The document's reader
|
|
93
|
+
*/
|
|
94
|
+
function read(retrieved, parse) {
|
|
95
|
+
let unavailable = statusIssue(retrieved);
|
|
96
|
+
if (unavailable !== null)
|
|
97
|
+
return { retrieved, data: null, issues: [unavailable] };
|
|
98
|
+
let parsed = parse(retrieved.text);
|
|
99
|
+
if (isFailure(parsed))
|
|
100
|
+
return { retrieved, data: null, issues: parsed.error.issues };
|
|
101
|
+
return { retrieved, data: parsed.data, issues: [] };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The served body for people: indented when it is JSON, verbatim otherwise.
|
|
105
|
+
*
|
|
106
|
+
* @param text - The body as served
|
|
107
|
+
*/
|
|
108
|
+
function pretty(text) {
|
|
109
|
+
try {
|
|
110
|
+
return `${JSON.stringify(JSON.parse(text), null, 2)}\n`;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return text.endsWith("\n") ? text : `${text}\n`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Prints the validated body on stdout, or each problem as `<pointer>: message` on stderr;
|
|
118
|
+
* `--json` prints `{ document, url, valid, issues, data }` with the parsed, camelCase document.
|
|
119
|
+
* NodeInfo answers 2.1 when its links name it, else 2.0.
|
|
120
|
+
*/
|
|
121
|
+
export default createCommand(WELL_KNOWN_COMMANDS.fetch, async (ctx) => {
|
|
122
|
+
let { identifier, document } = ctx.arguments;
|
|
123
|
+
let checked = readWebUrl(identifier);
|
|
124
|
+
if (isFailure(checked))
|
|
125
|
+
return checked;
|
|
126
|
+
let reader = READERS[document];
|
|
127
|
+
let url = reader.url(identifier, ctx.flags);
|
|
128
|
+
if (isFailure(url))
|
|
129
|
+
return url;
|
|
130
|
+
let retrieved = await fetchDocument(url.data, reader.accept, ctx);
|
|
131
|
+
if (isFailure(retrieved))
|
|
132
|
+
return retrieved;
|
|
133
|
+
let outcome = read(retrieved.data, (text) => reader.parse(text, identifier));
|
|
134
|
+
if (document === "nodeinfo" && outcome.issues.length === 0) {
|
|
135
|
+
let { links } = outcome.data;
|
|
136
|
+
let link = links.find((entry) => entry.rel === nodeinfo.NODEINFO_2_1) ??
|
|
137
|
+
links.find((entry) => entry.rel === nodeinfo.NODEINFO_2_0);
|
|
138
|
+
if (link === undefined) {
|
|
139
|
+
outcome = {
|
|
140
|
+
...outcome,
|
|
141
|
+
data: null,
|
|
142
|
+
issues: [{ at: "/links", message: "No link names a NodeInfo 2.0 or 2.1 schema." }],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
let linked = await fetchDocument(new URL(link.href), nodeinfo.DOCUMENT_MEDIA_TYPE, ctx);
|
|
147
|
+
if (isFailure(linked))
|
|
148
|
+
return linked;
|
|
149
|
+
outcome = read(linked.data, nodeinfo.parse);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
let valid = outcome.issues.length === 0;
|
|
153
|
+
if (ctx.flags.json) {
|
|
154
|
+
let report = {
|
|
155
|
+
document,
|
|
156
|
+
url: outcome.retrieved.from,
|
|
157
|
+
valid,
|
|
158
|
+
issues: outcome.issues,
|
|
159
|
+
data: outcome.data,
|
|
160
|
+
};
|
|
161
|
+
ctx.stdout(`${JSON.stringify(report, null, 2)}\n`);
|
|
162
|
+
}
|
|
163
|
+
else if (valid) {
|
|
164
|
+
ctx.stdout(pretty(outcome.retrieved.text));
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
ctx.stderr(`${outcome.retrieved.from}\n`);
|
|
168
|
+
for (let issue of outcome.issues) {
|
|
169
|
+
let at = issue.at === "" || issue.at === 0 ? "(document)" : String(issue.at);
|
|
170
|
+
ctx.stderr(` ${at}: ${issue.message}\n`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return valid ? success() : exit(1);
|
|
174
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where `sdxc-well-known` reads a document from: a file, stdin, or an origin over HTTP. Every
|
|
3
|
+
* command shares it, so an unreadable file and an unreachable origin both exit 2 with the same
|
|
4
|
+
* kind of message, while a server's error status stays a finding the command reports.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { ChainContext, ExitError, UsageError } from "@sdxc/cli";
|
|
10
|
+
import type { Result } from "@sdxc/result";
|
|
11
|
+
import { WellKnownParseError } from "./parse-error.js";
|
|
12
|
+
/** A document as read, with `from` naming it in reports and `status` set when it was fetched. */
|
|
13
|
+
export interface Retrieved {
|
|
14
|
+
from: string;
|
|
15
|
+
text: string;
|
|
16
|
+
/** The HTTP status for a fetched document, `null` for a file or stdin. */
|
|
17
|
+
status: number | null;
|
|
18
|
+
}
|
|
19
|
+
/** The run's directory, stdin and abort signal, which every read needs. */
|
|
20
|
+
export type InputContext = Pick<ChainContext, "cwd" | "stdin" | "signal">;
|
|
21
|
+
/**
|
|
22
|
+
* Whether text names a web origin rather than a file.
|
|
23
|
+
*
|
|
24
|
+
* @param text - An argument as typed
|
|
25
|
+
*/
|
|
26
|
+
export declare function isWebUrl(text: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* An `http(s)` URL from an argument, or a usage error naming what was expected.
|
|
29
|
+
*
|
|
30
|
+
* @param text - The argument as typed
|
|
31
|
+
*/
|
|
32
|
+
export declare function readWebUrl(text: string): Result<URL, UsageError>;
|
|
33
|
+
/**
|
|
34
|
+
* Reads `file` against the run's directory, or stdin for `-`. A terminal on stdin is a usage
|
|
35
|
+
* error, so a command run with no input answers at once with its help.
|
|
36
|
+
*
|
|
37
|
+
* @param file - The path as typed, or `-`
|
|
38
|
+
* @param ctx - The running command
|
|
39
|
+
*/
|
|
40
|
+
export declare function readLocal(file: string, ctx: InputContext): Promise<Result<Retrieved, ExitError | UsageError>>;
|
|
41
|
+
/**
|
|
42
|
+
* GETs `url`, following redirects, within a fixed deadline. Any answer the server gives is a
|
|
43
|
+
* success carrying its status; only a request that gets no answer exits 2.
|
|
44
|
+
*
|
|
45
|
+
* @param url - The document's URL
|
|
46
|
+
* @param accept - The media types the request asks for
|
|
47
|
+
* @param ctx - The running command, whose signal cancels the request
|
|
48
|
+
*/
|
|
49
|
+
export declare function fetchDocument(url: URL, accept: string, ctx: InputContext): Promise<Result<Retrieved, ExitError>>;
|
|
50
|
+
/**
|
|
51
|
+
* The issue a non-2xx answer becomes, so a missing document is reported like a malformed one.
|
|
52
|
+
*
|
|
53
|
+
* @param retrieved - A document as read
|
|
54
|
+
* @returns The issue, or `null` for a file, stdin or a 2xx answer
|
|
55
|
+
*/
|
|
56
|
+
export declare function statusIssue(retrieved: Retrieved): WellKnownParseError.Issue | null;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where `sdxc-well-known` reads a document from: a file, stdin, or an origin over HTTP. Every
|
|
3
|
+
* command shares it, so an unreadable file and an unreachable origin both exit 2 with the same
|
|
4
|
+
* kind of message, while a server's error status stays a finding the command reports.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import { exit, usage } from "@sdxc/cli";
|
|
12
|
+
import { success } from "@sdxc/result";
|
|
13
|
+
import { WellKnownParseError } from "./parse-error.js";
|
|
14
|
+
/** How long one request may take before the command gives up on the origin. */
|
|
15
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
16
|
+
/**
|
|
17
|
+
* Whether text names a web origin rather than a file.
|
|
18
|
+
*
|
|
19
|
+
* @param text - An argument as typed
|
|
20
|
+
*/
|
|
21
|
+
export function isWebUrl(text) {
|
|
22
|
+
return /^https?:\/\//i.test(text);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* An `http(s)` URL from an argument, or a usage error naming what was expected.
|
|
26
|
+
*
|
|
27
|
+
* @param text - The argument as typed
|
|
28
|
+
*/
|
|
29
|
+
export function readWebUrl(text) {
|
|
30
|
+
if (isWebUrl(text) && URL.canParse(text))
|
|
31
|
+
return success(new URL(text));
|
|
32
|
+
return usage(`Expected an http(s) URL such as https://example.com; got ${JSON.stringify(text)}`);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Reads `file` against the run's directory, or stdin for `-`. A terminal on stdin is a usage
|
|
36
|
+
* error, so a command run with no input answers at once with its help.
|
|
37
|
+
*
|
|
38
|
+
* @param file - The path as typed, or `-`
|
|
39
|
+
* @param ctx - The running command
|
|
40
|
+
*/
|
|
41
|
+
export async function readLocal(file, ctx) {
|
|
42
|
+
if (file === "-") {
|
|
43
|
+
if (ctx.stdin.isTTY)
|
|
44
|
+
return usage("Pass an origin or a file, or pipe one on stdin");
|
|
45
|
+
return success({ from: "<stdin>", text: await ctx.stdin.text(), status: null });
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
let text = await readFile(resolve(ctx.cwd, file), "utf8");
|
|
49
|
+
return success({ from: file, text, status: null });
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
let reason = error instanceof Error ? error.message : String(error);
|
|
53
|
+
return exit(2, `Cannot read ${file}: ${reason}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* GETs `url`, following redirects, within a fixed deadline. Any answer the server gives is a
|
|
58
|
+
* success carrying its status; only a request that gets no answer exits 2.
|
|
59
|
+
*
|
|
60
|
+
* @param url - The document's URL
|
|
61
|
+
* @param accept - The media types the request asks for
|
|
62
|
+
* @param ctx - The running command, whose signal cancels the request
|
|
63
|
+
*/
|
|
64
|
+
export async function fetchDocument(url, accept, ctx) {
|
|
65
|
+
try {
|
|
66
|
+
let response = await fetch(url, {
|
|
67
|
+
headers: { Accept: accept },
|
|
68
|
+
redirect: "follow",
|
|
69
|
+
signal: AbortSignal.any([ctx.signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]),
|
|
70
|
+
});
|
|
71
|
+
return success({ from: url.href, text: await response.text(), status: response.status });
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
let reason = error instanceof Error ? error.message : String(error);
|
|
75
|
+
return exit(2, `Cannot fetch ${url.href}: ${reason}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The issue a non-2xx answer becomes, so a missing document is reported like a malformed one.
|
|
80
|
+
*
|
|
81
|
+
* @param retrieved - A document as read
|
|
82
|
+
* @returns The issue, or `null` for a file, stdin or a 2xx answer
|
|
83
|
+
*/
|
|
84
|
+
export function statusIssue(retrieved) {
|
|
85
|
+
if (retrieved.status === null || (retrieved.status >= 200 && retrieved.status < 300)) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return { at: 0, message: `The server answered HTTP ${retrieved.status}.` };
|
|
89
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `sdxc-well-known security-txt check` handler: reads a security.txt from an origin, a
|
|
3
|
+
* file or stdin, applies the RFC 9116 reader and the expiry rule, and reports every problem
|
|
4
|
+
* with its line. Exits 1 when the file is missing, malformed or expired.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Prints `<source>: valid, expires <date>` or one `<source>:<line>: message` per problem, a
|
|
11
|
+
* line of `0` meaning the file as a whole; `--json` prints `{ source, valid, signed, expires,
|
|
12
|
+
* issues }`, with `signed` and `expires` `null` when the file could not be read.
|
|
13
|
+
*/
|
|
14
|
+
declare const _default: import("@sdxc/cli").CommandAction<import("@sdxc/cli").Command<{
|
|
15
|
+
readonly target: import("@sdxc/cli").Argument<string>;
|
|
16
|
+
}, {
|
|
17
|
+
readonly json: import("@sdxc/cli").Flag<boolean>;
|
|
18
|
+
}>, []>;
|
|
19
|
+
export default _default;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `sdxc-well-known security-txt check` handler: reads a security.txt from an origin, a
|
|
3
|
+
* file or stdin, applies the RFC 9116 reader and the expiry rule, and reports every problem
|
|
4
|
+
* with its line. Exits 1 when the file is missing, malformed or expired.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { createCommand, exit, success } from "@sdxc/cli";
|
|
10
|
+
import { isFailure } from "@sdxc/result";
|
|
11
|
+
import { WELL_KNOWN_COMMANDS } from "./cli-contract.js";
|
|
12
|
+
import { fetchDocument, isWebUrl, readLocal, statusIssue } from "./cli-input.js";
|
|
13
|
+
import { isExpired, MEDIA_TYPE, NAME, parse } from "./security-txt.js";
|
|
14
|
+
import { wellKnownUrl } from "./well-known-url.js";
|
|
15
|
+
/**
|
|
16
|
+
* Prints `<source>: valid, expires <date>` or one `<source>:<line>: message` per problem, a
|
|
17
|
+
* line of `0` meaning the file as a whole; `--json` prints `{ source, valid, signed, expires,
|
|
18
|
+
* issues }`, with `signed` and `expires` `null` when the file could not be read.
|
|
19
|
+
*/
|
|
20
|
+
export default createCommand(WELL_KNOWN_COMMANDS["security-txt"].check, async (ctx) => {
|
|
21
|
+
let target = ctx.arguments.target;
|
|
22
|
+
let retrieved = isWebUrl(target)
|
|
23
|
+
? await fetchDocument(wellKnownUrl(new URL(target).origin, NAME), MEDIA_TYPE, ctx)
|
|
24
|
+
: await readLocal(target, ctx);
|
|
25
|
+
if (isFailure(retrieved))
|
|
26
|
+
return retrieved;
|
|
27
|
+
let from = retrieved.data.from;
|
|
28
|
+
let issues = [];
|
|
29
|
+
let document = null;
|
|
30
|
+
let unavailable = statusIssue(retrieved.data);
|
|
31
|
+
if (unavailable !== null) {
|
|
32
|
+
issues.push(unavailable);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
let parsed = parse(retrieved.data.text);
|
|
36
|
+
if (isFailure(parsed)) {
|
|
37
|
+
issues.push(...parsed.error.issues);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
document = parsed.data;
|
|
41
|
+
if (isExpired(document)) {
|
|
42
|
+
issues.push({ at: 0, message: `Expires ${document.expires.toISOString()} has passed.` });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (ctx.flags.json) {
|
|
47
|
+
let report = {
|
|
48
|
+
source: from,
|
|
49
|
+
valid: issues.length === 0,
|
|
50
|
+
signed: document?.signed ?? null,
|
|
51
|
+
expires: document?.expires ?? null,
|
|
52
|
+
issues,
|
|
53
|
+
};
|
|
54
|
+
ctx.stdout(`${JSON.stringify(report, null, 2)}\n`);
|
|
55
|
+
}
|
|
56
|
+
else if (issues.length === 0 && document !== null) {
|
|
57
|
+
let signed = document.signed ? ", signed" : "";
|
|
58
|
+
ctx.stdout(`${from}: valid, expires ${document.expires.toISOString()}${signed}\n`);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
for (let issue of issues) {
|
|
62
|
+
ctx.stdout(issue.at === 0 ? `${from}: ${issue.message}\n` : `${from}:${issue.at}: ${issue.message}\n`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return issues.length === 0 ? success() : exit(1);
|
|
66
|
+
});
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The `sdxc-well-known` command-line program: the contract in `cli-contract.ts` with each
|
|
4
|
+
* command mapped onto its handler, which loads only when that command runs. Exit codes: 0
|
|
5
|
+
* done, 1 a document with problems, 2 usage, unreadable input or an unreachable origin.
|
|
6
|
+
*
|
|
7
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
8
|
+
* @copyright Sergio Xalambrí 2026
|
|
9
|
+
*/
|
|
10
|
+
/** The `sdxc-well-known` program, run by the bin entry below and by tests through `invoke()`. */
|
|
11
|
+
export declare const cli: import("@sdxc/cli").CLI<import("@sdxc/cli").Group<Omit<{
|
|
12
|
+
readonly description: "sdxc-well-known — read and check /.well-known/ documents";
|
|
13
|
+
readonly "security-txt": import("@sdxc/cli").Group<Omit<{
|
|
14
|
+
readonly description: "security.txt (RFC 9116)";
|
|
15
|
+
readonly check: import("@sdxc/cli").Command<{
|
|
16
|
+
readonly target: import("@sdxc/cli").Argument<string>;
|
|
17
|
+
}, {
|
|
18
|
+
readonly json: import("@sdxc/cli").Flag<boolean>;
|
|
19
|
+
}>;
|
|
20
|
+
}, "description" | "hidden" | "arguments" | "flags" | "tasks">, Record<never, never>, Record<never, never>>;
|
|
21
|
+
readonly fetch: import("@sdxc/cli").Command<{
|
|
22
|
+
readonly identifier: import("@sdxc/cli").Argument<string>;
|
|
23
|
+
readonly document: import("@sdxc/cli").Argument<"api-catalog" | "webfinger" | "nodeinfo" | "oauth-authorization-server" | "openid-configuration" | "oauth-protected-resource" | "jwks.json" | "passkey-endpoints" | "http-message-signatures-directory">;
|
|
24
|
+
}, {
|
|
25
|
+
readonly resource: import("@sdxc/cli").ValueFlag<string, false, false, false>;
|
|
26
|
+
readonly rel: import("@sdxc/cli").ValueFlag<string, false, true, false>;
|
|
27
|
+
readonly json: import("@sdxc/cli").Flag<boolean>;
|
|
28
|
+
}>;
|
|
29
|
+
}, "description" | "hidden" | "arguments" | "flags" | "tasks">, Record<never, never>, Record<never, never>>, []>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The `sdxc-well-known` command-line program: the contract in `cli-contract.ts` with each
|
|
4
|
+
* command mapped onto its handler, which loads only when that command runs. Exit codes: 0
|
|
5
|
+
* done, 1 a document with problems, 2 usage, unreadable input or an unreachable origin.
|
|
6
|
+
*
|
|
7
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
8
|
+
* @copyright Sergio Xalambrí 2026
|
|
9
|
+
*/
|
|
10
|
+
import { createCLI, lazy } from "@sdxc/cli";
|
|
11
|
+
import { WELL_KNOWN_COMMANDS } from "./cli-contract.js";
|
|
12
|
+
/** The `sdxc-well-known` program, run by the bin entry below and by tests through `invoke()`. */
|
|
13
|
+
export const cli = createCLI({ name: "sdxc-well-known", commands: WELL_KNOWN_COMMANDS });
|
|
14
|
+
cli.map(WELL_KNOWN_COMMANDS["security-txt"].check, lazy(() => import("./cli-security-txt-check.js")));
|
|
15
|
+
cli.map(WELL_KNOWN_COMMANDS.fetch, lazy(() => import("./cli-fetch.js")));
|
|
16
|
+
if (import.meta.main) {
|
|
17
|
+
process.exit(await cli.run(process.argv.slice(2)));
|
|
18
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/well-known",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.11.3",
|
|
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
|
+
"bin": {
|
|
7
|
+
"sdxc-well-known": "dist/cli.js"
|
|
8
|
+
},
|
|
6
9
|
"type": "module",
|
|
7
10
|
"exports": {
|
|
8
11
|
".": "./dist/index.js",
|
|
@@ -23,14 +26,15 @@
|
|
|
23
26
|
},
|
|
24
27
|
"dependencies": {
|
|
25
28
|
"@remix-run/data-schema": "^1.0.0",
|
|
29
|
+
"@sdxc/cli": "2026.11.3",
|
|
26
30
|
"@sdxc/json-pointer": "2026.10.14",
|
|
27
31
|
"@sdxc/link-header": "2026.10.25",
|
|
28
|
-
"@sdxc/openpgp": "2026.
|
|
32
|
+
"@sdxc/openpgp": "2026.11.3",
|
|
29
33
|
"@sdxc/result": "2026.10.13",
|
|
30
34
|
"@standard-schema/spec": "^1.1.0"
|
|
31
35
|
},
|
|
32
36
|
"peerDependencies": {
|
|
33
|
-
"@sdxc/http": "2026.
|
|
37
|
+
"@sdxc/http": "2026.11.3",
|
|
34
38
|
"remix": "3.1.0"
|
|
35
39
|
},
|
|
36
40
|
"peerDependenciesMeta": {
|
|
@@ -41,7 +45,7 @@
|
|
|
41
45
|
"optional": true
|
|
42
46
|
}
|
|
43
47
|
},
|
|
44
|
-
"gitHead": "
|
|
48
|
+
"gitHead": "d1dd4030f8df68b8a170940a3f790ea8654d3676",
|
|
45
49
|
"publishConfig": {
|
|
46
50
|
"access": "public"
|
|
47
51
|
},
|