@dwk/webmention 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 +140 -0
- package/dist/discovery.d.ts +43 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +128 -0
- package/dist/discovery.js.map +1 -0
- package/dist/fetch.d.ts +28 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +73 -0
- package/dist/fetch.js.map +1 -0
- package/dist/html.d.ts +68 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js +183 -0
- package/dist/html.js.map +1 -0
- package/dist/inbox.d.ts +41 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +73 -0
- package/dist/inbox.js.map +1 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +161 -0
- package/dist/index.js.map +1 -0
- package/dist/log.d.ts +42 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +40 -0
- package/dist/log.js.map +1 -0
- package/dist/safe-fetch.d.ts +101 -0
- package/dist/safe-fetch.d.ts.map +1 -0
- package/dist/safe-fetch.js +348 -0
- package/dist/safe-fetch.js.map +1 -0
- package/dist/sender.d.ts +43 -0
- package/dist/sender.d.ts.map +1 -0
- package/dist/sender.js +80 -0
- package/dist/sender.js.map +1 -0
- package/dist/validate.d.ts +47 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +76 -0
- package/dist/validate.js.map +1 -0
- package/dist/verify.d.ts +61 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +216 -0
- package/dist/verify.js.map +1 -0
- package/package.json +45 -0
- package/src/discovery.ts +167 -0
- package/src/fetch.ts +84 -0
- package/src/html.ts +206 -0
- package/src/inbox.ts +121 -0
- package/src/index.ts +297 -0
- package/src/log.ts +44 -0
- package/src/safe-fetch.ts +405 -0
- package/src/sender.ts +131 -0
- package/src/validate.ts +116 -0
- package/src/verify.ts +294 -0
package/dist/html.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/webmention` — HTML / `Link`-header parsing helpers.
|
|
3
|
+
*
|
|
4
|
+
* Shared by endpoint discovery (sender) and source verification (receiver).
|
|
5
|
+
* `Link`-header parsing is plain string scanning; HTML scanning uses the
|
|
6
|
+
* Workers runtime's streaming `HTMLRewriter` rather than regex tag matching, so
|
|
7
|
+
* it handles comments, attribute quoting, and malformed markup correctly
|
|
8
|
+
* without pulling a parser into the bundle (`HTMLRewriter` is built into the
|
|
9
|
+
* runtime — zero script-size cost; see `spec/non-functional-requirements.md`).
|
|
10
|
+
*
|
|
11
|
+
* Because `HTMLRewriter` is a `workerd` global, the HTML scanners are async and
|
|
12
|
+
* exercised under the Workers test pool, not bare Node.
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Parse an HTTP `Link` header into entries. Handles multiple comma-separated
|
|
18
|
+
* links and semicolon-separated parameters, e.g.
|
|
19
|
+
* `<https://a.example/webmention>; rel="webmention"`.
|
|
20
|
+
*
|
|
21
|
+
* Entries with no `rel` parameter are dropped; an empty URI (`<>`) is kept so
|
|
22
|
+
* the caller can resolve it against the document URL (a Webmention endpoint
|
|
23
|
+
* advertised at the page itself).
|
|
24
|
+
*/
|
|
25
|
+
export function parseLinkHeader(value) {
|
|
26
|
+
if (value === null || value.trim() === "") {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
const entries = [];
|
|
30
|
+
for (const part of splitLinks(value)) {
|
|
31
|
+
const match = /^\s*<([^>]*)>\s*(.*)$/.exec(part);
|
|
32
|
+
if (match === null) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const uri = match[1] ?? "";
|
|
36
|
+
const rels = splitTokens(extractRel(match[2] ?? ""));
|
|
37
|
+
if (rels.length > 0) {
|
|
38
|
+
entries.push({ uri, rels });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return entries;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Split a `Link` header on top-level commas, respecting both the angle-bracket
|
|
45
|
+
* URI reference and double-quoted parameter values — so a comma inside
|
|
46
|
+
* `title="A, B"` or inside `<…>` does not split the entry.
|
|
47
|
+
*/
|
|
48
|
+
function splitLinks(value) {
|
|
49
|
+
const result = [];
|
|
50
|
+
let depth = 0;
|
|
51
|
+
let inQuotes = false;
|
|
52
|
+
let current = "";
|
|
53
|
+
for (let i = 0; i < value.length; i++) {
|
|
54
|
+
const char = value[i];
|
|
55
|
+
if (char === '"' && value[i - 1] !== "\\") {
|
|
56
|
+
inQuotes = !inQuotes;
|
|
57
|
+
}
|
|
58
|
+
else if (!inQuotes && char === "<") {
|
|
59
|
+
depth++;
|
|
60
|
+
}
|
|
61
|
+
else if (!inQuotes && char === ">") {
|
|
62
|
+
depth--;
|
|
63
|
+
}
|
|
64
|
+
if (char === "," && depth === 0 && !inQuotes) {
|
|
65
|
+
result.push(current);
|
|
66
|
+
current = "";
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
current += char;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (current.trim() !== "") {
|
|
73
|
+
result.push(current);
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Split a `Link` entry's parameter string on top-level semicolons, respecting
|
|
79
|
+
* double-quoted values so a `;` inside a quoted value does not split a param.
|
|
80
|
+
*/
|
|
81
|
+
function splitParams(paramString) {
|
|
82
|
+
const result = [];
|
|
83
|
+
let inQuotes = false;
|
|
84
|
+
let current = "";
|
|
85
|
+
for (let i = 0; i < paramString.length; i++) {
|
|
86
|
+
const char = paramString[i];
|
|
87
|
+
if (char === '"' && paramString[i - 1] !== "\\") {
|
|
88
|
+
inQuotes = !inQuotes;
|
|
89
|
+
}
|
|
90
|
+
if (char === ";" && !inQuotes) {
|
|
91
|
+
result.push(current);
|
|
92
|
+
current = "";
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
current += char;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (current.trim() !== "") {
|
|
99
|
+
result.push(current);
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Extract the `rel` parameter from a `Link` entry's parameter string. Matches
|
|
105
|
+
* the `rel` parameter exactly (per-parameter), so a `rel=` substring inside
|
|
106
|
+
* another parameter's quoted value (e.g. `title="my rel=x"`) is not mistaken
|
|
107
|
+
* for it.
|
|
108
|
+
*/
|
|
109
|
+
function extractRel(paramString) {
|
|
110
|
+
for (const param of splitParams(paramString)) {
|
|
111
|
+
const match = /^\s*rel\s*=\s*("([^"]*)"|'([^']*)'|[^;\s]+)\s*$/i.exec(param);
|
|
112
|
+
if (match !== null) {
|
|
113
|
+
return match[2] ?? match[3] ?? match[1] ?? null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Whether a `Content-Type` value names an HTML document (`text/html` or
|
|
120
|
+
* `application/xhtml+xml`). Compares the media type's essence — the part before
|
|
121
|
+
* any `;` parameters — case-insensitively, so `text/html; charset=utf-8`
|
|
122
|
+
* matches but an unrelated type carrying `text/html` inside a parameter does
|
|
123
|
+
* not.
|
|
124
|
+
*/
|
|
125
|
+
export function isHtmlContentType(contentType) {
|
|
126
|
+
const essence = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
127
|
+
return essence === "text/html" || essence === "application/xhtml+xml";
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Whether a `Content-Type` value names a JSON document (`application/json` or a
|
|
131
|
+
* `+json`-suffixed type such as `application/activity+json`). Compares the media
|
|
132
|
+
* type's essence — the part before any `;` parameters — case-insensitively.
|
|
133
|
+
*/
|
|
134
|
+
export function isJsonContentType(contentType) {
|
|
135
|
+
const essence = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
136
|
+
return essence === "application/json" || essence.endsWith("+json");
|
|
137
|
+
}
|
|
138
|
+
/** Split a whitespace-separated token list (e.g. a `rel` value) into tokens. */
|
|
139
|
+
export function splitTokens(value) {
|
|
140
|
+
if (value === null) {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
return value
|
|
144
|
+
.trim()
|
|
145
|
+
.split(/\s+/)
|
|
146
|
+
.filter((token) => token !== "");
|
|
147
|
+
}
|
|
148
|
+
/** Resolve `uri` against `base`, returning a normalized absolute URL or `null`. */
|
|
149
|
+
export function resolveUrl(uri, base) {
|
|
150
|
+
try {
|
|
151
|
+
return new URL(uri, base).toString();
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Scan `html` with the runtime's streaming `HTMLRewriter`, returning — in
|
|
159
|
+
* document order — every element matching `selector` together with the
|
|
160
|
+
* requested attribute values.
|
|
161
|
+
*
|
|
162
|
+
* Using a real tokenizer (rather than regex) means elements inside comments are
|
|
163
|
+
* never reported (the parser treats comment contents as text, satisfying
|
|
164
|
+
* webmention.rocks discovery test 13), attribute quoting is handled correctly,
|
|
165
|
+
* and a `data-href` attribute is never mistaken for `href`. An absent attribute
|
|
166
|
+
* is reported as `null`; a present-but-empty one (`href=""`) as `""`.
|
|
167
|
+
*/
|
|
168
|
+
export async function scanElements(html, selector, attrNames) {
|
|
169
|
+
const elements = [];
|
|
170
|
+
const rewriter = new HTMLRewriter().on(selector, {
|
|
171
|
+
element(el) {
|
|
172
|
+
const attrs = {};
|
|
173
|
+
for (const name of attrNames) {
|
|
174
|
+
attrs[name] = el.getAttribute(name);
|
|
175
|
+
}
|
|
176
|
+
elements.push({ name: el.tagName, attrs });
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
// Drive the parser to completion by consuming the transformed body.
|
|
180
|
+
await rewriter.transform(new Response(html)).text();
|
|
181
|
+
return elements;
|
|
182
|
+
}
|
|
183
|
+
//# sourceMappingURL=html.js.map
|
package/dist/html.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"html.js","sourceRoot":"","sources":["../src/html.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAQH;;;;;;;;GAQG;AACH,MAAM,UAAU,eAAe,CAAC,KAAoB;IAClD,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAC1C,OAAO,EAAE,CAAC;IACZ,CAAC;IACD,MAAM,OAAO,GAAsB,EAAE,CAAC;IACtC,KAAK,MAAM,IAAI,IAAI,UAAU,CAAC,KAAK,CAAC,EAAE,CAAC;QACrC,MAAM,KAAK,GAAG,uBAAuB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACjD,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;YACnB,SAAS;QACX,CAAC;QACD,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAC3B,MAAM,IAAI,GAAG,WAAW,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;QACrD,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACpB,OAAO,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC;QAC9B,CAAC;IACH,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;;GAIG;AACH,SAAS,UAAU,CAAC,KAAa;IAC/B,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,IAAI,OAAO,GAAG,EAAE,CAAC;IACjB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACtC,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAW,CAAC;QAChC,IAAI,IAAI,KAAK,GAAG,IAAI,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;YAC1C,QAAQ,GAAG,CAAC,QAAQ,CAAC;QACvB,CAAC;aAAM,IAAI,CAAC,QAAQ,IAAI,IAAI,KAAK,GAAG,EAAE,CAAC;YACrC,KAAK,EAAE,CAAC;QACV,CAAC;aAAM,IAAI,CAAC,QAAQ,IAAI,IAAI,KAAK,GAAG,EAAE,CAAC;YACrC,KAAK,EAAE,CAAC;QACV,CAAC;QACD,IAAI,IAAI,KAAK,GAAG,IAAI,KAAK,KAAK,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YAC7C,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YACrB,OAAO,GAAG,EAAE,CAAC;QACf,CAAC;aAAM,CAAC;YACN,OAAO,IAAI,IAAI,CAAC;QAClB,CAAC;IACH,CAAC;IACD,IAAI,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAC1B,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACvB,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;GAGG;AACH,SAAS,WAAW,CAAC,WAAmB;IACtC,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,IAAI,OAAO,GAAG,EAAE,CAAC;IACjB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,WAAW,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QAC5C,MAAM,IAAI,GAAG,WAAW,CAAC,CAAC,CAAW,CAAC;QACtC,IAAI,IAAI,KAAK,GAAG,IAAI,WAAW,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;YAChD,QAAQ,GAAG,CAAC,QAAQ,CAAC;QACvB,CAAC;QACD,IAAI,IAAI,KAAK,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC;YAC9B,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YACrB,OAAO,GAAG,EAAE,CAAC;QACf,CAAC;aAAM,CAAC;YACN,OAAO,IAAI,IAAI,CAAC;QAClB,CAAC;IACH,CAAC;IACD,IAAI,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAC1B,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACvB,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;GAKG;AACH,SAAS,UAAU,CAAC,WAAmB;IACrC,KAAK,MAAM,KAAK,IAAI,WAAW,CAAC,WAAW,CAAC,EAAE,CAAC;QAC7C,MAAM,KAAK,GAAG,kDAAkD,CAAC,IAAI,CACnE,KAAK,CACN,CAAC;QACF,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;YACnB,OAAO,KAAK,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;QAClD,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAAC,WAAmB;IACnD,MAAM,OAAO,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC;IACtE,OAAO,OAAO,KAAK,WAAW,IAAI,OAAO,KAAK,uBAAuB,CAAC;AACxE,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,WAAmB;IACnD,MAAM,OAAO,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC;IACtE,OAAO,OAAO,KAAK,kBAAkB,IAAI,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;AACrE,CAAC;AAED,gFAAgF;AAChF,MAAM,UAAU,WAAW,CAAC,KAAoB;IAC9C,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;QACnB,OAAO,EAAE,CAAC;IACZ,CAAC;IACD,OAAO,KAAK;SACT,IAAI,EAAE;SACN,KAAK,CAAC,KAAK,CAAC;SACZ,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,KAAK,EAAE,CAAC,CAAC;AACrC,CAAC;AAED,mFAAmF;AACnF,MAAM,UAAU,UAAU,CAAC,GAAW,EAAE,IAAY;IAClD,IAAI,CAAC;QACH,OAAO,IAAI,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAC;IACvC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAUD;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,IAAY,EACZ,QAAgB,EAChB,SAA4B;IAE5B,MAAM,QAAQ,GAAqB,EAAE,CAAC;IACtC,MAAM,QAAQ,GAAG,IAAI,YAAY,EAAE,CAAC,EAAE,CAAC,QAAQ,EAAE;QAC/C,OAAO,CAAC,EAAE;YACR,MAAM,KAAK,GAAkC,EAAE,CAAC;YAChD,KAAK,MAAM,IAAI,IAAI,SAAS,EAAE,CAAC;gBAC7B,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;YACtC,CAAC;YACD,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,EAAE,CAAC,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC;QAC7C,CAAC;KACF,CAAC,CAAC;IACH,oEAAoE;IACpE,MAAM,QAAQ,CAAC,SAAS,CAAC,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IACpD,OAAO,QAAQ,CAAC;AAClB,CAAC"}
|
package/dist/inbox.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/webmention` — inbox store.
|
|
3
|
+
*
|
|
4
|
+
* Verified mentions are persisted to an inbox so they can be surfaced on the
|
|
5
|
+
* target resource. The default is a D1-backed store (strongly consistent —
|
|
6
|
+
* never KV, per `spec/non-functional-requirements.md`); when composed into a
|
|
7
|
+
* Solid Pod, a caller can supply an {@link InboxStore} backed by the
|
|
8
|
+
* `@dwk/solid-pod` Durable Object instead. The store keys on the
|
|
9
|
+
* `(source, target)` pair so re-verifying a mention updates it in place and a
|
|
10
|
+
* source that drops the link can be removed. See `spec/packages/webmention.md`.
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
15
|
+
/** A verified Webmention: `source` links to `target`, confirmed at `verifiedAt`. */
|
|
16
|
+
export interface VerifiedMention {
|
|
17
|
+
readonly source: string;
|
|
18
|
+
readonly target: string;
|
|
19
|
+
/** Verification time, epoch milliseconds. */
|
|
20
|
+
readonly verifiedAt: number;
|
|
21
|
+
}
|
|
22
|
+
/** Persistence surface for verified mentions. */
|
|
23
|
+
export interface InboxStore {
|
|
24
|
+
/** Upsert a verified mention, keyed on `(source, target)`. */
|
|
25
|
+
store(mention: VerifiedMention): Promise<void>;
|
|
26
|
+
/** Remove a mention (e.g. the source dropped the link); no-op when absent. */
|
|
27
|
+
remove(source: string, target: string): Promise<void>;
|
|
28
|
+
/** List mentions, newest first; scoped to `target` when given. */
|
|
29
|
+
list(target?: string): Promise<VerifiedMention[]>;
|
|
30
|
+
}
|
|
31
|
+
/** Options for {@link createD1Inbox}. */
|
|
32
|
+
export interface D1InboxOptions {
|
|
33
|
+
/** Table name to use; created if absent. Defaults to `webmentions`. */
|
|
34
|
+
readonly table?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Build a D1-backed {@link InboxStore}. The backing table is created on first
|
|
38
|
+
* use if it does not already exist.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createD1Inbox(db: D1Database, options?: D1InboxOptions): InboxStore;
|
|
41
|
+
//# sourceMappingURL=inbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inbox.d.ts","sourceRoot":"","sources":["../src/inbox.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAE5D,oFAAoF;AACpF,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,6CAA6C;IAC7C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,iDAAiD;AACjD,MAAM,WAAW,UAAU;IACzB,8DAA8D;IAC9D,KAAK,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,8EAA8E;IAC9E,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD,kEAAkE;IAClE,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAAC;CACnD;AAED,yCAAyC;AACzC,MAAM,WAAW,cAAc;IAC7B,uEAAuE;IACvE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAQD;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,EAAE,EAAE,UAAU,EACd,OAAO,CAAC,EAAE,cAAc,GACvB,UAAU,CAmEZ"}
|
package/dist/inbox.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/webmention` — inbox store.
|
|
3
|
+
*
|
|
4
|
+
* Verified mentions are persisted to an inbox so they can be surfaced on the
|
|
5
|
+
* target resource. The default is a D1-backed store (strongly consistent —
|
|
6
|
+
* never KV, per `spec/non-functional-requirements.md`); when composed into a
|
|
7
|
+
* Solid Pod, a caller can supply an {@link InboxStore} backed by the
|
|
8
|
+
* `@dwk/solid-pod` Durable Object instead. The store keys on the
|
|
9
|
+
* `(source, target)` pair so re-verifying a mention updates it in place and a
|
|
10
|
+
* source that drops the link can be removed. See `spec/packages/webmention.md`.
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Build a D1-backed {@link InboxStore}. The backing table is created on first
|
|
16
|
+
* use if it does not already exist.
|
|
17
|
+
*/
|
|
18
|
+
export function createD1Inbox(db, options) {
|
|
19
|
+
const table = options?.table ?? "webmentions";
|
|
20
|
+
// Guard the identifier: it is interpolated into DDL, so only allow a safe
|
|
21
|
+
// set of characters rather than trusting the caller blindly.
|
|
22
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) {
|
|
23
|
+
throw new Error(`@dwk/webmention: invalid inbox table name "${table}".`);
|
|
24
|
+
}
|
|
25
|
+
let ready = null;
|
|
26
|
+
const ensureSchema = () => {
|
|
27
|
+
ready ??= db
|
|
28
|
+
.prepare(`CREATE TABLE IF NOT EXISTS ${table} (` +
|
|
29
|
+
`source TEXT NOT NULL, ` +
|
|
30
|
+
`target TEXT NOT NULL, ` +
|
|
31
|
+
`verified_at INTEGER NOT NULL, ` +
|
|
32
|
+
`PRIMARY KEY (source, target))`)
|
|
33
|
+
.run()
|
|
34
|
+
.then(() => undefined);
|
|
35
|
+
return ready;
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
async store(mention) {
|
|
39
|
+
await ensureSchema();
|
|
40
|
+
await db
|
|
41
|
+
.prepare(`INSERT INTO ${table} (source, target, verified_at) ` +
|
|
42
|
+
`VALUES (?1, ?2, ?3) ` +
|
|
43
|
+
`ON CONFLICT (source, target) ` +
|
|
44
|
+
`DO UPDATE SET verified_at = excluded.verified_at`)
|
|
45
|
+
.bind(mention.source, mention.target, mention.verifiedAt)
|
|
46
|
+
.run();
|
|
47
|
+
},
|
|
48
|
+
async remove(source, target) {
|
|
49
|
+
await ensureSchema();
|
|
50
|
+
await db
|
|
51
|
+
.prepare(`DELETE FROM ${table} WHERE source = ?1 AND target = ?2`)
|
|
52
|
+
.bind(source, target)
|
|
53
|
+
.run();
|
|
54
|
+
},
|
|
55
|
+
async list(target) {
|
|
56
|
+
await ensureSchema();
|
|
57
|
+
const statement = target === undefined
|
|
58
|
+
? db.prepare(`SELECT source, target, verified_at FROM ${table} ` +
|
|
59
|
+
`ORDER BY verified_at DESC`)
|
|
60
|
+
: db
|
|
61
|
+
.prepare(`SELECT source, target, verified_at FROM ${table} ` +
|
|
62
|
+
`WHERE target = ?1 ORDER BY verified_at DESC`)
|
|
63
|
+
.bind(target);
|
|
64
|
+
const { results } = await statement.all();
|
|
65
|
+
return results.map((row) => ({
|
|
66
|
+
source: row.source,
|
|
67
|
+
target: row.target,
|
|
68
|
+
verifiedAt: row.verified_at,
|
|
69
|
+
}));
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=inbox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inbox.js","sourceRoot":"","sources":["../src/inbox.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAkCH;;;GAGG;AACH,MAAM,UAAU,aAAa,CAC3B,EAAc,EACd,OAAwB;IAExB,MAAM,KAAK,GAAG,OAAO,EAAE,KAAK,IAAI,aAAa,CAAC;IAC9C,0EAA0E;IAC1E,6DAA6D;IAC7D,IAAI,CAAC,0BAA0B,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QAC5C,MAAM,IAAI,KAAK,CAAC,8CAA8C,KAAK,IAAI,CAAC,CAAC;IAC3E,CAAC;IAED,IAAI,KAAK,GAAyB,IAAI,CAAC;IACvC,MAAM,YAAY,GAAG,GAAkB,EAAE;QACvC,KAAK,KAAK,EAAE;aACT,OAAO,CACN,8BAA8B,KAAK,IAAI;YACrC,wBAAwB;YACxB,wBAAwB;YACxB,gCAAgC;YAChC,+BAA+B,CAClC;aACA,GAAG,EAAE;aACL,IAAI,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QACzB,OAAO,KAAK,CAAC;IACf,CAAC,CAAC;IAEF,OAAO;QACL,KAAK,CAAC,KAAK,CAAC,OAAO;YACjB,MAAM,YAAY,EAAE,CAAC;YACrB,MAAM,EAAE;iBACL,OAAO,CACN,eAAe,KAAK,iCAAiC;gBACnD,sBAAsB;gBACtB,+BAA+B;gBAC/B,kDAAkD,CACrD;iBACA,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,UAAU,CAAC;iBACxD,GAAG,EAAE,CAAC;QACX,CAAC;QAED,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM;YACzB,MAAM,YAAY,EAAE,CAAC;YACrB,MAAM,EAAE;iBACL,OAAO,CAAC,eAAe,KAAK,oCAAoC,CAAC;iBACjE,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC;iBACpB,GAAG,EAAE,CAAC;QACX,CAAC;QAED,KAAK,CAAC,IAAI,CAAC,MAAM;YACf,MAAM,YAAY,EAAE,CAAC;YACrB,MAAM,SAAS,GACb,MAAM,KAAK,SAAS;gBAClB,CAAC,CAAC,EAAE,CAAC,OAAO,CACR,2CAA2C,KAAK,GAAG;oBACjD,2BAA2B,CAC9B;gBACH,CAAC,CAAC,EAAE;qBACC,OAAO,CACN,2CAA2C,KAAK,GAAG;oBACjD,6CAA6C,CAChD;qBACA,IAAI,CAAC,MAAM,CAAC,CAAC;YACtB,MAAM,EAAE,OAAO,EAAE,GAAG,MAAM,SAAS,CAAC,GAAG,EAAc,CAAC;YACtD,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;gBAC3B,MAAM,EAAE,GAAG,CAAC,MAAM;gBAClB,MAAM,EAAE,GAAG,CAAC,MAAM;gBAClB,UAAU,EAAE,GAAG,CAAC,WAAW;aAC5B,CAAC,CAAC,CAAC;QACN,CAAC;KACF,CAAC;AACJ,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/webmention` — Webmention (W3C) receiver + sender.
|
|
3
|
+
*
|
|
4
|
+
* Endpoint package. The receiver validates `source`/`target` synchronously,
|
|
5
|
+
* returns `202 Accepted`, and enqueues the pair for asynchronous link
|
|
6
|
+
* verification; the queue consumer fetches the source, confirms it links to the
|
|
7
|
+
* target, and persists (or removes) the mention in an inbox. The sender
|
|
8
|
+
* discovers a target's Webmention endpoint and notifies it on publish. Cloud
|
|
9
|
+
* specifics (Queue, D1) are confined here; HTML scanning uses the runtime's
|
|
10
|
+
* streaming `HTMLRewriter`, so the parsing/verification helpers are async and
|
|
11
|
+
* exercised under the Workers test pool.
|
|
12
|
+
*
|
|
13
|
+
* @see spec/packages/webmention.md
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
import type { D1Database, ExecutionContext, MessageBatch, Queue } from "@cloudflare/workers-types";
|
|
17
|
+
import { type Logger, type Metrics } from "@dwk/log";
|
|
18
|
+
import { type InboxStore } from "./inbox";
|
|
19
|
+
import type { FetchLike } from "./fetch";
|
|
20
|
+
export { validateWebmentionParams, type ValidateParams, type ValidationResult, type WebmentionValidationError, } from "./validate";
|
|
21
|
+
export { discoverEndpoint, findWebmentionEndpoint, type DiscoverOptions, } from "./discovery";
|
|
22
|
+
export { sendWebmention, sendWebmentions, type SendOptions, type SendResult, } from "./sender";
|
|
23
|
+
export { verifySource, sourceLinksTo, extractLinks, type VerifyOptions, type VerifyResult, } from "./verify";
|
|
24
|
+
export { createD1Inbox, type InboxStore, type VerifiedMention, type D1InboxOptions, } from "./inbox";
|
|
25
|
+
export type { FetchLike } from "./fetch";
|
|
26
|
+
export { safeFetch, assertPublicUrl, isPrivateOrReservedHost, SsrfError, DEFAULT_MAX_REDIRECTS, DEFAULT_TIMEOUT_MS, type SafeFetchOptions, type SafeFetchResult, type SsrfReason, } from "./safe-fetch";
|
|
27
|
+
export { WebmentionLogEvent } from "./log";
|
|
28
|
+
export type { Logger, Metrics } from "@dwk/log";
|
|
29
|
+
/** A queued verification job: confirm that `source` links to `target`. */
|
|
30
|
+
export interface WebmentionJob {
|
|
31
|
+
readonly source: string;
|
|
32
|
+
readonly target: string;
|
|
33
|
+
}
|
|
34
|
+
/** Cloudflare bindings required by the Webmention handler and queue consumer. */
|
|
35
|
+
export interface WebmentionEnv {
|
|
36
|
+
/** Queue producer for async verification of received mentions. */
|
|
37
|
+
readonly WEBMENTION_QUEUE: Queue<WebmentionJob>;
|
|
38
|
+
/**
|
|
39
|
+
* D1 database backing the default inbox. Optional only when an
|
|
40
|
+
* {@link InboxStore} is supplied via {@link WebmentionConfig.inbox} (e.g. a
|
|
41
|
+
* Solid Pod DO-backed inbox).
|
|
42
|
+
*/
|
|
43
|
+
readonly WEBMENTION_INBOX?: D1Database;
|
|
44
|
+
}
|
|
45
|
+
/** Configuration passed to {@link createWebmention}. */
|
|
46
|
+
export interface WebmentionConfig {
|
|
47
|
+
/** Base URL of this receiver; a `target` must live under its origin. */
|
|
48
|
+
readonly baseUrl: string;
|
|
49
|
+
/** Additional controlled hostnames besides `baseUrl`'s. */
|
|
50
|
+
readonly allowedHosts?: readonly string[];
|
|
51
|
+
/**
|
|
52
|
+
* Inbox store for verified mentions. Defaults to a D1 store built from
|
|
53
|
+
* {@link WebmentionEnv.WEBMENTION_INBOX}; supply one to back the inbox with
|
|
54
|
+
* the `@dwk/solid-pod` Durable Object instead.
|
|
55
|
+
*/
|
|
56
|
+
readonly inbox?: InboxStore;
|
|
57
|
+
/** `fetch` implementation for verification; defaults to the global `fetch`. */
|
|
58
|
+
readonly fetch?: FetchLike;
|
|
59
|
+
/**
|
|
60
|
+
* Logger for receiver/queue events; defaults to a no-op. Wire a real logger
|
|
61
|
+
* (see `@dwk/log`) to surface SSRF blocks, validation rejections, and
|
|
62
|
+
* poison-message retries instead of swallowing them.
|
|
63
|
+
*/
|
|
64
|
+
readonly logger?: Logger;
|
|
65
|
+
/**
|
|
66
|
+
* Metrics sink for receiver/queue counters; defaults to a no-op. Wire an
|
|
67
|
+
* adapter (e.g. `analyticsEngineMetrics` from `@dwk/log`, bound to an
|
|
68
|
+
* `AnalyticsEngineDataset`) to chart the same events the logger names —
|
|
69
|
+
* "SSRF blocks/min", "verification success rate", "queue retries by reason".
|
|
70
|
+
*/
|
|
71
|
+
readonly metrics?: Metrics;
|
|
72
|
+
}
|
|
73
|
+
/** A `fetch`-compatible Worker handler. */
|
|
74
|
+
export type WebmentionHandler = (request: Request, env: WebmentionEnv, ctx: ExecutionContext) => Promise<Response>;
|
|
75
|
+
/** A Queue consumer for asynchronous Webmention verification. */
|
|
76
|
+
export type WebmentionQueueConsumer = (batch: MessageBatch<WebmentionJob>, env: WebmentionEnv, ctx: ExecutionContext) => Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* Build the Webmention receiver handler from configuration.
|
|
79
|
+
*
|
|
80
|
+
* The returned handler is mountable under any path prefix. It accepts a
|
|
81
|
+
* form-encoded `POST` (`source` + `target`), validates synchronously, enqueues
|
|
82
|
+
* the pair for verification, and returns `202 Accepted`. Invalid requests get
|
|
83
|
+
* `400`; other methods get `405`. Fails loudly if the required `WEBMENTION_QUEUE`
|
|
84
|
+
* binding is missing.
|
|
85
|
+
*/
|
|
86
|
+
export declare function createWebmention(config: WebmentionConfig): WebmentionHandler;
|
|
87
|
+
/**
|
|
88
|
+
* Build the Queue consumer that verifies received mentions.
|
|
89
|
+
*
|
|
90
|
+
* For each job it fetches the source and checks for a link to the target: a
|
|
91
|
+
* verified mention is upserted into the inbox, while a source that no longer
|
|
92
|
+
* links is removed. A job that throws is retried; otherwise it is acked. Fails
|
|
93
|
+
* loudly if no inbox is configured.
|
|
94
|
+
*/
|
|
95
|
+
export declare function createWebmentionQueueConsumer(config: WebmentionConfig): WebmentionQueueConsumer;
|
|
96
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/webmention` — Webmention (W3C) receiver + sender.
|
|
3
|
+
*
|
|
4
|
+
* Endpoint package. The receiver validates `source`/`target` synchronously,
|
|
5
|
+
* returns `202 Accepted`, and enqueues the pair for asynchronous link
|
|
6
|
+
* verification; the queue consumer fetches the source, confirms it links to the
|
|
7
|
+
* target, and persists (or removes) the mention in an inbox. The sender
|
|
8
|
+
* discovers a target's Webmention endpoint and notifies it on publish. Cloud
|
|
9
|
+
* specifics (Queue, D1) are confined here; HTML scanning uses the runtime's
|
|
10
|
+
* streaming `HTMLRewriter`, so the parsing/verification helpers are async and
|
|
11
|
+
* exercised under the Workers test pool.
|
|
12
|
+
*
|
|
13
|
+
* @see spec/packages/webmention.md
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
import { hostFromUrl, noopLogger, noopMetrics, } from "@dwk/log";
|
|
17
|
+
import { createD1Inbox } from "./inbox";
|
|
18
|
+
import { WebmentionLogEvent } from "./log";
|
|
19
|
+
import { validateWebmentionParams } from "./validate";
|
|
20
|
+
import { verifySource } from "./verify";
|
|
21
|
+
export { validateWebmentionParams, } from "./validate";
|
|
22
|
+
export { discoverEndpoint, findWebmentionEndpoint, } from "./discovery";
|
|
23
|
+
export { sendWebmention, sendWebmentions, } from "./sender";
|
|
24
|
+
export { verifySource, sourceLinksTo, extractLinks, } from "./verify";
|
|
25
|
+
export { createD1Inbox, } from "./inbox";
|
|
26
|
+
export { safeFetch, assertPublicUrl, isPrivateOrReservedHost, SsrfError, DEFAULT_MAX_REDIRECTS, DEFAULT_TIMEOUT_MS, } from "./safe-fetch";
|
|
27
|
+
export { WebmentionLogEvent } from "./log";
|
|
28
|
+
function textResponse(status, body) {
|
|
29
|
+
return new Response(body, {
|
|
30
|
+
status,
|
|
31
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function resolveInbox(config, env) {
|
|
35
|
+
if (config.inbox !== undefined) {
|
|
36
|
+
return config.inbox;
|
|
37
|
+
}
|
|
38
|
+
if (env.WEBMENTION_INBOX !== undefined) {
|
|
39
|
+
return createD1Inbox(env.WEBMENTION_INBOX);
|
|
40
|
+
}
|
|
41
|
+
throw new Error("@dwk/webmention: no inbox configured — provide config.inbox or bind " +
|
|
42
|
+
"WEBMENTION_INBOX (D1).");
|
|
43
|
+
}
|
|
44
|
+
function formValue(value) {
|
|
45
|
+
return typeof value === "string" ? value : null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Whether the request body is `application/x-www-form-urlencoded` — the encoding
|
|
49
|
+
* Webmention §3.1.3 requires. `Request.formData()` would also accept
|
|
50
|
+
* `multipart/form-data`, so the essence is checked up front rather than relying
|
|
51
|
+
* on it.
|
|
52
|
+
*/
|
|
53
|
+
function isFormUrlEncoded(contentType) {
|
|
54
|
+
const essence = contentType?.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
55
|
+
return essence === "application/x-www-form-urlencoded";
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Build the Webmention receiver handler from configuration.
|
|
59
|
+
*
|
|
60
|
+
* The returned handler is mountable under any path prefix. It accepts a
|
|
61
|
+
* form-encoded `POST` (`source` + `target`), validates synchronously, enqueues
|
|
62
|
+
* the pair for verification, and returns `202 Accepted`. Invalid requests get
|
|
63
|
+
* `400`; other methods get `405`. Fails loudly if the required `WEBMENTION_QUEUE`
|
|
64
|
+
* binding is missing.
|
|
65
|
+
*/
|
|
66
|
+
export function createWebmention(config) {
|
|
67
|
+
const logger = config.logger ?? noopLogger;
|
|
68
|
+
const metrics = config.metrics ?? noopMetrics;
|
|
69
|
+
return async (request, env, _ctx) => {
|
|
70
|
+
if (request.method !== "POST") {
|
|
71
|
+
return new Response("Method Not Allowed", {
|
|
72
|
+
status: 405,
|
|
73
|
+
headers: { allow: "POST" },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (env.WEBMENTION_QUEUE === undefined) {
|
|
77
|
+
throw new Error("@dwk/webmention: missing required binding WEBMENTION_QUEUE.");
|
|
78
|
+
}
|
|
79
|
+
if (!isFormUrlEncoded(request.headers.get("content-type"))) {
|
|
80
|
+
const fields = { reason: "invalid_content_type" };
|
|
81
|
+
logger.warn(WebmentionLogEvent.ReceiveRejected, fields);
|
|
82
|
+
metrics.count(WebmentionLogEvent.ReceiveRejected, fields);
|
|
83
|
+
return textResponse(400, "invalid_request: Content-Type must be application/x-www-form-urlencoded");
|
|
84
|
+
}
|
|
85
|
+
let form;
|
|
86
|
+
try {
|
|
87
|
+
form = await request.formData();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return textResponse(400, "invalid_request: expected a form-encoded body with source and target");
|
|
91
|
+
}
|
|
92
|
+
const result = validateWebmentionParams({
|
|
93
|
+
source: formValue(form.get("source")),
|
|
94
|
+
target: formValue(form.get("target")),
|
|
95
|
+
baseUrl: config.baseUrl,
|
|
96
|
+
allowedHosts: config.allowedHosts,
|
|
97
|
+
});
|
|
98
|
+
if (!result.ok) {
|
|
99
|
+
const fields = { reason: result.error };
|
|
100
|
+
logger.warn(WebmentionLogEvent.ReceiveRejected, fields);
|
|
101
|
+
metrics.count(WebmentionLogEvent.ReceiveRejected, fields);
|
|
102
|
+
return textResponse(400, result.error);
|
|
103
|
+
}
|
|
104
|
+
await env.WEBMENTION_QUEUE.send({
|
|
105
|
+
source: result.source,
|
|
106
|
+
target: result.target,
|
|
107
|
+
});
|
|
108
|
+
const fields = {
|
|
109
|
+
sourceHost: hostFromUrl(result.source),
|
|
110
|
+
targetHost: hostFromUrl(result.target),
|
|
111
|
+
};
|
|
112
|
+
logger.info(WebmentionLogEvent.ReceiveAccepted, fields);
|
|
113
|
+
metrics.count(WebmentionLogEvent.ReceiveAccepted, fields);
|
|
114
|
+
return new Response(null, { status: 202 });
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Build the Queue consumer that verifies received mentions.
|
|
119
|
+
*
|
|
120
|
+
* For each job it fetches the source and checks for a link to the target: a
|
|
121
|
+
* verified mention is upserted into the inbox, while a source that no longer
|
|
122
|
+
* links is removed. A job that throws is retried; otherwise it is acked. Fails
|
|
123
|
+
* loudly if no inbox is configured.
|
|
124
|
+
*/
|
|
125
|
+
export function createWebmentionQueueConsumer(config) {
|
|
126
|
+
const logger = config.logger ?? noopLogger;
|
|
127
|
+
const metrics = config.metrics ?? noopMetrics;
|
|
128
|
+
return async (batch, env, _ctx) => {
|
|
129
|
+
const inbox = resolveInbox(config, env);
|
|
130
|
+
for (const message of batch.messages) {
|
|
131
|
+
const { source, target } = message.body;
|
|
132
|
+
try {
|
|
133
|
+
const result = await verifySource(source, target, {
|
|
134
|
+
fetch: config.fetch,
|
|
135
|
+
logger,
|
|
136
|
+
metrics,
|
|
137
|
+
});
|
|
138
|
+
if (result.links) {
|
|
139
|
+
await inbox.store({ source, target, verifiedAt: Date.now() });
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
await inbox.remove(source, target);
|
|
143
|
+
}
|
|
144
|
+
message.ack();
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
// A poison message must not retry silently — record why so an operator
|
|
148
|
+
// can tell a transient failure from a wedged one.
|
|
149
|
+
const fields = {
|
|
150
|
+
sourceHost: hostFromUrl(source),
|
|
151
|
+
targetHost: hostFromUrl(target),
|
|
152
|
+
error: err instanceof Error ? err.name : "unknown",
|
|
153
|
+
};
|
|
154
|
+
logger.warn(WebmentionLogEvent.QueueRetry, fields);
|
|
155
|
+
metrics.count(WebmentionLogEvent.QueueRetry, fields);
|
|
156
|
+
message.retry();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/webmention` — structured observability event taxonomy.
|
|
3
|
+
*
|
|
4
|
+
* The package's logging and metrics are opt-in via an injected {@link Logger}
|
|
5
|
+
* and {@link Metrics} (see `@dwk/log`), and **share this one vocabulary**: the
|
|
6
|
+
* same dotted event name is passed to `logger.warn(...)` and
|
|
7
|
+
* `metrics.count(...)` so a log line and its counter line up. Event names are
|
|
8
|
+
* stable, dotted, and queryable — operators filter on these rather than grepping
|
|
9
|
+
* free text. Security-relevant events (a blocked SSRF attempt, a receiver
|
|
10
|
+
* rejection, a poison-message retry) are first-class here so that being actively
|
|
11
|
+
* probed produces a distinct signal instead of looking like a dead link. See
|
|
12
|
+
* `spec/observability.md`.
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Stable event names emitted by `@dwk/webmention`. Fields logged alongside each
|
|
18
|
+
* event use `hostFromUrl` for any URL so an attacker-supplied path/query is
|
|
19
|
+
* never recorded; tokens and bodies are never logged.
|
|
20
|
+
*/
|
|
21
|
+
export declare const WebmentionLogEvent: {
|
|
22
|
+
/**
|
|
23
|
+
* An outbound fetch was refused on SSRF grounds. Fields: `reason`
|
|
24
|
+
* (machine-readable cause), `host` (sanitized, when known).
|
|
25
|
+
*/
|
|
26
|
+
readonly SsrfBlocked: "webmention.ssrf.blocked";
|
|
27
|
+
/** A received mention passed validation and was enqueued. */
|
|
28
|
+
readonly ReceiveAccepted: "webmention.receive.accepted";
|
|
29
|
+
/** A received mention was rejected at validation. Field: `reason`. */
|
|
30
|
+
readonly ReceiveRejected: "webmention.receive.rejected";
|
|
31
|
+
/** Source verification finished. Fields: `links`, `status`. */
|
|
32
|
+
readonly VerifyCompleted: "webmention.verify.completed";
|
|
33
|
+
/** The source fetch failed/was blocked during verification. Field: `error`. */
|
|
34
|
+
readonly VerifyFetchFailed: "webmention.verify.fetch_failed";
|
|
35
|
+
/** A queue message threw and is being retried. Field: `error`. */
|
|
36
|
+
readonly QueueRetry: "webmention.queue.retry";
|
|
37
|
+
/** A send attempt finished. Fields: `endpointHost`, `delivered`, `status`. */
|
|
38
|
+
readonly SendCompleted: "webmention.send.completed";
|
|
39
|
+
};
|
|
40
|
+
/** Union of the event-name string literals in {@link WebmentionLogEvent}. */
|
|
41
|
+
export type WebmentionLogEvent = (typeof WebmentionLogEvent)[keyof typeof WebmentionLogEvent];
|
|
42
|
+
//# sourceMappingURL=log.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"log.d.ts","sourceRoot":"","sources":["../src/log.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;;;;GAIG;AACH,eAAO,MAAM,kBAAkB;IAC7B;;;OAGG;;IAEH,6DAA6D;;IAE7D,sEAAsE;;IAEtE,+DAA+D;;IAE/D,+EAA+E;;IAE/E,kEAAkE;;IAElE,8EAA8E;;CAEtE,CAAC;AAEX,6EAA6E;AAC7E,MAAM,MAAM,kBAAkB,GAC5B,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,OAAO,kBAAkB,CAAC,CAAC"}
|