@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 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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"}
@@ -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"}
@@ -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"}
@@ -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
+ }
@@ -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";