@dwk/ldn 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +56 -0
- package/dist/discovery.d.ts +50 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +165 -0
- package/dist/discovery.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/listing.d.ts +24 -0
- package/dist/listing.d.ts.map +1 -0
- package/dist/listing.js +63 -0
- package/dist/listing.js.map +1 -0
- package/dist/notification.d.ts +66 -0
- package/dist/notification.d.ts.map +1 -0
- package/dist/notification.js +82 -0
- package/dist/notification.js.map +1 -0
- package/dist/vocab.d.ts +22 -0
- package/dist/vocab.d.ts.map +1 -0
- package/dist/vocab.js +22 -0
- package/dist/vocab.js.map +1 -0
- package/package.json +51 -0
- package/src/discovery.ts +166 -0
- package/src/index.ts +47 -0
- package/src/listing.ts +71 -0
- package/src/notification.ts +125 -0
- package/src/vocab.ts +28 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David W. Keith
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# `@dwk/ldn`
|
|
2
|
+
|
|
3
|
+
[Linked Data Notifications](https://www.w3.org/TR/ldn/) (W3C LDN) primitives. A
|
|
4
|
+
pure, RDF-only, **protocol-agnostic** library: it implements the three LDN roles
|
|
5
|
+
as plain-data functions over [`@dwk/rdf`](../rdf)'s flat `StoredQuad`
|
|
6
|
+
representation, with no Cloudflare bindings, no transport, and **no Solid/WAC
|
|
7
|
+
assumptions**. The same primitives back the [`@dwk/solid-pod`](../solid-pod)
|
|
8
|
+
inbox (discovery) and the [`@dwk/activitypub`](../activitypub) inbox
|
|
9
|
+
(advertisement) without either standard leaking into the other.
|
|
10
|
+
|
|
11
|
+
See the [spec](../../spec/packages/ldn.md) for the authoritative requirements.
|
|
12
|
+
|
|
13
|
+
## What it does
|
|
14
|
+
|
|
15
|
+
- **Discovery** — advertise an inbox with `inboxLinkHeader(inboxIri)` (an
|
|
16
|
+
`ldp:inbox` `Link` value) or `inboxTriple(subject, inbox)` (the in-body
|
|
17
|
+
triple), and find one from the consumer side with `parseInboxLinks(header)` or
|
|
18
|
+
`discoverInboxIris(quads, subject?)`.
|
|
19
|
+
- **Receiver** — `parseNotification(body, contentType, { baseIRI })` validates a
|
|
20
|
+
posted RDF notification, throwing a `NotificationProblem` that carries the HTTP
|
|
21
|
+
status to answer (`415` for a non-RDF media type, `400` for a body that does
|
|
22
|
+
not parse or has no triples) and otherwise returning the parsed `StoredQuad`s.
|
|
23
|
+
- **Consumer** — `inboxListingQuads(inbox, members)` builds the `ldp:Container` +
|
|
24
|
+
`ldp:contains` listing triples; `listInboxMembers(quads, inbox?)` reads them
|
|
25
|
+
back.
|
|
26
|
+
|
|
27
|
+
Authorization, deduplication, and storage are the **caller's** concern — this
|
|
28
|
+
library only speaks RDF and the LDN vocabulary.
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import {
|
|
34
|
+
inboxLinkHeader,
|
|
35
|
+
parseNotification,
|
|
36
|
+
NotificationProblem,
|
|
37
|
+
} from "@dwk/ldn";
|
|
38
|
+
|
|
39
|
+
// Producer: advertise the inbox on a target resource's response.
|
|
40
|
+
response.headers.set("link", inboxLinkHeader("https://alice.example/inbox/"));
|
|
41
|
+
|
|
42
|
+
// Receiver: validate an inbound notification before persisting it.
|
|
43
|
+
try {
|
|
44
|
+
const { quads } = await parseNotification(
|
|
45
|
+
await request.text(),
|
|
46
|
+
request.headers.get("content-type"),
|
|
47
|
+
{ baseIRI: inboxIri },
|
|
48
|
+
);
|
|
49
|
+
// …authorize, dedup, and store `quads`…
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error instanceof NotificationProblem) {
|
|
52
|
+
return new Response(error.message, { status: error.status });
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
```
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LDN inbox discovery — both sides of the wire.
|
|
3
|
+
*
|
|
4
|
+
* A target resource advertises its inbox so a sender can find it. LDN allows the
|
|
5
|
+
* advertisement to travel either in an HTTP `Link` header or as an `ldp:inbox`
|
|
6
|
+
* triple in the resource's RDF body, so this module covers building the header
|
|
7
|
+
* (and triple) on the producer side and parsing both forms on the consumer side.
|
|
8
|
+
*
|
|
9
|
+
* It depends on `@dwk/rdf` for *types only* (erased at build), so it carries no
|
|
10
|
+
* RDF-parser runtime. It is re-exported from the package root and also reachable
|
|
11
|
+
* directly as `@dwk/ldn/discovery`, the n3-free entry point a binding-bound
|
|
12
|
+
* consumer (e.g. a Workers runtime) imports to advertise an inbox without
|
|
13
|
+
* pulling in the notification parser.
|
|
14
|
+
*/
|
|
15
|
+
import type { StoredQuad } from "@dwk/rdf";
|
|
16
|
+
/**
|
|
17
|
+
* Build the `Link` header value advertising `inboxIri` as the resource's inbox:
|
|
18
|
+
* `<inboxIri>; rel="http://www.w3.org/ns/ldp#inbox"`. Join it with any other
|
|
19
|
+
* link-values using `, ` to form a complete header.
|
|
20
|
+
*/
|
|
21
|
+
export declare function inboxLinkHeader(inboxIri: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Build the `Link` header value advertising `constraintsIri` as the constraints
|
|
24
|
+
* a resource imposes: `<constraintsIri>; rel="http://www.w3.org/ns/ldp#constrainedBy"`.
|
|
25
|
+
* LDN §5.1 (and LDP) lets a receiver point senders at a document describing the
|
|
26
|
+
* constraints it enforces — the media types it accepts, whether it requires a
|
|
27
|
+
* non-empty graph, and so on. Join it with any other link-values using `, `.
|
|
28
|
+
*/
|
|
29
|
+
export declare function constrainedByLinkHeader(constraintsIri: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* Build the `<subjectIri> ldp:inbox <inboxIri>` triple that advertises the inbox
|
|
32
|
+
* inside a resource's RDF body (the alternative to the `Link` header).
|
|
33
|
+
*/
|
|
34
|
+
export declare function inboxTriple(subjectIri: string, inboxIri: string): StoredQuad;
|
|
35
|
+
/**
|
|
36
|
+
* Extract the inbox IRIs a graph advertises via `ldp:inbox`. When `subjectIri`
|
|
37
|
+
* is given, only triples with that subject are considered (the usual case: "what
|
|
38
|
+
* is _this_ resource's inbox?"); otherwise every `ldp:inbox` object is returned.
|
|
39
|
+
* Only named-node objects count — a literal `ldp:inbox` is not a dereferenceable
|
|
40
|
+
* inbox. Results are de-duplicated, preserving first-seen order.
|
|
41
|
+
*/
|
|
42
|
+
export declare function discoverInboxIris(quads: readonly StoredQuad[], subjectIri?: string): string[];
|
|
43
|
+
/**
|
|
44
|
+
* Parse the inbox IRIs advertised by an HTTP `Link` header. A link-value counts
|
|
45
|
+
* when its `rel` token list includes the full `ldp:inbox` IRI (LDN's canonical
|
|
46
|
+
* form) or the bare `inbox` token. Returns de-duplicated, first-seen-order IRIs;
|
|
47
|
+
* an absent or malformed header yields an empty list.
|
|
48
|
+
*/
|
|
49
|
+
export declare function parseInboxLinks(linkHeader: string | null): string[];
|
|
50
|
+
//# sourceMappingURL=discovery.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discovery.d.ts","sourceRoot":"","sources":["../src/discovery.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAM3C;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAExD;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,CAEtE;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,UAAU,CAO5E;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,SAAS,UAAU,EAAE,EAC5B,UAAU,CAAC,EAAE,MAAM,GAClB,MAAM,EAAE,CASV;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,EAAE,CAYnE"}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LDN inbox discovery — both sides of the wire.
|
|
3
|
+
*
|
|
4
|
+
* A target resource advertises its inbox so a sender can find it. LDN allows the
|
|
5
|
+
* advertisement to travel either in an HTTP `Link` header or as an `ldp:inbox`
|
|
6
|
+
* triple in the resource's RDF body, so this module covers building the header
|
|
7
|
+
* (and triple) on the producer side and parsing both forms on the consumer side.
|
|
8
|
+
*
|
|
9
|
+
* It depends on `@dwk/rdf` for *types only* (erased at build), so it carries no
|
|
10
|
+
* RDF-parser runtime. It is re-exported from the package root and also reachable
|
|
11
|
+
* directly as `@dwk/ldn/discovery`, the n3-free entry point a binding-bound
|
|
12
|
+
* consumer (e.g. a Workers runtime) imports to advertise an inbox without
|
|
13
|
+
* pulling in the notification parser.
|
|
14
|
+
*/
|
|
15
|
+
import { LDP_INBOX, LDP_CONSTRAINED_BY } from "./vocab";
|
|
16
|
+
const DEFAULT_GRAPH = { termType: "DefaultGraph", value: "" };
|
|
17
|
+
/**
|
|
18
|
+
* Build the `Link` header value advertising `inboxIri` as the resource's inbox:
|
|
19
|
+
* `<inboxIri>; rel="http://www.w3.org/ns/ldp#inbox"`. Join it with any other
|
|
20
|
+
* link-values using `, ` to form a complete header.
|
|
21
|
+
*/
|
|
22
|
+
export function inboxLinkHeader(inboxIri) {
|
|
23
|
+
return `<${inboxIri}>; rel="${LDP_INBOX}"`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Build the `Link` header value advertising `constraintsIri` as the constraints
|
|
27
|
+
* a resource imposes: `<constraintsIri>; rel="http://www.w3.org/ns/ldp#constrainedBy"`.
|
|
28
|
+
* LDN §5.1 (and LDP) lets a receiver point senders at a document describing the
|
|
29
|
+
* constraints it enforces — the media types it accepts, whether it requires a
|
|
30
|
+
* non-empty graph, and so on. Join it with any other link-values using `, `.
|
|
31
|
+
*/
|
|
32
|
+
export function constrainedByLinkHeader(constraintsIri) {
|
|
33
|
+
return `<${constraintsIri}>; rel="${LDP_CONSTRAINED_BY}"`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Build the `<subjectIri> ldp:inbox <inboxIri>` triple that advertises the inbox
|
|
37
|
+
* inside a resource's RDF body (the alternative to the `Link` header).
|
|
38
|
+
*/
|
|
39
|
+
export function inboxTriple(subjectIri, inboxIri) {
|
|
40
|
+
return {
|
|
41
|
+
subject: { termType: "NamedNode", value: subjectIri },
|
|
42
|
+
predicate: { termType: "NamedNode", value: LDP_INBOX },
|
|
43
|
+
object: { termType: "NamedNode", value: inboxIri },
|
|
44
|
+
graph: DEFAULT_GRAPH,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Extract the inbox IRIs a graph advertises via `ldp:inbox`. When `subjectIri`
|
|
49
|
+
* is given, only triples with that subject are considered (the usual case: "what
|
|
50
|
+
* is _this_ resource's inbox?"); otherwise every `ldp:inbox` object is returned.
|
|
51
|
+
* Only named-node objects count — a literal `ldp:inbox` is not a dereferenceable
|
|
52
|
+
* inbox. Results are de-duplicated, preserving first-seen order.
|
|
53
|
+
*/
|
|
54
|
+
export function discoverInboxIris(quads, subjectIri) {
|
|
55
|
+
const found = new Set();
|
|
56
|
+
for (const quad of quads) {
|
|
57
|
+
if (quad.predicate.value !== LDP_INBOX)
|
|
58
|
+
continue;
|
|
59
|
+
if (quad.object.termType !== "NamedNode")
|
|
60
|
+
continue;
|
|
61
|
+
if (subjectIri !== undefined && quad.subject.value !== subjectIri)
|
|
62
|
+
continue;
|
|
63
|
+
found.add(quad.object.value);
|
|
64
|
+
}
|
|
65
|
+
return [...found];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Parse the inbox IRIs advertised by an HTTP `Link` header. A link-value counts
|
|
69
|
+
* when its `rel` token list includes the full `ldp:inbox` IRI (LDN's canonical
|
|
70
|
+
* form) or the bare `inbox` token. Returns de-duplicated, first-seen-order IRIs;
|
|
71
|
+
* an absent or malformed header yields an empty list.
|
|
72
|
+
*/
|
|
73
|
+
export function parseInboxLinks(linkHeader) {
|
|
74
|
+
if (!linkHeader)
|
|
75
|
+
return [];
|
|
76
|
+
const found = new Set();
|
|
77
|
+
for (const entry of splitLinkValues(linkHeader)) {
|
|
78
|
+
const start = entry.indexOf("<");
|
|
79
|
+
const end = entry.indexOf(">", start + 1);
|
|
80
|
+
if (start === -1 || end === -1)
|
|
81
|
+
continue;
|
|
82
|
+
const uri = entry.slice(start + 1, end).trim();
|
|
83
|
+
if (uri.length === 0)
|
|
84
|
+
continue;
|
|
85
|
+
if (linkEntryRelIsInbox(entry.slice(end + 1)))
|
|
86
|
+
found.add(uri);
|
|
87
|
+
}
|
|
88
|
+
return [...found];
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Split a `Link` header into its individual link-values on top-level commas,
|
|
92
|
+
* tracking both quoted parameter values and `<…>` URI-references so a comma
|
|
93
|
+
* inside either never splits an entry (RFC 8288). A simple `,`-followed-by-`<`
|
|
94
|
+
* regex would mis-split a quoted parameter such as `title="a, <b>"`.
|
|
95
|
+
*/
|
|
96
|
+
function splitLinkValues(header) {
|
|
97
|
+
const values = [];
|
|
98
|
+
let inQuotes = false;
|
|
99
|
+
let inBrackets = false;
|
|
100
|
+
let current = "";
|
|
101
|
+
for (let i = 0; i < header.length; i++) {
|
|
102
|
+
const char = header[i];
|
|
103
|
+
if (char === '"' && header[i - 1] !== "\\") {
|
|
104
|
+
inQuotes = !inQuotes;
|
|
105
|
+
}
|
|
106
|
+
else if (char === "<" && !inQuotes) {
|
|
107
|
+
inBrackets = true;
|
|
108
|
+
}
|
|
109
|
+
else if (char === ">" && !inQuotes) {
|
|
110
|
+
inBrackets = false;
|
|
111
|
+
}
|
|
112
|
+
if (char === "," && !inQuotes && !inBrackets) {
|
|
113
|
+
values.push(current);
|
|
114
|
+
current = "";
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
current += char;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (current.trim() !== "")
|
|
121
|
+
values.push(current);
|
|
122
|
+
return values;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Whether a link-value's parameter string declares `rel` naming the inbox. `rel`
|
|
126
|
+
* is a space-separated token list, so `rel="http://www.w3.org/ns/ldp#inbox"` and
|
|
127
|
+
* `rel="inbox alternate"` both count. Parameters are split on top-level `;`
|
|
128
|
+
* (respecting quotes) so a `rel=...` substring inside another quoted parameter
|
|
129
|
+
* value is not mistaken for the rel parameter.
|
|
130
|
+
*/
|
|
131
|
+
function linkEntryRelIsInbox(params) {
|
|
132
|
+
for (const param of splitLinkParams(params)) {
|
|
133
|
+
const match = /^\s*rel\s*=\s*("([^"]*)"|'([^']*)'|[^;\s]+)\s*$/i.exec(param);
|
|
134
|
+
if (match === null)
|
|
135
|
+
continue;
|
|
136
|
+
const value = match[2] ?? match[3] ?? match[1] ?? "";
|
|
137
|
+
const tokens = value.split(/\s+/);
|
|
138
|
+
if (tokens.includes(LDP_INBOX) || tokens.includes("inbox"))
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
/** Split a link-value parameter string on top-level `;`, respecting quotes. */
|
|
144
|
+
function splitLinkParams(params) {
|
|
145
|
+
const parts = [];
|
|
146
|
+
let inQuotes = false;
|
|
147
|
+
let current = "";
|
|
148
|
+
for (let i = 0; i < params.length; i++) {
|
|
149
|
+
const char = params[i];
|
|
150
|
+
if (char === '"' && params[i - 1] !== "\\") {
|
|
151
|
+
inQuotes = !inQuotes;
|
|
152
|
+
}
|
|
153
|
+
if (char === ";" && !inQuotes) {
|
|
154
|
+
parts.push(current);
|
|
155
|
+
current = "";
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
current += char;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (current.trim() !== "")
|
|
162
|
+
parts.push(current);
|
|
163
|
+
return parts;
|
|
164
|
+
}
|
|
165
|
+
//# sourceMappingURL=discovery.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discovery.js","sourceRoot":"","sources":["../src/discovery.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/ldn` — Linked Data Notifications (W3C LDN) primitives.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Cross-standard reusable library: it implements the three LDN roles —
|
|
6
|
+
* **discovery** (advertise / find an inbox via `ldp:inbox`), **receiver**
|
|
7
|
+
* (validate a posted RDF notification), and **consumer** (read an inbox
|
|
8
|
+
* listing) — as plain-data functions over `@dwk/rdf`'s flat `StoredQuad`
|
|
9
|
+
* representation. It carries no Cloudflare bindings, no transport, and **no
|
|
10
|
+
* Solid/WAC assumptions**, so the same primitives back the `@dwk/solid-pod`
|
|
11
|
+
* inbox and the `@dwk/activitypub` inbox without either leaking into the other.
|
|
12
|
+
* Authorization, dedup, and storage stay the caller's concern.
|
|
13
|
+
*
|
|
14
|
+
* @see {@link https://www.w3.org/TR/ldn/ | W3C Linked Data Notifications}
|
|
15
|
+
* @see spec/packages/ldn.md
|
|
16
|
+
* @packageDocumentation
|
|
17
|
+
*/
|
|
18
|
+
export { LDP_NAMESPACE, LDP_INBOX, LDP_CONTAINS, LDP_CONSTRAINED_BY, LDP_CONTAINER, LDP_RESOURCE, RDF_TYPE, } from "./vocab";
|
|
19
|
+
export { inboxLinkHeader, inboxTriple, constrainedByLinkHeader, discoverInboxIris, parseInboxLinks, } from "./discovery";
|
|
20
|
+
export { parseNotification, acceptedContentTypes, acceptPostHeader, NotificationProblem, type NotificationProblemCode, type ParsedNotification, type ParseNotificationOptions, } from "./notification";
|
|
21
|
+
export { inboxListingQuads, listInboxMembers } from "./listing";
|
|
22
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,kBAAkB,EAClB,aAAa,EACb,YAAY,EACZ,QAAQ,GACT,MAAM,SAAS,CAAC;AAEjB,OAAO,EACL,eAAe,EACf,WAAW,EACX,uBAAuB,EACvB,iBAAiB,EACjB,eAAe,GAChB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,iBAAiB,EACjB,oBAAoB,EACpB,gBAAgB,EAChB,mBAAmB,EACnB,KAAK,uBAAuB,EAC5B,KAAK,kBAAkB,EACvB,KAAK,wBAAwB,GAC9B,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/ldn` — Linked Data Notifications (W3C LDN) primitives.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Cross-standard reusable library: it implements the three LDN roles —
|
|
6
|
+
* **discovery** (advertise / find an inbox via `ldp:inbox`), **receiver**
|
|
7
|
+
* (validate a posted RDF notification), and **consumer** (read an inbox
|
|
8
|
+
* listing) — as plain-data functions over `@dwk/rdf`'s flat `StoredQuad`
|
|
9
|
+
* representation. It carries no Cloudflare bindings, no transport, and **no
|
|
10
|
+
* Solid/WAC assumptions**, so the same primitives back the `@dwk/solid-pod`
|
|
11
|
+
* inbox and the `@dwk/activitypub` inbox without either leaking into the other.
|
|
12
|
+
* Authorization, dedup, and storage stay the caller's concern.
|
|
13
|
+
*
|
|
14
|
+
* @see {@link https://www.w3.org/TR/ldn/ | W3C Linked Data Notifications}
|
|
15
|
+
* @see spec/packages/ldn.md
|
|
16
|
+
* @packageDocumentation
|
|
17
|
+
*/
|
|
18
|
+
export { LDP_NAMESPACE, LDP_INBOX, LDP_CONTAINS, LDP_CONSTRAINED_BY, LDP_CONTAINER, LDP_RESOURCE, RDF_TYPE, } from "./vocab";
|
|
19
|
+
export { inboxLinkHeader, inboxTriple, constrainedByLinkHeader, discoverInboxIris, parseInboxLinks, } from "./discovery";
|
|
20
|
+
export { parseNotification, acceptedContentTypes, acceptPostHeader, NotificationProblem, } from "./notification";
|
|
21
|
+
export { inboxListingQuads, listInboxMembers } from "./listing";
|
|
22
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,kBAAkB,EAClB,aAAa,EACb,YAAY,EACZ,QAAQ,GACT,MAAM,SAAS,CAAC;AAEjB,OAAO,EACL,eAAe,EACf,WAAW,EACX,uBAAuB,EACvB,iBAAiB,EACjB,eAAe,GAChB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,iBAAiB,EACjB,oBAAoB,EACpB,gBAAgB,EAChB,mBAAmB,GAIpB,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LDN consumer — inbox listing.
|
|
3
|
+
*
|
|
4
|
+
* A consumer `GET`s an inbox and reads the contained notification IRIs. The
|
|
5
|
+
* inbox is an `ldp:Container` whose members are linked with `ldp:contains`;
|
|
6
|
+
* {@link inboxListingQuads} builds exactly those triples in the flat
|
|
7
|
+
* `StoredQuad` shape so a caller can serialize them with `@dwk/rdf`, and
|
|
8
|
+
* {@link listInboxMembers} reads them back out of a fetched inbox graph.
|
|
9
|
+
*/
|
|
10
|
+
import type { StoredQuad } from "@dwk/rdf";
|
|
11
|
+
/**
|
|
12
|
+
* Build the triples describing an inbox listing: `<inboxIri> a ldp:Container`
|
|
13
|
+
* plus one `<inboxIri> ldp:contains <member>` per notification. Members are
|
|
14
|
+
* de-duplicated, preserving first-seen order.
|
|
15
|
+
*/
|
|
16
|
+
export declare function inboxListingQuads(inboxIri: string, memberIris: Iterable<string>): StoredQuad[];
|
|
17
|
+
/**
|
|
18
|
+
* Read the notification IRIs an inbox graph contains via `ldp:contains`. When
|
|
19
|
+
* `inboxIri` is given, only that container's members are returned; otherwise
|
|
20
|
+
* every `ldp:contains` object is. Only named-node objects count. Results are
|
|
21
|
+
* de-duplicated, preserving first-seen order.
|
|
22
|
+
*/
|
|
23
|
+
export declare function listInboxMembers(quads: readonly StoredQuad[], inboxIri?: string): string[];
|
|
24
|
+
//# sourceMappingURL=listing.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"listing.d.ts","sourceRoot":"","sources":["../src/listing.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAU3C;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,GAC3B,UAAU,EAAE,CAsBd;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,SAAS,UAAU,EAAE,EAC5B,QAAQ,CAAC,EAAE,MAAM,GAChB,MAAM,EAAE,CASV"}
|
package/dist/listing.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LDN consumer — inbox listing.
|
|
3
|
+
*
|
|
4
|
+
* A consumer `GET`s an inbox and reads the contained notification IRIs. The
|
|
5
|
+
* inbox is an `ldp:Container` whose members are linked with `ldp:contains`;
|
|
6
|
+
* {@link inboxListingQuads} builds exactly those triples in the flat
|
|
7
|
+
* `StoredQuad` shape so a caller can serialize them with `@dwk/rdf`, and
|
|
8
|
+
* {@link listInboxMembers} reads them back out of a fetched inbox graph.
|
|
9
|
+
*/
|
|
10
|
+
import { LDP_CONTAINS, LDP_CONTAINER, RDF_TYPE } from "./vocab";
|
|
11
|
+
const DEFAULT_GRAPH = { termType: "DefaultGraph", value: "" };
|
|
12
|
+
function namedNode(value) {
|
|
13
|
+
return { termType: "NamedNode", value };
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Build the triples describing an inbox listing: `<inboxIri> a ldp:Container`
|
|
17
|
+
* plus one `<inboxIri> ldp:contains <member>` per notification. Members are
|
|
18
|
+
* de-duplicated, preserving first-seen order.
|
|
19
|
+
*/
|
|
20
|
+
export function inboxListingQuads(inboxIri, memberIris) {
|
|
21
|
+
const subject = namedNode(inboxIri);
|
|
22
|
+
const quads = [
|
|
23
|
+
{
|
|
24
|
+
subject,
|
|
25
|
+
predicate: namedNode(RDF_TYPE),
|
|
26
|
+
object: namedNode(LDP_CONTAINER),
|
|
27
|
+
graph: DEFAULT_GRAPH,
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
for (const member of memberIris) {
|
|
32
|
+
if (seen.has(member))
|
|
33
|
+
continue;
|
|
34
|
+
seen.add(member);
|
|
35
|
+
quads.push({
|
|
36
|
+
subject,
|
|
37
|
+
predicate: namedNode(LDP_CONTAINS),
|
|
38
|
+
object: namedNode(member),
|
|
39
|
+
graph: DEFAULT_GRAPH,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return quads;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Read the notification IRIs an inbox graph contains via `ldp:contains`. When
|
|
46
|
+
* `inboxIri` is given, only that container's members are returned; otherwise
|
|
47
|
+
* every `ldp:contains` object is. Only named-node objects count. Results are
|
|
48
|
+
* de-duplicated, preserving first-seen order.
|
|
49
|
+
*/
|
|
50
|
+
export function listInboxMembers(quads, inboxIri) {
|
|
51
|
+
const found = new Set();
|
|
52
|
+
for (const quad of quads) {
|
|
53
|
+
if (quad.predicate.value !== LDP_CONTAINS)
|
|
54
|
+
continue;
|
|
55
|
+
if (quad.object.termType !== "NamedNode")
|
|
56
|
+
continue;
|
|
57
|
+
if (inboxIri !== undefined && quad.subject.value !== inboxIri)
|
|
58
|
+
continue;
|
|
59
|
+
found.add(quad.object.value);
|
|
60
|
+
}
|
|
61
|
+
return [...found];
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=listing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"listing.js","sourceRoot":"","sources":["../src/listing.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEhE,MAAM,aAAa,GAAG,EAAE,QAAQ,EAAE,cAAc,EAAE,KAAK,EAAE,EAAE,EAAW,CAAC;AAEvE,SAAS,SAAS,CAAC,KAAa;IAC9B,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,KAAK,EAAE,CAAC;AAC1C,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAC/B,QAAgB,EAChB,UAA4B;IAE5B,MAAM,OAAO,GAAG,SAAS,CAAC,QAAQ,CAAC,CAAC;IACpC,MAAM,KAAK,GAAiB;QAC1B;YACE,OAAO;YACP,SAAS,EAAE,SAAS,CAAC,QAAQ,CAAC;YAC9B,MAAM,EAAE,SAAS,CAAC,aAAa,CAAC;YAChC,KAAK,EAAE,aAAa;SACrB;KACF,CAAC;IACF,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,KAAK,MAAM,MAAM,IAAI,UAAU,EAAE,CAAC;QAChC,IAAI,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC;YAAE,SAAS;QAC/B,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACjB,KAAK,CAAC,IAAI,CAAC;YACT,OAAO;YACP,SAAS,EAAE,SAAS,CAAC,YAAY,CAAC;YAClC,MAAM,EAAE,SAAS,CAAC,MAAM,CAAC;YACzB,KAAK,EAAE,aAAa;SACrB,CAAC,CAAC;IACL,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,gBAAgB,CAC9B,KAA4B,EAC5B,QAAiB;IAEjB,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAC;IAChC,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,IAAI,CAAC,SAAS,CAAC,KAAK,KAAK,YAAY;YAAE,SAAS;QACpD,IAAI,IAAI,CAAC,MAAM,CAAC,QAAQ,KAAK,WAAW;YAAE,SAAS;QACnD,IAAI,QAAQ,KAAK,SAAS,IAAI,IAAI,CAAC,OAAO,CAAC,KAAK,KAAK,QAAQ;YAAE,SAAS;QACxE,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC/B,CAAC;IACD,OAAO,CAAC,GAAG,KAAK,CAAC,CAAC;AACpB,CAAC"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LDN receiver — notification validation.
|
|
3
|
+
*
|
|
4
|
+
* An LDN receiver accepts a `POST` of an RDF notification to an inbox. The body
|
|
5
|
+
* MUST be a supported RDF serialization (LDN mandates JSON-LD; this also accepts
|
|
6
|
+
* the Turtle family `@dwk/rdf` supports) and MUST parse. This module classifies
|
|
7
|
+
* the two rejection cases an inbox owner needs to map to a status code —
|
|
8
|
+
* `unsupported_media_type` (415) and `malformed` (400) — and otherwise returns
|
|
9
|
+
* the parsed triples in the flat, storage-friendly `StoredQuad` shape. It stays
|
|
10
|
+
* protocol-agnostic: authorization, dedup, and storage are the caller's concern.
|
|
11
|
+
*
|
|
12
|
+
* Per LDN §3.3.1, a receiver SHOULD advertise the content types it accepts via
|
|
13
|
+
* an `Accept-Post` header on `OPTIONS`. {@link acceptedContentTypes} and
|
|
14
|
+
* {@link acceptPostHeader} build that advertisement from the same media-type
|
|
15
|
+
* table {@link parseNotification} validates against, so the two cannot drift.
|
|
16
|
+
*/
|
|
17
|
+
import { type RdfFormat, type StoredQuad } from "@dwk/rdf";
|
|
18
|
+
/** Why a notification body was rejected. */
|
|
19
|
+
export type NotificationProblemCode = "unsupported_media_type" | "malformed";
|
|
20
|
+
/**
|
|
21
|
+
* A rejected notification body, carrying the HTTP status an LDN receiver should
|
|
22
|
+
* answer: `415` for a non-RDF / unknown media type, `400` for an RDF media type
|
|
23
|
+
* whose body does not parse.
|
|
24
|
+
*/
|
|
25
|
+
export declare class NotificationProblem extends Error {
|
|
26
|
+
readonly code: NotificationProblemCode;
|
|
27
|
+
readonly status: 415 | 400;
|
|
28
|
+
constructor(code: NotificationProblemCode, message: string);
|
|
29
|
+
}
|
|
30
|
+
/** A validated notification: its triples plus the RDF format it arrived in. */
|
|
31
|
+
export interface ParsedNotification {
|
|
32
|
+
readonly quads: StoredQuad[];
|
|
33
|
+
readonly mediaType: string;
|
|
34
|
+
readonly format: RdfFormat;
|
|
35
|
+
}
|
|
36
|
+
/** Options for {@link parseNotification}. */
|
|
37
|
+
export interface ParseNotificationOptions {
|
|
38
|
+
/** Base IRI used to resolve relative IRIs in the notification body. */
|
|
39
|
+
readonly baseIRI?: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Validate and parse an LDN notification body. Throws a {@link NotificationProblem}
|
|
43
|
+
* when the `Content-Type` is missing / not a supported RDF serialization (415),
|
|
44
|
+
* or when the body fails to parse (400). On success the triples are returned as
|
|
45
|
+
* {@link StoredQuad}s ready to persist.
|
|
46
|
+
*
|
|
47
|
+
* A well-formed body that yields zero triples (an empty JSON-LD `@graph`, a bare
|
|
48
|
+
* `@context`, a Turtle document of only prefix declarations) is **accepted**:
|
|
49
|
+
* LDN §3.2 does not require a notification to carry at least one triple, so this
|
|
50
|
+
* returns an empty `quads` array rather than rejecting it as malformed.
|
|
51
|
+
*/
|
|
52
|
+
export declare function parseNotification(body: string, contentType: string | null, options?: ParseNotificationOptions): Promise<ParsedNotification>;
|
|
53
|
+
/**
|
|
54
|
+
* The RDF media types {@link parseNotification} accepts, in registry order — the
|
|
55
|
+
* value set an LDN receiver should advertise so senders pick a supported
|
|
56
|
+
* serialization. Derived from `@dwk/rdf`'s `MEDIA_TYPE_FORMATS`, the same table
|
|
57
|
+
* parsing validates against, so the advertisement and the validator never drift.
|
|
58
|
+
*/
|
|
59
|
+
export declare function acceptedContentTypes(): string[];
|
|
60
|
+
/**
|
|
61
|
+
* Build the `Accept-Post` header value (LDN §3.3.1) an LDN receiver should
|
|
62
|
+
* answer on `OPTIONS`, advertising every content type {@link parseNotification}
|
|
63
|
+
* accepts as a comma-separated list.
|
|
64
|
+
*/
|
|
65
|
+
export declare function acceptPostHeader(): string;
|
|
66
|
+
//# sourceMappingURL=notification.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notification.d.ts","sourceRoot":"","sources":["../src/notification.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAKL,KAAK,SAAS,EACd,KAAK,UAAU,EAChB,MAAM,UAAU,CAAC;AAElB,4CAA4C;AAC5C,MAAM,MAAM,uBAAuB,GAAG,wBAAwB,GAAG,WAAW,CAAC;AAE7E;;;;GAIG;AACH,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,QAAQ,CAAC,IAAI,EAAE,uBAAuB,CAAC;IACvC,QAAQ,CAAC,MAAM,EAAE,GAAG,GAAG,GAAG,CAAC;gBAEf,IAAI,EAAE,uBAAuB,EAAE,OAAO,EAAE,MAAM;CAM3D;AAED,+EAA+E;AAC/E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,KAAK,EAAE,UAAU,EAAE,CAAC;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;CAC5B;AAED,6CAA6C;AAC7C,MAAM,WAAW,wBAAwB;IACvC,uEAAuE;IACvE,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,GAAG,IAAI,EAC1B,OAAO,GAAE,wBAA6B,GACrC,OAAO,CAAC,kBAAkB,CAAC,CA+B7B;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,IAAI,MAAM,EAAE,CAE/C;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LDN receiver — notification validation.
|
|
3
|
+
*
|
|
4
|
+
* An LDN receiver accepts a `POST` of an RDF notification to an inbox. The body
|
|
5
|
+
* MUST be a supported RDF serialization (LDN mandates JSON-LD; this also accepts
|
|
6
|
+
* the Turtle family `@dwk/rdf` supports) and MUST parse. This module classifies
|
|
7
|
+
* the two rejection cases an inbox owner needs to map to a status code —
|
|
8
|
+
* `unsupported_media_type` (415) and `malformed` (400) — and otherwise returns
|
|
9
|
+
* the parsed triples in the flat, storage-friendly `StoredQuad` shape. It stays
|
|
10
|
+
* protocol-agnostic: authorization, dedup, and storage are the caller's concern.
|
|
11
|
+
*
|
|
12
|
+
* Per LDN §3.3.1, a receiver SHOULD advertise the content types it accepts via
|
|
13
|
+
* an `Accept-Post` header on `OPTIONS`. {@link acceptedContentTypes} and
|
|
14
|
+
* {@link acceptPostHeader} build that advertisement from the same media-type
|
|
15
|
+
* table {@link parseNotification} validates against, so the two cannot drift.
|
|
16
|
+
*/
|
|
17
|
+
import { parse as parseRdf, formatForMediaType, quadToStored, MEDIA_TYPE_FORMATS, } from "@dwk/rdf";
|
|
18
|
+
/**
|
|
19
|
+
* A rejected notification body, carrying the HTTP status an LDN receiver should
|
|
20
|
+
* answer: `415` for a non-RDF / unknown media type, `400` for an RDF media type
|
|
21
|
+
* whose body does not parse.
|
|
22
|
+
*/
|
|
23
|
+
export class NotificationProblem extends Error {
|
|
24
|
+
code;
|
|
25
|
+
status;
|
|
26
|
+
constructor(code, message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "NotificationProblem";
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.status = code === "unsupported_media_type" ? 415 : 400;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Validate and parse an LDN notification body. Throws a {@link NotificationProblem}
|
|
35
|
+
* when the `Content-Type` is missing / not a supported RDF serialization (415),
|
|
36
|
+
* or when the body fails to parse (400). On success the triples are returned as
|
|
37
|
+
* {@link StoredQuad}s ready to persist.
|
|
38
|
+
*
|
|
39
|
+
* A well-formed body that yields zero triples (an empty JSON-LD `@graph`, a bare
|
|
40
|
+
* `@context`, a Turtle document of only prefix declarations) is **accepted**:
|
|
41
|
+
* LDN §3.2 does not require a notification to carry at least one triple, so this
|
|
42
|
+
* returns an empty `quads` array rather than rejecting it as malformed.
|
|
43
|
+
*/
|
|
44
|
+
export async function parseNotification(body, contentType, options = {}) {
|
|
45
|
+
// Resolve the clean media type once (no parameters/casing) and reuse it for
|
|
46
|
+
// both format lookup and parsing, consistent with the rest of the codebase.
|
|
47
|
+
const mediaType = contentType
|
|
48
|
+
? (contentType.split(";")[0]?.trim().toLowerCase() ?? "")
|
|
49
|
+
: "";
|
|
50
|
+
const format = mediaType ? formatForMediaType(mediaType) : undefined;
|
|
51
|
+
if (!format) {
|
|
52
|
+
throw new NotificationProblem("unsupported_media_type", `@dwk/ldn: notification media type "${contentType ?? ""}" is not a ` +
|
|
53
|
+
`supported RDF serialization`);
|
|
54
|
+
}
|
|
55
|
+
let quads;
|
|
56
|
+
try {
|
|
57
|
+
quads = await parseRdf(body, mediaType, options.baseIRI ? { baseIRI: options.baseIRI } : {});
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
throw new NotificationProblem("malformed", `@dwk/ldn: notification body is not valid ${format}: ` +
|
|
61
|
+
`${error instanceof Error ? error.message : String(error)}`);
|
|
62
|
+
}
|
|
63
|
+
return { quads: quads.map(quadToStored), mediaType, format };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The RDF media types {@link parseNotification} accepts, in registry order — the
|
|
67
|
+
* value set an LDN receiver should advertise so senders pick a supported
|
|
68
|
+
* serialization. Derived from `@dwk/rdf`'s `MEDIA_TYPE_FORMATS`, the same table
|
|
69
|
+
* parsing validates against, so the advertisement and the validator never drift.
|
|
70
|
+
*/
|
|
71
|
+
export function acceptedContentTypes() {
|
|
72
|
+
return Object.keys(MEDIA_TYPE_FORMATS);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Build the `Accept-Post` header value (LDN §3.3.1) an LDN receiver should
|
|
76
|
+
* answer on `OPTIONS`, advertising every content type {@link parseNotification}
|
|
77
|
+
* accepts as a comma-separated list.
|
|
78
|
+
*/
|
|
79
|
+
export function acceptPostHeader() {
|
|
80
|
+
return acceptedContentTypes().join(", ");
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=notification.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notification.js","sourceRoot":"","sources":["../src/notification.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EACL,KAAK,IAAI,QAAQ,EACjB,kBAAkB,EAClB,YAAY,EACZ,kBAAkB,GAGnB,MAAM,UAAU,CAAC;AAKlB;;;;GAIG;AACH,MAAM,OAAO,mBAAoB,SAAQ,KAAK;IACnC,IAAI,CAA0B;IAC9B,MAAM,CAAY;IAE3B,YAAY,IAA6B,EAAE,OAAe;QACxD,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,qBAAqB,CAAC;QAClC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,MAAM,GAAG,IAAI,KAAK,wBAAwB,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC;IAC9D,CAAC;CACF;AAeD;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,IAAY,EACZ,WAA0B,EAC1B,UAAoC,EAAE;IAEtC,4EAA4E;IAC5E,4EAA4E;IAC5E,MAAM,SAAS,GAAG,WAAW;QAC3B,CAAC,CAAC,CAAC,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC;QACzD,CAAC,CAAC,EAAE,CAAC;IACP,MAAM,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC,kBAAkB,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IACrE,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,IAAI,mBAAmB,CAC3B,wBAAwB,EACxB,sCAAsC,WAAW,IAAI,EAAE,aAAa;YAClE,6BAA6B,CAChC,CAAC;IACJ,CAAC;IAED,IAAI,KAAK,CAAC;IACV,IAAI,CAAC;QACH,KAAK,GAAG,MAAM,QAAQ,CACpB,IAAI,EACJ,SAAS,EACT,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CACpD,CAAC;IACJ,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,mBAAmB,CAC3B,WAAW,EACX,4CAA4C,MAAM,IAAI;YACpD,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAC9D,CAAC;IACJ,CAAC;IAED,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,YAAY,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC;AAC/D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB;IAClC,OAAO,MAAM,CAAC,IAAI,CAAC,kBAAkB,CAAC,CAAC;AACzC,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,gBAAgB;IAC9B,OAAO,oBAAoB,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC3C,CAAC"}
|
package/dist/vocab.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LDP / RDF vocabulary terms used by Linked Data Notifications.
|
|
3
|
+
*
|
|
4
|
+
* LDN layers a single property — `ldp:inbox` — and the LDP container model onto
|
|
5
|
+
* RDF; these constants are the full IRIs so callers never hard-code a typo'd
|
|
6
|
+
* namespace.
|
|
7
|
+
*/
|
|
8
|
+
/** The LDP namespace IRI. */
|
|
9
|
+
export declare const LDP_NAMESPACE = "http://www.w3.org/ns/ldp#";
|
|
10
|
+
/** `ldp:inbox` — the property a resource uses to advertise its inbox. */
|
|
11
|
+
export declare const LDP_INBOX = "http://www.w3.org/ns/ldp#inbox";
|
|
12
|
+
/** `ldp:contains` — links a container to each of its members. */
|
|
13
|
+
export declare const LDP_CONTAINS = "http://www.w3.org/ns/ldp#contains";
|
|
14
|
+
/** `ldp:constrainedBy` — points at the constraints a resource imposes (LDN §5.1). */
|
|
15
|
+
export declare const LDP_CONSTRAINED_BY = "http://www.w3.org/ns/ldp#constrainedBy";
|
|
16
|
+
/** `ldp:Container` — the type of an inbox (a container of notifications). */
|
|
17
|
+
export declare const LDP_CONTAINER = "http://www.w3.org/ns/ldp#Container";
|
|
18
|
+
/** `ldp:Resource` — the LDP resource type. */
|
|
19
|
+
export declare const LDP_RESOURCE = "http://www.w3.org/ns/ldp#Resource";
|
|
20
|
+
/** `rdf:type` — used to assert the inbox container's type in a listing. */
|
|
21
|
+
export declare const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
|
22
|
+
//# sourceMappingURL=vocab.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vocab.d.ts","sourceRoot":"","sources":["../src/vocab.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,6BAA6B;AAC7B,eAAO,MAAM,aAAa,8BAA8B,CAAC;AAEzD,yEAAyE;AACzE,eAAO,MAAM,SAAS,mCAA0B,CAAC;AAEjD,iEAAiE;AACjE,eAAO,MAAM,YAAY,sCAA6B,CAAC;AAEvD,qFAAqF;AACrF,eAAO,MAAM,kBAAkB,2CAAkC,CAAC;AAElE,6EAA6E;AAC7E,eAAO,MAAM,aAAa,uCAA8B,CAAC;AAEzD,8CAA8C;AAC9C,eAAO,MAAM,YAAY,sCAA6B,CAAC;AAEvD,2EAA2E;AAC3E,eAAO,MAAM,QAAQ,oDAAoD,CAAC"}
|
package/dist/vocab.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LDP / RDF vocabulary terms used by Linked Data Notifications.
|
|
3
|
+
*
|
|
4
|
+
* LDN layers a single property — `ldp:inbox` — and the LDP container model onto
|
|
5
|
+
* RDF; these constants are the full IRIs so callers never hard-code a typo'd
|
|
6
|
+
* namespace.
|
|
7
|
+
*/
|
|
8
|
+
/** The LDP namespace IRI. */
|
|
9
|
+
export const LDP_NAMESPACE = "http://www.w3.org/ns/ldp#";
|
|
10
|
+
/** `ldp:inbox` — the property a resource uses to advertise its inbox. */
|
|
11
|
+
export const LDP_INBOX = `${LDP_NAMESPACE}inbox`;
|
|
12
|
+
/** `ldp:contains` — links a container to each of its members. */
|
|
13
|
+
export const LDP_CONTAINS = `${LDP_NAMESPACE}contains`;
|
|
14
|
+
/** `ldp:constrainedBy` — points at the constraints a resource imposes (LDN §5.1). */
|
|
15
|
+
export const LDP_CONSTRAINED_BY = `${LDP_NAMESPACE}constrainedBy`;
|
|
16
|
+
/** `ldp:Container` — the type of an inbox (a container of notifications). */
|
|
17
|
+
export const LDP_CONTAINER = `${LDP_NAMESPACE}Container`;
|
|
18
|
+
/** `ldp:Resource` — the LDP resource type. */
|
|
19
|
+
export const LDP_RESOURCE = `${LDP_NAMESPACE}Resource`;
|
|
20
|
+
/** `rdf:type` — used to assert the inbox container's type in a listing. */
|
|
21
|
+
export const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
|
22
|
+
//# sourceMappingURL=vocab.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vocab.js","sourceRoot":"","sources":["../src/vocab.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,6BAA6B;AAC7B,MAAM,CAAC,MAAM,aAAa,GAAG,2BAA2B,CAAC;AAEzD,yEAAyE;AACzE,MAAM,CAAC,MAAM,SAAS,GAAG,GAAG,aAAa,OAAO,CAAC;AAEjD,iEAAiE;AACjE,MAAM,CAAC,MAAM,YAAY,GAAG,GAAG,aAAa,UAAU,CAAC;AAEvD,qFAAqF;AACrF,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,aAAa,eAAe,CAAC;AAElE,6EAA6E;AAC7E,MAAM,CAAC,MAAM,aAAa,GAAG,GAAG,aAAa,WAAW,CAAC;AAEzD,8CAA8C;AAC9C,MAAM,CAAC,MAAM,YAAY,GAAG,GAAG,aAAa,UAAU,CAAC;AAEvD,2EAA2E;AAC3E,MAAM,CAAC,MAAM,QAAQ,GAAG,iDAAiD,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dwk/ldn",
|
|
3
|
+
"version": "0.1.0-beta.0",
|
|
4
|
+
"description": "Linked Data Notifications (W3C LDN) primitives: inbox discovery, notification validation, and listing. RDF-only, protocol-agnostic.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"linked-data-notifications",
|
|
7
|
+
"ldn",
|
|
8
|
+
"inbox",
|
|
9
|
+
"rdf",
|
|
10
|
+
"solid",
|
|
11
|
+
"activitypub"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"license": "ISC",
|
|
15
|
+
"author": "David W. Keith <me@dwk.io>",
|
|
16
|
+
"homepage": "https://github.com/davidwkeith/workers/tree/main/packages/ldn#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/davidwkeith/workers.git",
|
|
20
|
+
"directory": "packages/ldn"
|
|
21
|
+
},
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./discovery": {
|
|
31
|
+
"types": "./dist/discovery.d.ts",
|
|
32
|
+
"import": "./dist/discovery.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"src",
|
|
38
|
+
"!src/**/*.test.ts"
|
|
39
|
+
],
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@dwk/rdf": "0.1.0-beta.0"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsc -p tsconfig.build.json",
|
|
48
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
49
|
+
"clean": "rm -rf dist"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LDN inbox discovery — both sides of the wire.
|
|
3
|
+
*
|
|
4
|
+
* A target resource advertises its inbox so a sender can find it. LDN allows the
|
|
5
|
+
* advertisement to travel either in an HTTP `Link` header or as an `ldp:inbox`
|
|
6
|
+
* triple in the resource's RDF body, so this module covers building the header
|
|
7
|
+
* (and triple) on the producer side and parsing both forms on the consumer side.
|
|
8
|
+
*
|
|
9
|
+
* It depends on `@dwk/rdf` for *types only* (erased at build), so it carries no
|
|
10
|
+
* RDF-parser runtime. It is re-exported from the package root and also reachable
|
|
11
|
+
* directly as `@dwk/ldn/discovery`, the n3-free entry point a binding-bound
|
|
12
|
+
* consumer (e.g. a Workers runtime) imports to advertise an inbox without
|
|
13
|
+
* pulling in the notification parser.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { StoredQuad } from "@dwk/rdf";
|
|
17
|
+
|
|
18
|
+
import { LDP_INBOX, LDP_CONSTRAINED_BY } from "./vocab";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_GRAPH = { termType: "DefaultGraph", value: "" } as const;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build the `Link` header value advertising `inboxIri` as the resource's inbox:
|
|
24
|
+
* `<inboxIri>; rel="http://www.w3.org/ns/ldp#inbox"`. Join it with any other
|
|
25
|
+
* link-values using `, ` to form a complete header.
|
|
26
|
+
*/
|
|
27
|
+
export function inboxLinkHeader(inboxIri: string): string {
|
|
28
|
+
return `<${inboxIri}>; rel="${LDP_INBOX}"`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the `Link` header value advertising `constraintsIri` as the constraints
|
|
33
|
+
* a resource imposes: `<constraintsIri>; rel="http://www.w3.org/ns/ldp#constrainedBy"`.
|
|
34
|
+
* LDN §5.1 (and LDP) lets a receiver point senders at a document describing the
|
|
35
|
+
* constraints it enforces — the media types it accepts, whether it requires a
|
|
36
|
+
* non-empty graph, and so on. Join it with any other link-values using `, `.
|
|
37
|
+
*/
|
|
38
|
+
export function constrainedByLinkHeader(constraintsIri: string): string {
|
|
39
|
+
return `<${constraintsIri}>; rel="${LDP_CONSTRAINED_BY}"`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build the `<subjectIri> ldp:inbox <inboxIri>` triple that advertises the inbox
|
|
44
|
+
* inside a resource's RDF body (the alternative to the `Link` header).
|
|
45
|
+
*/
|
|
46
|
+
export function inboxTriple(subjectIri: string, inboxIri: string): StoredQuad {
|
|
47
|
+
return {
|
|
48
|
+
subject: { termType: "NamedNode", value: subjectIri },
|
|
49
|
+
predicate: { termType: "NamedNode", value: LDP_INBOX },
|
|
50
|
+
object: { termType: "NamedNode", value: inboxIri },
|
|
51
|
+
graph: DEFAULT_GRAPH,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extract the inbox IRIs a graph advertises via `ldp:inbox`. When `subjectIri`
|
|
57
|
+
* is given, only triples with that subject are considered (the usual case: "what
|
|
58
|
+
* is _this_ resource's inbox?"); otherwise every `ldp:inbox` object is returned.
|
|
59
|
+
* Only named-node objects count — a literal `ldp:inbox` is not a dereferenceable
|
|
60
|
+
* inbox. Results are de-duplicated, preserving first-seen order.
|
|
61
|
+
*/
|
|
62
|
+
export function discoverInboxIris(
|
|
63
|
+
quads: readonly StoredQuad[],
|
|
64
|
+
subjectIri?: string,
|
|
65
|
+
): string[] {
|
|
66
|
+
const found = new Set<string>();
|
|
67
|
+
for (const quad of quads) {
|
|
68
|
+
if (quad.predicate.value !== LDP_INBOX) continue;
|
|
69
|
+
if (quad.object.termType !== "NamedNode") continue;
|
|
70
|
+
if (subjectIri !== undefined && quad.subject.value !== subjectIri) continue;
|
|
71
|
+
found.add(quad.object.value);
|
|
72
|
+
}
|
|
73
|
+
return [...found];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parse the inbox IRIs advertised by an HTTP `Link` header. A link-value counts
|
|
78
|
+
* when its `rel` token list includes the full `ldp:inbox` IRI (LDN's canonical
|
|
79
|
+
* form) or the bare `inbox` token. Returns de-duplicated, first-seen-order IRIs;
|
|
80
|
+
* an absent or malformed header yields an empty list.
|
|
81
|
+
*/
|
|
82
|
+
export function parseInboxLinks(linkHeader: string | null): string[] {
|
|
83
|
+
if (!linkHeader) return [];
|
|
84
|
+
const found = new Set<string>();
|
|
85
|
+
for (const entry of splitLinkValues(linkHeader)) {
|
|
86
|
+
const start = entry.indexOf("<");
|
|
87
|
+
const end = entry.indexOf(">", start + 1);
|
|
88
|
+
if (start === -1 || end === -1) continue;
|
|
89
|
+
const uri = entry.slice(start + 1, end).trim();
|
|
90
|
+
if (uri.length === 0) continue;
|
|
91
|
+
if (linkEntryRelIsInbox(entry.slice(end + 1))) found.add(uri);
|
|
92
|
+
}
|
|
93
|
+
return [...found];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Split a `Link` header into its individual link-values on top-level commas,
|
|
98
|
+
* tracking both quoted parameter values and `<…>` URI-references so a comma
|
|
99
|
+
* inside either never splits an entry (RFC 8288). A simple `,`-followed-by-`<`
|
|
100
|
+
* regex would mis-split a quoted parameter such as `title="a, <b>"`.
|
|
101
|
+
*/
|
|
102
|
+
function splitLinkValues(header: string): string[] {
|
|
103
|
+
const values: string[] = [];
|
|
104
|
+
let inQuotes = false;
|
|
105
|
+
let inBrackets = false;
|
|
106
|
+
let current = "";
|
|
107
|
+
for (let i = 0; i < header.length; i++) {
|
|
108
|
+
const char = header[i] as string;
|
|
109
|
+
if (char === '"' && header[i - 1] !== "\\") {
|
|
110
|
+
inQuotes = !inQuotes;
|
|
111
|
+
} else if (char === "<" && !inQuotes) {
|
|
112
|
+
inBrackets = true;
|
|
113
|
+
} else if (char === ">" && !inQuotes) {
|
|
114
|
+
inBrackets = false;
|
|
115
|
+
}
|
|
116
|
+
if (char === "," && !inQuotes && !inBrackets) {
|
|
117
|
+
values.push(current);
|
|
118
|
+
current = "";
|
|
119
|
+
} else {
|
|
120
|
+
current += char;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (current.trim() !== "") values.push(current);
|
|
124
|
+
return values;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Whether a link-value's parameter string declares `rel` naming the inbox. `rel`
|
|
129
|
+
* is a space-separated token list, so `rel="http://www.w3.org/ns/ldp#inbox"` and
|
|
130
|
+
* `rel="inbox alternate"` both count. Parameters are split on top-level `;`
|
|
131
|
+
* (respecting quotes) so a `rel=...` substring inside another quoted parameter
|
|
132
|
+
* value is not mistaken for the rel parameter.
|
|
133
|
+
*/
|
|
134
|
+
function linkEntryRelIsInbox(params: string): boolean {
|
|
135
|
+
for (const param of splitLinkParams(params)) {
|
|
136
|
+
const match = /^\s*rel\s*=\s*("([^"]*)"|'([^']*)'|[^;\s]+)\s*$/i.exec(
|
|
137
|
+
param,
|
|
138
|
+
);
|
|
139
|
+
if (match === null) continue;
|
|
140
|
+
const value = match[2] ?? match[3] ?? match[1] ?? "";
|
|
141
|
+
const tokens = value.split(/\s+/);
|
|
142
|
+
if (tokens.includes(LDP_INBOX) || tokens.includes("inbox")) return true;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Split a link-value parameter string on top-level `;`, respecting quotes. */
|
|
148
|
+
function splitLinkParams(params: string): string[] {
|
|
149
|
+
const parts: string[] = [];
|
|
150
|
+
let inQuotes = false;
|
|
151
|
+
let current = "";
|
|
152
|
+
for (let i = 0; i < params.length; i++) {
|
|
153
|
+
const char = params[i] as string;
|
|
154
|
+
if (char === '"' && params[i - 1] !== "\\") {
|
|
155
|
+
inQuotes = !inQuotes;
|
|
156
|
+
}
|
|
157
|
+
if (char === ";" && !inQuotes) {
|
|
158
|
+
parts.push(current);
|
|
159
|
+
current = "";
|
|
160
|
+
} else {
|
|
161
|
+
current += char;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (current.trim() !== "") parts.push(current);
|
|
165
|
+
return parts;
|
|
166
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/ldn` — Linked Data Notifications (W3C LDN) primitives.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Cross-standard reusable library: it implements the three LDN roles —
|
|
6
|
+
* **discovery** (advertise / find an inbox via `ldp:inbox`), **receiver**
|
|
7
|
+
* (validate a posted RDF notification), and **consumer** (read an inbox
|
|
8
|
+
* listing) — as plain-data functions over `@dwk/rdf`'s flat `StoredQuad`
|
|
9
|
+
* representation. It carries no Cloudflare bindings, no transport, and **no
|
|
10
|
+
* Solid/WAC assumptions**, so the same primitives back the `@dwk/solid-pod`
|
|
11
|
+
* inbox and the `@dwk/activitypub` inbox without either leaking into the other.
|
|
12
|
+
* Authorization, dedup, and storage stay the caller's concern.
|
|
13
|
+
*
|
|
14
|
+
* @see {@link https://www.w3.org/TR/ldn/ | W3C Linked Data Notifications}
|
|
15
|
+
* @see spec/packages/ldn.md
|
|
16
|
+
* @packageDocumentation
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
LDP_NAMESPACE,
|
|
21
|
+
LDP_INBOX,
|
|
22
|
+
LDP_CONTAINS,
|
|
23
|
+
LDP_CONSTRAINED_BY,
|
|
24
|
+
LDP_CONTAINER,
|
|
25
|
+
LDP_RESOURCE,
|
|
26
|
+
RDF_TYPE,
|
|
27
|
+
} from "./vocab";
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
inboxLinkHeader,
|
|
31
|
+
inboxTriple,
|
|
32
|
+
constrainedByLinkHeader,
|
|
33
|
+
discoverInboxIris,
|
|
34
|
+
parseInboxLinks,
|
|
35
|
+
} from "./discovery";
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
parseNotification,
|
|
39
|
+
acceptedContentTypes,
|
|
40
|
+
acceptPostHeader,
|
|
41
|
+
NotificationProblem,
|
|
42
|
+
type NotificationProblemCode,
|
|
43
|
+
type ParsedNotification,
|
|
44
|
+
type ParseNotificationOptions,
|
|
45
|
+
} from "./notification";
|
|
46
|
+
|
|
47
|
+
export { inboxListingQuads, listInboxMembers } from "./listing";
|
package/src/listing.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LDN consumer — inbox listing.
|
|
3
|
+
*
|
|
4
|
+
* A consumer `GET`s an inbox and reads the contained notification IRIs. The
|
|
5
|
+
* inbox is an `ldp:Container` whose members are linked with `ldp:contains`;
|
|
6
|
+
* {@link inboxListingQuads} builds exactly those triples in the flat
|
|
7
|
+
* `StoredQuad` shape so a caller can serialize them with `@dwk/rdf`, and
|
|
8
|
+
* {@link listInboxMembers} reads them back out of a fetched inbox graph.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { StoredQuad } from "@dwk/rdf";
|
|
12
|
+
|
|
13
|
+
import { LDP_CONTAINS, LDP_CONTAINER, RDF_TYPE } from "./vocab";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_GRAPH = { termType: "DefaultGraph", value: "" } as const;
|
|
16
|
+
|
|
17
|
+
function namedNode(value: string): StoredQuad["object"] {
|
|
18
|
+
return { termType: "NamedNode", value };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the triples describing an inbox listing: `<inboxIri> a ldp:Container`
|
|
23
|
+
* plus one `<inboxIri> ldp:contains <member>` per notification. Members are
|
|
24
|
+
* de-duplicated, preserving first-seen order.
|
|
25
|
+
*/
|
|
26
|
+
export function inboxListingQuads(
|
|
27
|
+
inboxIri: string,
|
|
28
|
+
memberIris: Iterable<string>,
|
|
29
|
+
): StoredQuad[] {
|
|
30
|
+
const subject = namedNode(inboxIri);
|
|
31
|
+
const quads: StoredQuad[] = [
|
|
32
|
+
{
|
|
33
|
+
subject,
|
|
34
|
+
predicate: namedNode(RDF_TYPE),
|
|
35
|
+
object: namedNode(LDP_CONTAINER),
|
|
36
|
+
graph: DEFAULT_GRAPH,
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
const seen = new Set<string>();
|
|
40
|
+
for (const member of memberIris) {
|
|
41
|
+
if (seen.has(member)) continue;
|
|
42
|
+
seen.add(member);
|
|
43
|
+
quads.push({
|
|
44
|
+
subject,
|
|
45
|
+
predicate: namedNode(LDP_CONTAINS),
|
|
46
|
+
object: namedNode(member),
|
|
47
|
+
graph: DEFAULT_GRAPH,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return quads;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read the notification IRIs an inbox graph contains via `ldp:contains`. When
|
|
55
|
+
* `inboxIri` is given, only that container's members are returned; otherwise
|
|
56
|
+
* every `ldp:contains` object is. Only named-node objects count. Results are
|
|
57
|
+
* de-duplicated, preserving first-seen order.
|
|
58
|
+
*/
|
|
59
|
+
export function listInboxMembers(
|
|
60
|
+
quads: readonly StoredQuad[],
|
|
61
|
+
inboxIri?: string,
|
|
62
|
+
): string[] {
|
|
63
|
+
const found = new Set<string>();
|
|
64
|
+
for (const quad of quads) {
|
|
65
|
+
if (quad.predicate.value !== LDP_CONTAINS) continue;
|
|
66
|
+
if (quad.object.termType !== "NamedNode") continue;
|
|
67
|
+
if (inboxIri !== undefined && quad.subject.value !== inboxIri) continue;
|
|
68
|
+
found.add(quad.object.value);
|
|
69
|
+
}
|
|
70
|
+
return [...found];
|
|
71
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LDN receiver — notification validation.
|
|
3
|
+
*
|
|
4
|
+
* An LDN receiver accepts a `POST` of an RDF notification to an inbox. The body
|
|
5
|
+
* MUST be a supported RDF serialization (LDN mandates JSON-LD; this also accepts
|
|
6
|
+
* the Turtle family `@dwk/rdf` supports) and MUST parse. This module classifies
|
|
7
|
+
* the two rejection cases an inbox owner needs to map to a status code —
|
|
8
|
+
* `unsupported_media_type` (415) and `malformed` (400) — and otherwise returns
|
|
9
|
+
* the parsed triples in the flat, storage-friendly `StoredQuad` shape. It stays
|
|
10
|
+
* protocol-agnostic: authorization, dedup, and storage are the caller's concern.
|
|
11
|
+
*
|
|
12
|
+
* Per LDN §3.3.1, a receiver SHOULD advertise the content types it accepts via
|
|
13
|
+
* an `Accept-Post` header on `OPTIONS`. {@link acceptedContentTypes} and
|
|
14
|
+
* {@link acceptPostHeader} build that advertisement from the same media-type
|
|
15
|
+
* table {@link parseNotification} validates against, so the two cannot drift.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
parse as parseRdf,
|
|
20
|
+
formatForMediaType,
|
|
21
|
+
quadToStored,
|
|
22
|
+
MEDIA_TYPE_FORMATS,
|
|
23
|
+
type RdfFormat,
|
|
24
|
+
type StoredQuad,
|
|
25
|
+
} from "@dwk/rdf";
|
|
26
|
+
|
|
27
|
+
/** Why a notification body was rejected. */
|
|
28
|
+
export type NotificationProblemCode = "unsupported_media_type" | "malformed";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A rejected notification body, carrying the HTTP status an LDN receiver should
|
|
32
|
+
* answer: `415` for a non-RDF / unknown media type, `400` for an RDF media type
|
|
33
|
+
* whose body does not parse.
|
|
34
|
+
*/
|
|
35
|
+
export class NotificationProblem extends Error {
|
|
36
|
+
readonly code: NotificationProblemCode;
|
|
37
|
+
readonly status: 415 | 400;
|
|
38
|
+
|
|
39
|
+
constructor(code: NotificationProblemCode, message: string) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "NotificationProblem";
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.status = code === "unsupported_media_type" ? 415 : 400;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A validated notification: its triples plus the RDF format it arrived in. */
|
|
48
|
+
export interface ParsedNotification {
|
|
49
|
+
readonly quads: StoredQuad[];
|
|
50
|
+
readonly mediaType: string;
|
|
51
|
+
readonly format: RdfFormat;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Options for {@link parseNotification}. */
|
|
55
|
+
export interface ParseNotificationOptions {
|
|
56
|
+
/** Base IRI used to resolve relative IRIs in the notification body. */
|
|
57
|
+
readonly baseIRI?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Validate and parse an LDN notification body. Throws a {@link NotificationProblem}
|
|
62
|
+
* when the `Content-Type` is missing / not a supported RDF serialization (415),
|
|
63
|
+
* or when the body fails to parse (400). On success the triples are returned as
|
|
64
|
+
* {@link StoredQuad}s ready to persist.
|
|
65
|
+
*
|
|
66
|
+
* A well-formed body that yields zero triples (an empty JSON-LD `@graph`, a bare
|
|
67
|
+
* `@context`, a Turtle document of only prefix declarations) is **accepted**:
|
|
68
|
+
* LDN §3.2 does not require a notification to carry at least one triple, so this
|
|
69
|
+
* returns an empty `quads` array rather than rejecting it as malformed.
|
|
70
|
+
*/
|
|
71
|
+
export async function parseNotification(
|
|
72
|
+
body: string,
|
|
73
|
+
contentType: string | null,
|
|
74
|
+
options: ParseNotificationOptions = {},
|
|
75
|
+
): Promise<ParsedNotification> {
|
|
76
|
+
// Resolve the clean media type once (no parameters/casing) and reuse it for
|
|
77
|
+
// both format lookup and parsing, consistent with the rest of the codebase.
|
|
78
|
+
const mediaType = contentType
|
|
79
|
+
? (contentType.split(";")[0]?.trim().toLowerCase() ?? "")
|
|
80
|
+
: "";
|
|
81
|
+
const format = mediaType ? formatForMediaType(mediaType) : undefined;
|
|
82
|
+
if (!format) {
|
|
83
|
+
throw new NotificationProblem(
|
|
84
|
+
"unsupported_media_type",
|
|
85
|
+
`@dwk/ldn: notification media type "${contentType ?? ""}" is not a ` +
|
|
86
|
+
`supported RDF serialization`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let quads;
|
|
91
|
+
try {
|
|
92
|
+
quads = await parseRdf(
|
|
93
|
+
body,
|
|
94
|
+
mediaType,
|
|
95
|
+
options.baseIRI ? { baseIRI: options.baseIRI } : {},
|
|
96
|
+
);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
throw new NotificationProblem(
|
|
99
|
+
"malformed",
|
|
100
|
+
`@dwk/ldn: notification body is not valid ${format}: ` +
|
|
101
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { quads: quads.map(quadToStored), mediaType, format };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The RDF media types {@link parseNotification} accepts, in registry order — the
|
|
110
|
+
* value set an LDN receiver should advertise so senders pick a supported
|
|
111
|
+
* serialization. Derived from `@dwk/rdf`'s `MEDIA_TYPE_FORMATS`, the same table
|
|
112
|
+
* parsing validates against, so the advertisement and the validator never drift.
|
|
113
|
+
*/
|
|
114
|
+
export function acceptedContentTypes(): string[] {
|
|
115
|
+
return Object.keys(MEDIA_TYPE_FORMATS);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build the `Accept-Post` header value (LDN §3.3.1) an LDN receiver should
|
|
120
|
+
* answer on `OPTIONS`, advertising every content type {@link parseNotification}
|
|
121
|
+
* accepts as a comma-separated list.
|
|
122
|
+
*/
|
|
123
|
+
export function acceptPostHeader(): string {
|
|
124
|
+
return acceptedContentTypes().join(", ");
|
|
125
|
+
}
|
package/src/vocab.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LDP / RDF vocabulary terms used by Linked Data Notifications.
|
|
3
|
+
*
|
|
4
|
+
* LDN layers a single property — `ldp:inbox` — and the LDP container model onto
|
|
5
|
+
* RDF; these constants are the full IRIs so callers never hard-code a typo'd
|
|
6
|
+
* namespace.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The LDP namespace IRI. */
|
|
10
|
+
export const LDP_NAMESPACE = "http://www.w3.org/ns/ldp#";
|
|
11
|
+
|
|
12
|
+
/** `ldp:inbox` — the property a resource uses to advertise its inbox. */
|
|
13
|
+
export const LDP_INBOX = `${LDP_NAMESPACE}inbox`;
|
|
14
|
+
|
|
15
|
+
/** `ldp:contains` — links a container to each of its members. */
|
|
16
|
+
export const LDP_CONTAINS = `${LDP_NAMESPACE}contains`;
|
|
17
|
+
|
|
18
|
+
/** `ldp:constrainedBy` — points at the constraints a resource imposes (LDN §5.1). */
|
|
19
|
+
export const LDP_CONSTRAINED_BY = `${LDP_NAMESPACE}constrainedBy`;
|
|
20
|
+
|
|
21
|
+
/** `ldp:Container` — the type of an inbox (a container of notifications). */
|
|
22
|
+
export const LDP_CONTAINER = `${LDP_NAMESPACE}Container`;
|
|
23
|
+
|
|
24
|
+
/** `ldp:Resource` — the LDP resource type. */
|
|
25
|
+
export const LDP_RESOURCE = `${LDP_NAMESPACE}Resource`;
|
|
26
|
+
|
|
27
|
+
/** `rdf:type` — used to assert the inbox container's type in a listing. */
|
|
28
|
+
export const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|