@sdxc/well-known 2026.10.8 → 2026.10.10
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 +55 -1
- package/dist/nodeinfo.d.ts +123 -0
- package/dist/nodeinfo.js +303 -0
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @sdxc/well-known
|
|
2
2
|
|
|
3
|
-
Typed documents for well-known URIs: security.txt, WebFinger, OAuth and OIDC metadata, JWKS and more.
|
|
3
|
+
Typed documents for well-known URIs: security.txt, WebFinger, NodeInfo, OAuth and OIDC metadata, JWKS and more.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -204,6 +204,25 @@ discovery document calls the subpath's `parse` with the identifier it expected.
|
|
|
204
204
|
`PasskeyEndpoints` has `enroll`, `manage` and `prfUsageDetails`, each `URL | null`; an empty
|
|
205
205
|
object is a valid document. `passkeyEndpoints` is the descriptor.
|
|
206
206
|
|
|
207
|
+
### `@sdxc/well-known/nodeinfo` (NodeInfo 2.0 and 2.1)
|
|
208
|
+
|
|
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.
|
|
225
|
+
|
|
207
226
|
### `@sdxc/well-known/response`
|
|
208
227
|
|
|
209
228
|
#### `respond(format, document, options?)`
|
|
@@ -250,6 +269,41 @@ let middleware = wellKnown({
|
|
|
250
269
|
let challenge = `Bearer resource_metadata="${metadataUrl(API_METADATA.resource)}"`;
|
|
251
270
|
```
|
|
252
271
|
|
|
272
|
+
## Pattern: Advertising A Server Through NodeInfo
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
import { serve, wellKnown } from "@sdxc/well-known/middleware";
|
|
276
|
+
import { NODEINFO_2_1, nodeInfo, nodeInfoLinks } from "@sdxc/well-known/nodeinfo";
|
|
277
|
+
import { respond } from "@sdxc/well-known/response";
|
|
278
|
+
import { createAction } from "remix/router";
|
|
279
|
+
|
|
280
|
+
let middleware = wellKnown({
|
|
281
|
+
nodeinfo: serve(nodeInfoLinks, () => ({
|
|
282
|
+
links: [{ rel: NODEINFO_2_1, href: "https://example.com/nodeinfo/2.1" }],
|
|
283
|
+
})),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
export default createAction(routes.nodeInfo, (ctx) =>
|
|
287
|
+
respond(
|
|
288
|
+
nodeInfo,
|
|
289
|
+
{
|
|
290
|
+
version: "2.1",
|
|
291
|
+
software: { name: "example", version: "1.0.0", repository: null, homepage: null },
|
|
292
|
+
protocols: ["activitypub"],
|
|
293
|
+
services: { inbound: [], outbound: ["rss2.0"] },
|
|
294
|
+
openRegistrations: false,
|
|
295
|
+
usage: {
|
|
296
|
+
users: { total: 1, activeMonth: 1, activeHalfyear: 1 },
|
|
297
|
+
localPosts: 42,
|
|
298
|
+
localComments: null,
|
|
299
|
+
},
|
|
300
|
+
metadata: {},
|
|
301
|
+
},
|
|
302
|
+
{ request: ctx.request },
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
```
|
|
306
|
+
|
|
253
307
|
## Pattern: A Change-Password Redirect Beside Documents
|
|
254
308
|
|
|
255
309
|
```typescript
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NodeInfo: the `/.well-known/nodeinfo` links document and the NodeInfo 2.0/2.1 document
|
|
3
|
+
* it points at, so a server tells the fediverse which software it runs, which protocols
|
|
4
|
+
* it speaks and how many people use it. Reads 2.0 and 2.1, writes 2.1.
|
|
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 { JsonObject } from "./lib/json.js";
|
|
12
|
+
import { WellKnownParseError } from "./parse-error.js";
|
|
13
|
+
export declare const NAME = "nodeinfo";
|
|
14
|
+
export declare const MEDIA_TYPE = "application/json";
|
|
15
|
+
/** The schema URI that names NodeInfo 2.0, as a link's `rel`. */
|
|
16
|
+
export declare const NODEINFO_2_0 = "http://nodeinfo.diaspora.software/ns/schema/2.0";
|
|
17
|
+
/** The schema URI that names NodeInfo 2.1, as a link's `rel`. */
|
|
18
|
+
export declare const NODEINFO_2_1 = "http://nodeinfo.diaspora.software/ns/schema/2.1";
|
|
19
|
+
/** The `Content-Type` of a NodeInfo 2.1 document, whose profile names the schema it follows. */
|
|
20
|
+
export declare const DOCUMENT_MEDIA_TYPE = "application/json; profile=\"http://nodeinfo.diaspora.software/ns/schema/2.1#\"";
|
|
21
|
+
/**
|
|
22
|
+
* A protocol the server speaks: one the NodeInfo schema registers, or any other name a
|
|
23
|
+
* server publishes, such as `atproto`.
|
|
24
|
+
*/
|
|
25
|
+
export type NodeInfoProtocol = "activitypub" | "buddycloud" | "dfrn" | "diaspora" | "libertree" | "ostatus" | "pumpio" | "tent" | "xmpp" | "zot" | (string & {});
|
|
26
|
+
/** A site the server can retrieve messages from, registered or not. */
|
|
27
|
+
export type InboundService = "atom1.0" | "gnusocial" | "imap" | "pnut" | "pop3" | "pumpio" | "rss2.0" | "twitter" | (string & {});
|
|
28
|
+
/** A site the server can publish messages to, registered or not. */
|
|
29
|
+
export type OutboundService = "atom1.0" | "blogger" | "buddycloud" | "diaspora" | "dreamwidth" | "drupal" | "facebook" | "friendica" | "gnusocial" | "google" | "insanejournal" | "libertree" | "linkedin" | "livejournal" | "mediagoblin" | "myspace" | "pinterest" | "pnut" | "posterous" | "pumpio" | "redmatrix" | "rss2.0" | "smtp" | "tent" | "tumblr" | "twitter" | "wordpress" | "xmpp" | (string & {});
|
|
30
|
+
/** The schema versions this module reads; every write is 2.1. */
|
|
31
|
+
export type NodeInfoVersion = "2.0" | "2.1";
|
|
32
|
+
/** The `/.well-known/nodeinfo` document: where each NodeInfo version a server offers lives. */
|
|
33
|
+
export interface NodeInfoLinks {
|
|
34
|
+
links: NodeInfoLink[];
|
|
35
|
+
}
|
|
36
|
+
/** One NodeInfo version a server serves. */
|
|
37
|
+
export interface NodeInfoLink {
|
|
38
|
+
/** The schema URI, such as `NODEINFO_2_1`. */
|
|
39
|
+
rel: string;
|
|
40
|
+
/** The absolute URL of the document. */
|
|
41
|
+
href: string;
|
|
42
|
+
}
|
|
43
|
+
/** The NodeInfo document, in the 2.1 shape; a 2.0 document reads with 2.1's additions `null`. */
|
|
44
|
+
export interface NodeInfo {
|
|
45
|
+
/** The schema version the document was read as; `stringify` always writes `"2.1"`. */
|
|
46
|
+
version: NodeInfoVersion;
|
|
47
|
+
software: NodeInfoSoftware;
|
|
48
|
+
/** At least one protocol, as the schema requires. */
|
|
49
|
+
protocols: NodeInfoProtocol[];
|
|
50
|
+
services: NodeInfoServices;
|
|
51
|
+
openRegistrations: boolean;
|
|
52
|
+
usage: NodeInfoUsage;
|
|
53
|
+
/** Free-form, server-specific data. */
|
|
54
|
+
metadata: JsonObject;
|
|
55
|
+
}
|
|
56
|
+
/** The server software. */
|
|
57
|
+
export interface NodeInfoSoftware {
|
|
58
|
+
/** Lowercase letters, digits and hyphens only, as the schema's pattern requires. */
|
|
59
|
+
name: string;
|
|
60
|
+
version: string;
|
|
61
|
+
/** NodeInfo 2.1; `null` when absent and for every 2.0 document. */
|
|
62
|
+
repository: string | null;
|
|
63
|
+
/** NodeInfo 2.1; `null` when absent and for every 2.0 document. */
|
|
64
|
+
homepage: string | null;
|
|
65
|
+
}
|
|
66
|
+
/** The third-party sites the server bridges. */
|
|
67
|
+
export interface NodeInfoServices {
|
|
68
|
+
inbound: InboundService[];
|
|
69
|
+
outbound: OutboundService[];
|
|
70
|
+
}
|
|
71
|
+
/** Usage statistics; a count the server withholds is `null`. */
|
|
72
|
+
export interface NodeInfoUsage {
|
|
73
|
+
users: NodeInfoUsers;
|
|
74
|
+
localPosts: number | null;
|
|
75
|
+
localComments: number | null;
|
|
76
|
+
}
|
|
77
|
+
/** User counts; a count the server withholds is `null`. */
|
|
78
|
+
export interface NodeInfoUsers {
|
|
79
|
+
total: number | null;
|
|
80
|
+
activeMonth: number | null;
|
|
81
|
+
activeHalfyear: number | null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Reads the links document. Every link needs a string `rel` and an absolute `href`;
|
|
85
|
+
* any `rel` is kept, so a caller picks the version it supports.
|
|
86
|
+
*
|
|
87
|
+
* @param text - The served JSON.
|
|
88
|
+
* @example
|
|
89
|
+
* let links = parseLinks(body);
|
|
90
|
+
* let href = links.data.links.find((link) => link.rel === NODEINFO_2_1)?.href;
|
|
91
|
+
*/
|
|
92
|
+
export declare function parseLinks(text: string): Result<NodeInfoLinks, WellKnownParseError>;
|
|
93
|
+
/**
|
|
94
|
+
* Writes the links document.
|
|
95
|
+
*
|
|
96
|
+
* @param document - The versions to advertise.
|
|
97
|
+
*/
|
|
98
|
+
export declare function stringifyLinks(document: NodeInfoLinks): string;
|
|
99
|
+
/**
|
|
100
|
+
* Reads a NodeInfo 2.0 or 2.1 document, checking it against its schema: every required
|
|
101
|
+
* member present, `software.name` matching the pattern, at least one protocol, every
|
|
102
|
+
* protocol and service a non-empty string, and non-negative integer counts. Every issue is reported.
|
|
103
|
+
*
|
|
104
|
+
* @param text - The served JSON.
|
|
105
|
+
*/
|
|
106
|
+
export declare function parse(text: string): Result<NodeInfo, WellKnownParseError>;
|
|
107
|
+
/**
|
|
108
|
+
* Writes the document as NodeInfo 2.1 whatever version it was read as, which upgrades a
|
|
109
|
+
* 2.0 document since 2.1 only adds members. `null` members are left out.
|
|
110
|
+
*
|
|
111
|
+
* @param document - The document to publish.
|
|
112
|
+
*/
|
|
113
|
+
export declare function stringify(document: NodeInfo): string;
|
|
114
|
+
/**
|
|
115
|
+
* The `/.well-known/nodeinfo` links document as a servable format; every answer carries
|
|
116
|
+
* CORS `*` so a browser-based client reads it from any origin.
|
|
117
|
+
*/
|
|
118
|
+
export declare const nodeInfoLinks: WellKnownFormat<NodeInfoLinks>;
|
|
119
|
+
/**
|
|
120
|
+
* The NodeInfo document as a servable format, answered at the path the links document
|
|
121
|
+
* names (such as `/nodeinfo/2.1`) with the 2.1 profile in its media type.
|
|
122
|
+
*/
|
|
123
|
+
export declare const nodeInfo: WellKnownFormat<NodeInfo>;
|
package/dist/nodeinfo.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { failure, success } from "@sdxc/result";
|
|
2
|
+
import { isObject, pointer, readJsonObject } from "./lib/json.js";
|
|
3
|
+
import { WellKnownParseError } from "./parse-error.js";
|
|
4
|
+
export const NAME = "nodeinfo";
|
|
5
|
+
export const MEDIA_TYPE = "application/json";
|
|
6
|
+
/** The schema URI that names NodeInfo 2.0, as a link's `rel`. */
|
|
7
|
+
export const NODEINFO_2_0 = "http://nodeinfo.diaspora.software/ns/schema/2.0";
|
|
8
|
+
/** The schema URI that names NodeInfo 2.1, as a link's `rel`. */
|
|
9
|
+
export const NODEINFO_2_1 = "http://nodeinfo.diaspora.software/ns/schema/2.1";
|
|
10
|
+
/** The `Content-Type` of a NodeInfo 2.1 document, whose profile names the schema it follows. */
|
|
11
|
+
export const DOCUMENT_MEDIA_TYPE = `application/json; profile="${NODEINFO_2_1}#"`;
|
|
12
|
+
/** The schema's pattern for `software.name`: lowercase letters, digits and hyphens. */
|
|
13
|
+
const SOFTWARE_NAME = /^[a-z0-9-]+$/;
|
|
14
|
+
/**
|
|
15
|
+
* Reads the links document. Every link needs a string `rel` and an absolute `href`;
|
|
16
|
+
* any `rel` is kept, so a caller picks the version it supports.
|
|
17
|
+
*
|
|
18
|
+
* @param text - The served JSON.
|
|
19
|
+
* @example
|
|
20
|
+
* let links = parseLinks(body);
|
|
21
|
+
* let href = links.data.links.find((link) => link.rel === NODEINFO_2_1)?.href;
|
|
22
|
+
*/
|
|
23
|
+
export function parseLinks(text) {
|
|
24
|
+
let decoded = readJsonObject(text, NAME);
|
|
25
|
+
if (decoded.status === "failure")
|
|
26
|
+
return decoded;
|
|
27
|
+
let body = decoded.data;
|
|
28
|
+
let issues = [];
|
|
29
|
+
let links = [];
|
|
30
|
+
if (!Array.isArray(body.links)) {
|
|
31
|
+
issues.push({ at: "/links", message: '"links" is not an array.' });
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
for (let [index, entry] of body.links.entries()) {
|
|
35
|
+
let at = pointer("links", index);
|
|
36
|
+
if (!isObject(entry)) {
|
|
37
|
+
issues.push({ at, message: "A link is not an object." });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
let valid = true;
|
|
41
|
+
if (typeof entry.rel !== "string") {
|
|
42
|
+
issues.push({ at: `${at}/rel`, message: 'A link has no string "rel".' });
|
|
43
|
+
valid = false;
|
|
44
|
+
}
|
|
45
|
+
if (typeof entry.href !== "string" || !URL.canParse(entry.href)) {
|
|
46
|
+
issues.push({ at: `${at}/href`, message: 'A link has no absolute URL "href".' });
|
|
47
|
+
valid = false;
|
|
48
|
+
}
|
|
49
|
+
if (valid)
|
|
50
|
+
links.push({ rel: entry.rel, href: entry.href });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (issues.length > 0)
|
|
54
|
+
return failure(new WellKnownParseError(NAME, issues));
|
|
55
|
+
return success({ links });
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Writes the links document.
|
|
59
|
+
*
|
|
60
|
+
* @param document - The versions to advertise.
|
|
61
|
+
*/
|
|
62
|
+
export function stringifyLinks(document) {
|
|
63
|
+
return JSON.stringify({
|
|
64
|
+
links: document.links.map((link) => ({ rel: link.rel, href: link.href })),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Reads a string member, pushing an issue when it is required and missing or of the
|
|
69
|
+
* wrong type.
|
|
70
|
+
*
|
|
71
|
+
* @param body - The object holding it.
|
|
72
|
+
* @param member - The member name.
|
|
73
|
+
* @param at - The object's pointer.
|
|
74
|
+
* @param required - Whether absence is an issue.
|
|
75
|
+
* @param issues - Where problems are collected.
|
|
76
|
+
*/
|
|
77
|
+
function readString(body, member, at, required, issues) {
|
|
78
|
+
let value = body[member];
|
|
79
|
+
if (typeof value === "string")
|
|
80
|
+
return value;
|
|
81
|
+
if (value === undefined || value === null) {
|
|
82
|
+
if (required)
|
|
83
|
+
issues.push({ at: `${at}${pointer(member)}`, message: `"${member}" is missing.` });
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
issues.push({ at: `${at}${pointer(member)}`, message: `"${member}" is not a string.` });
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Reads an optional count, which the schema requires to be a non-negative integer.
|
|
91
|
+
*
|
|
92
|
+
* @param body - The object holding it.
|
|
93
|
+
* @param member - The member name.
|
|
94
|
+
* @param at - The object's pointer.
|
|
95
|
+
* @param issues - Where problems are collected.
|
|
96
|
+
*/
|
|
97
|
+
function readCount(body, member, at, issues) {
|
|
98
|
+
let value = body[member];
|
|
99
|
+
if (value === undefined || value === null)
|
|
100
|
+
return null;
|
|
101
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 0)
|
|
102
|
+
return value;
|
|
103
|
+
issues.push({
|
|
104
|
+
at: `${at}${pointer(member)}`,
|
|
105
|
+
message: `"${member}" is not a non-negative integer.`,
|
|
106
|
+
});
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Reads a list of names, each a non-empty string; names outside the schema's registry
|
|
111
|
+
* are kept, so a crawler reads servers that speak newer protocols.
|
|
112
|
+
*
|
|
113
|
+
* @param value - The decoded member.
|
|
114
|
+
* @param at - The member's pointer.
|
|
115
|
+
* @param issues - Where problems are collected.
|
|
116
|
+
*/
|
|
117
|
+
function readNames(value, at, issues) {
|
|
118
|
+
if (!Array.isArray(value)) {
|
|
119
|
+
issues.push({ at, message: "The member is not an array." });
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
let result = [];
|
|
123
|
+
for (let [index, item] of value.entries()) {
|
|
124
|
+
if (typeof item === "string" && item !== "")
|
|
125
|
+
result.push(item);
|
|
126
|
+
else
|
|
127
|
+
issues.push({ at: `${at}/${index}`, message: "A name is not a non-empty string." });
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Reads `software`, whose 2.1-only `repository` and `homepage` stay `null` for a 2.0
|
|
133
|
+
* document whatever it carries.
|
|
134
|
+
*
|
|
135
|
+
* @param value - The decoded member.
|
|
136
|
+
* @param version - The document's schema version.
|
|
137
|
+
* @param issues - Where problems are collected.
|
|
138
|
+
*/
|
|
139
|
+
function readSoftware(value, version, issues) {
|
|
140
|
+
let software = { name: "", version: "", repository: null, homepage: null };
|
|
141
|
+
if (!isObject(value)) {
|
|
142
|
+
issues.push({ at: "/software", message: '"software" is not an object.' });
|
|
143
|
+
return software;
|
|
144
|
+
}
|
|
145
|
+
let name = readString(value, "name", "/software", true, issues);
|
|
146
|
+
if (name !== null && !SOFTWARE_NAME.test(name)) {
|
|
147
|
+
issues.push({
|
|
148
|
+
at: "/software/name",
|
|
149
|
+
message: '"name" may hold only lowercase letters, digits and hyphens.',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
software.name = name ?? "";
|
|
153
|
+
software.version = readString(value, "version", "/software", true, issues) ?? "";
|
|
154
|
+
if (version === "2.1") {
|
|
155
|
+
software.repository = readString(value, "repository", "/software", false, issues);
|
|
156
|
+
software.homepage = readString(value, "homepage", "/software", false, issues);
|
|
157
|
+
}
|
|
158
|
+
return software;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Reads `usage`, which must hold a `users` object; every count is optional.
|
|
162
|
+
*
|
|
163
|
+
* @param value - The decoded member.
|
|
164
|
+
* @param issues - Where problems are collected.
|
|
165
|
+
*/
|
|
166
|
+
function readUsage(value, issues) {
|
|
167
|
+
let users = { total: null, activeMonth: null, activeHalfyear: null };
|
|
168
|
+
if (!isObject(value)) {
|
|
169
|
+
issues.push({ at: "/usage", message: '"usage" is not an object.' });
|
|
170
|
+
return { users, localPosts: null, localComments: null };
|
|
171
|
+
}
|
|
172
|
+
if (isObject(value.users)) {
|
|
173
|
+
users = {
|
|
174
|
+
total: readCount(value.users, "total", "/usage/users", issues),
|
|
175
|
+
activeMonth: readCount(value.users, "activeMonth", "/usage/users", issues),
|
|
176
|
+
activeHalfyear: readCount(value.users, "activeHalfyear", "/usage/users", issues),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
else
|
|
180
|
+
issues.push({ at: "/usage/users", message: '"users" is not an object.' });
|
|
181
|
+
return {
|
|
182
|
+
users,
|
|
183
|
+
localPosts: readCount(value, "localPosts", "/usage", issues),
|
|
184
|
+
localComments: readCount(value, "localComments", "/usage", issues),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Reads a NodeInfo 2.0 or 2.1 document, checking it against its schema: every required
|
|
189
|
+
* member present, `software.name` matching the pattern, at least one protocol, every
|
|
190
|
+
* protocol and service a non-empty string, and non-negative integer counts. Every issue is reported.
|
|
191
|
+
*
|
|
192
|
+
* @param text - The served JSON.
|
|
193
|
+
*/
|
|
194
|
+
export function parse(text) {
|
|
195
|
+
let decoded = readJsonObject(text, NAME);
|
|
196
|
+
if (decoded.status === "failure")
|
|
197
|
+
return decoded;
|
|
198
|
+
let body = decoded.data;
|
|
199
|
+
let issues = [];
|
|
200
|
+
let version = "2.1";
|
|
201
|
+
if (body.version === "2.0" || body.version === "2.1")
|
|
202
|
+
version = body.version;
|
|
203
|
+
else
|
|
204
|
+
issues.push({ at: "/version", message: '"version" is not "2.0" or "2.1".' });
|
|
205
|
+
let software = readSoftware(body.software, version, issues);
|
|
206
|
+
let protocols = readNames(body.protocols, "/protocols", issues);
|
|
207
|
+
if (Array.isArray(body.protocols) && body.protocols.length === 0) {
|
|
208
|
+
issues.push({ at: "/protocols", message: '"protocols" lists no protocol.' });
|
|
209
|
+
}
|
|
210
|
+
let services = { inbound: [], outbound: [] };
|
|
211
|
+
if (isObject(body.services)) {
|
|
212
|
+
services = {
|
|
213
|
+
inbound: readNames(body.services.inbound, "/services/inbound", issues),
|
|
214
|
+
outbound: readNames(body.services.outbound, "/services/outbound", issues),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
else
|
|
218
|
+
issues.push({ at: "/services", message: '"services" is not an object.' });
|
|
219
|
+
let openRegistrations = false;
|
|
220
|
+
if (typeof body.openRegistrations === "boolean")
|
|
221
|
+
openRegistrations = body.openRegistrations;
|
|
222
|
+
else
|
|
223
|
+
issues.push({ at: "/openRegistrations", message: '"openRegistrations" is not a boolean.' });
|
|
224
|
+
let usage = readUsage(body.usage, issues);
|
|
225
|
+
let metadata = {};
|
|
226
|
+
if (isObject(body.metadata))
|
|
227
|
+
metadata = body.metadata;
|
|
228
|
+
else
|
|
229
|
+
issues.push({ at: "/metadata", message: '"metadata" is not an object.' });
|
|
230
|
+
if (issues.length > 0)
|
|
231
|
+
return failure(new WellKnownParseError(NAME, issues));
|
|
232
|
+
return success({
|
|
233
|
+
version,
|
|
234
|
+
software,
|
|
235
|
+
protocols,
|
|
236
|
+
services,
|
|
237
|
+
openRegistrations,
|
|
238
|
+
usage,
|
|
239
|
+
metadata,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Writes the document as NodeInfo 2.1 whatever version it was read as, which upgrades a
|
|
244
|
+
* 2.0 document since 2.1 only adds members. `null` members are left out.
|
|
245
|
+
*
|
|
246
|
+
* @param document - The document to publish.
|
|
247
|
+
*/
|
|
248
|
+
export function stringify(document) {
|
|
249
|
+
let software = {
|
|
250
|
+
name: document.software.name,
|
|
251
|
+
version: document.software.version,
|
|
252
|
+
};
|
|
253
|
+
if (document.software.repository !== null)
|
|
254
|
+
software.repository = document.software.repository;
|
|
255
|
+
if (document.software.homepage !== null)
|
|
256
|
+
software.homepage = document.software.homepage;
|
|
257
|
+
let users = {};
|
|
258
|
+
let { total, activeMonth, activeHalfyear } = document.usage.users;
|
|
259
|
+
if (total !== null)
|
|
260
|
+
users.total = total;
|
|
261
|
+
if (activeHalfyear !== null)
|
|
262
|
+
users.activeHalfyear = activeHalfyear;
|
|
263
|
+
if (activeMonth !== null)
|
|
264
|
+
users.activeMonth = activeMonth;
|
|
265
|
+
let usage = { users };
|
|
266
|
+
if (document.usage.localPosts !== null)
|
|
267
|
+
usage.localPosts = document.usage.localPosts;
|
|
268
|
+
if (document.usage.localComments !== null)
|
|
269
|
+
usage.localComments = document.usage.localComments;
|
|
270
|
+
return JSON.stringify({
|
|
271
|
+
version: "2.1",
|
|
272
|
+
software,
|
|
273
|
+
protocols: document.protocols,
|
|
274
|
+
services: { inbound: document.services.inbound, outbound: document.services.outbound },
|
|
275
|
+
openRegistrations: document.openRegistrations,
|
|
276
|
+
usage,
|
|
277
|
+
metadata: document.metadata,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The `/.well-known/nodeinfo` links document as a servable format; every answer carries
|
|
282
|
+
* CORS `*` so a browser-based client reads it from any origin.
|
|
283
|
+
*/
|
|
284
|
+
export const nodeInfoLinks = {
|
|
285
|
+
name: NAME,
|
|
286
|
+
mediaType: MEDIA_TYPE,
|
|
287
|
+
placement: "insert",
|
|
288
|
+
cors: true,
|
|
289
|
+
stringify: stringifyLinks,
|
|
290
|
+
parse: parseLinks,
|
|
291
|
+
};
|
|
292
|
+
/**
|
|
293
|
+
* The NodeInfo document as a servable format, answered at the path the links document
|
|
294
|
+
* names (such as `/nodeinfo/2.1`) with the 2.1 profile in its media type.
|
|
295
|
+
*/
|
|
296
|
+
export const nodeInfo = {
|
|
297
|
+
name: NAME,
|
|
298
|
+
mediaType: DOCUMENT_MEDIA_TYPE,
|
|
299
|
+
placement: "insert",
|
|
300
|
+
cors: true,
|
|
301
|
+
stringify,
|
|
302
|
+
parse,
|
|
303
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/well-known",
|
|
3
|
-
"version": "2026.10.
|
|
4
|
-
"description": "Typed well-known
|
|
3
|
+
"version": "2026.10.10",
|
|
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",
|
|
7
7
|
"exports": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"./jwks": "./dist/jwks.js",
|
|
15
15
|
"./change-password": "./dist/change-password.js",
|
|
16
16
|
"./passkey-endpoints": "./dist/passkey-endpoints.js",
|
|
17
|
+
"./nodeinfo": "./dist/nodeinfo.js",
|
|
17
18
|
"./response": "./dist/response.js",
|
|
18
19
|
"./middleware": "./dist/middleware.js"
|
|
19
20
|
},
|
|
@@ -23,7 +24,7 @@
|
|
|
23
24
|
"@standard-schema/spec": "^1.1.0"
|
|
24
25
|
},
|
|
25
26
|
"peerDependencies": {
|
|
26
|
-
"@sdxc/http": "2026.10.
|
|
27
|
+
"@sdxc/http": "2026.10.10",
|
|
27
28
|
"remix": "3.0.0"
|
|
28
29
|
},
|
|
29
30
|
"peerDependenciesMeta": {
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
"optional": true
|
|
35
36
|
}
|
|
36
37
|
},
|
|
37
|
-
"gitHead": "
|
|
38
|
+
"gitHead": "b61f19c59866708a2d89a5a8f4655ebbc16941b0",
|
|
38
39
|
"publishConfig": {
|
|
39
40
|
"access": "public"
|
|
40
41
|
},
|