@jimhoyd/urlcode 0.3.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.
Files changed (254) hide show
  1. package/CONTRIBUTING.md +80 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +20 -0
  4. package/README.md +342 -0
  5. package/ROADMAP.md +248 -0
  6. package/SECURITY.md +40 -0
  7. package/data/agents/LICENSES/ai-robots-txt.txt +24 -0
  8. package/data/agents/LICENSES/crawler-user-agents.txt +23 -0
  9. package/data/agents/ai-crawlers.json +1240 -0
  10. package/data/agents/crawlers.json +10515 -0
  11. package/data/agents/index.d.ts +3 -0
  12. package/data/agents/index.js +2453 -0
  13. package/data/agents/monitoring.json +1520 -0
  14. package/data/agents/seo.json +3865 -0
  15. package/dist/BUILD-MANIFEST.json +60 -0
  16. package/dist/adapters.js +59 -0
  17. package/dist/agent-lists.js +59 -0
  18. package/dist/assets.js +129 -0
  19. package/dist/authoring.js +68 -0
  20. package/dist/aws.js +110 -0
  21. package/dist/build-cloudflare.js +164 -0
  22. package/dist/cli.js +185 -0
  23. package/dist/client-address.js +85 -0
  24. package/dist/cloudflare.js +165 -0
  25. package/dist/compliance-rules/baseline.js +98 -0
  26. package/dist/compliance-rules/privacy.js +38 -0
  27. package/dist/compliance-rules/shared.js +62 -0
  28. package/dist/compliance-rules/strict.js +61 -0
  29. package/dist/compliance.js +219 -0
  30. package/dist/config-worker.js +9 -0
  31. package/dist/config.js +172 -0
  32. package/dist/errors.js +8 -0
  33. package/dist/function-sources.js +54 -0
  34. package/dist/function-worker.js +105 -0
  35. package/dist/functions.js +174 -0
  36. package/dist/guest-api.js +114 -0
  37. package/dist/header-validation.js +18 -0
  38. package/dist/http-policy.js +74 -0
  39. package/dist/http-response.js +84 -0
  40. package/dist/index.js +11 -0
  41. package/dist/link-api.js +136 -0
  42. package/dist/link-cli.js +141 -0
  43. package/dist/link-events.js +76 -0
  44. package/dist/link-records.js +31 -0
  45. package/dist/link-store-worker.js +150 -0
  46. package/dist/link-store.js +250 -0
  47. package/dist/logging.js +22 -0
  48. package/dist/management-policy.js +41 -0
  49. package/dist/match.js +124 -0
  50. package/dist/observability.js +242 -0
  51. package/dist/plugins.js +74 -0
  52. package/dist/policies/agents.js +248 -0
  53. package/dist/policies/cache.js +297 -0
  54. package/dist/policies/compression.js +187 -0
  55. package/dist/policies/security.js +0 -0
  56. package/dist/policies/throttle.js +131 -0
  57. package/dist/policies.js +142 -0
  58. package/dist/policy.js +55 -0
  59. package/dist/prerender.js +165 -0
  60. package/dist/project-tests.js +23 -0
  61. package/dist/readiness.js +216 -0
  62. package/dist/router.js +166 -0
  63. package/dist/runtime.js +244 -0
  64. package/dist/scaffold.js +0 -0
  65. package/dist/scripts/operational-drills.js +75 -0
  66. package/dist/server.js +270 -0
  67. package/dist/site.js +0 -0
  68. package/dist/sqlite-version.js +6 -0
  69. package/dist/types/adapters.d.ts +12 -0
  70. package/dist/types/agent-lists.d.ts +5 -0
  71. package/dist/types/assets.d.ts +13 -0
  72. package/dist/types/authoring.d.ts +2 -0
  73. package/dist/types/aws.d.ts +35 -0
  74. package/dist/types/build-cloudflare.d.ts +14 -0
  75. package/dist/types/cli.d.ts +2 -0
  76. package/dist/types/client-address.d.ts +9 -0
  77. package/dist/types/cloudflare.d.ts +75 -0
  78. package/dist/types/compliance-rules/baseline.d.ts +12 -0
  79. package/dist/types/compliance-rules/privacy.d.ts +6 -0
  80. package/dist/types/compliance-rules/shared.d.ts +24 -0
  81. package/dist/types/compliance-rules/strict.d.ts +9 -0
  82. package/dist/types/compliance.d.ts +119 -0
  83. package/dist/types/config-worker.d.ts +1 -0
  84. package/dist/types/config.d.ts +21 -0
  85. package/dist/types/errors.d.ts +7 -0
  86. package/dist/types/function-sources.d.ts +21 -0
  87. package/dist/types/function-worker.d.ts +1 -0
  88. package/dist/types/functions.d.ts +92 -0
  89. package/dist/types/guest-api.d.ts +16 -0
  90. package/dist/types/header-validation.d.ts +2 -0
  91. package/dist/types/http-policy.d.ts +39 -0
  92. package/dist/types/http-response.d.ts +43 -0
  93. package/dist/types/index.d.ts +11 -0
  94. package/dist/types/link-api.d.ts +30 -0
  95. package/dist/types/link-cli.d.ts +36 -0
  96. package/dist/types/link-events.d.ts +27 -0
  97. package/dist/types/link-records.d.ts +11 -0
  98. package/dist/types/link-store-worker.d.ts +1 -0
  99. package/dist/types/link-store.d.ts +130 -0
  100. package/dist/types/logging.d.ts +9 -0
  101. package/dist/types/management-policy.d.ts +9 -0
  102. package/dist/types/match.d.ts +81 -0
  103. package/dist/types/observability.d.ts +104 -0
  104. package/dist/types/plugins.d.ts +27 -0
  105. package/dist/types/policies/agents.d.ts +86 -0
  106. package/dist/types/policies/cache.d.ts +82 -0
  107. package/dist/types/policies/compression.d.ts +41 -0
  108. package/dist/types/policies/security.d.ts +37 -0
  109. package/dist/types/policies/throttle.d.ts +55 -0
  110. package/dist/types/policies.d.ts +36 -0
  111. package/dist/types/policy.d.ts +21 -0
  112. package/dist/types/prerender.d.ts +31 -0
  113. package/dist/types/project-tests.d.ts +13 -0
  114. package/dist/types/readiness.d.ts +121 -0
  115. package/dist/types/router.d.ts +11 -0
  116. package/dist/types/runtime.d.ts +91 -0
  117. package/dist/types/scaffold.d.ts +17 -0
  118. package/dist/types/server.d.ts +37 -0
  119. package/dist/types/site.d.ts +12 -0
  120. package/dist/types/sqlite-version.d.ts +1 -0
  121. package/dist/types/types.d.ts +332 -0
  122. package/dist/types/vercel.d.ts +12 -0
  123. package/dist/types.js +177 -0
  124. package/dist/vercel.js +65 -0
  125. package/docs/AI-AUTHORING.md +113 -0
  126. package/docs/ASSETS.md +106 -0
  127. package/docs/AWS.md +77 -0
  128. package/docs/BEST-PRACTICES.md +267 -0
  129. package/docs/CAPACITY.md +208 -0
  130. package/docs/CLOUDFLARE.md +110 -0
  131. package/docs/COMPLIANCE.md +242 -0
  132. package/docs/DYNAMIC-LINKS.md +561 -0
  133. package/docs/FUNCTION-SECURITY.md +113 -0
  134. package/docs/HTTP.md +129 -0
  135. package/docs/INSTALL.md +90 -0
  136. package/docs/LOAD-TESTING.md +91 -0
  137. package/docs/LOCAL-DEVELOPMENT.md +99 -0
  138. package/docs/MANAGEMENT-SECURITY.md +82 -0
  139. package/docs/MIDDLEWARE.md +83 -0
  140. package/docs/MONITORING.md +132 -0
  141. package/docs/OBSERVABILITY.md +229 -0
  142. package/docs/OPERATIONAL-PROOF.md +42 -0
  143. package/docs/OPERATIONS.md +207 -0
  144. package/docs/ORGANIZATION.md +135 -0
  145. package/docs/PERFORMANCE.md +72 -0
  146. package/docs/PLUGINS.md +234 -0
  147. package/docs/POLICIES.md +489 -0
  148. package/docs/PRERENDER.md +193 -0
  149. package/docs/PROJECT-DIRECTION.md +51 -0
  150. package/docs/READINESS.md +125 -0
  151. package/docs/README.md +58 -0
  152. package/docs/RELEASE-READINESS.md +95 -0
  153. package/docs/RELEASE-SECURITY.md +72 -0
  154. package/docs/RESILIENCE.md +160 -0
  155. package/docs/ROUTING.md +95 -0
  156. package/docs/SANDBOX-REVIEW.md +59 -0
  157. package/docs/SCAFFOLDING.md +72 -0
  158. package/docs/SECURITY-AUDIT.md +124 -0
  159. package/docs/SITE.md +150 -0
  160. package/docs/SPECIFICATION.md +232 -0
  161. package/docs/SPIKE-EXTENSIONS.md +475 -0
  162. package/docs/STANDARDS.md +303 -0
  163. package/docs/STARTERS.md +51 -0
  164. package/docs/TUNNELS.md +75 -0
  165. package/docs/TYPESCRIPT.md +102 -0
  166. package/docs/VERCEL.md +107 -0
  167. package/docs/YAML-GUIDE.md +521 -0
  168. package/docs/YAML-REFERENCE.md +328 -0
  169. package/docs/policies/agents.md +182 -0
  170. package/docs/policies/cache.md +139 -0
  171. package/docs/policies/compression.md +168 -0
  172. package/docs/policies/security.md +160 -0
  173. package/docs/policies/throttle.md +103 -0
  174. package/examples/assets/.env.example +1 -0
  175. package/examples/assets/.gitattributes +2 -0
  176. package/examples/assets/Makefile +30 -0
  177. package/examples/assets/README.md +18 -0
  178. package/examples/assets/functions/hello.mjs +3 -0
  179. package/examples/assets/gitignore.template +7 -0
  180. package/examples/assets/public/about.html +2 -0
  181. package/examples/assets/public/assets/example.txt +1 -0
  182. package/examples/assets/public/guide.txt +1 -0
  183. package/examples/assets/tests/requests.json +44 -0
  184. package/examples/assets/urlcode.yaml +27 -0
  185. package/examples/aws/README.md +13 -0
  186. package/examples/aws/handler.mjs +5 -0
  187. package/examples/aws/public/index.html +3 -0
  188. package/examples/aws/public/notes.txt +1 -0
  189. package/examples/aws/template.yaml +33 -0
  190. package/examples/aws/tests/requests.json +9 -0
  191. package/examples/aws/urlcode.yaml +19 -0
  192. package/examples/cloudflare/README.md +18 -0
  193. package/examples/cloudflare/package.json +14 -0
  194. package/examples/cloudflare/tests/requests.json +9 -0
  195. package/examples/cloudflare/urlcode.yaml +24 -0
  196. package/examples/cloudflare/wrangler.toml +5 -0
  197. package/examples/compliance/README.md +20 -0
  198. package/examples/compliance/rules.mjs +65 -0
  199. package/examples/cookbook/README.md +21 -0
  200. package/examples/cookbook/functions/choice.mjs +4 -0
  201. package/examples/cookbook/functions/echo.mjs +3 -0
  202. package/examples/cookbook/functions/hello.mjs +3 -0
  203. package/examples/cookbook/functions/text.mjs +3 -0
  204. package/examples/cookbook/middleware/headers.mjs +6 -0
  205. package/examples/cookbook/public/about.html +1 -0
  206. package/examples/cookbook/public/assets/index.html +1 -0
  207. package/examples/cookbook/public/assets/site.css +1 -0
  208. package/examples/cookbook/public/favicon.svg +1 -0
  209. package/examples/cookbook/public/guide.txt +1 -0
  210. package/examples/cookbook/public/llms.txt +6 -0
  211. package/examples/cookbook/routes/code.yaml +55 -0
  212. package/examples/cookbook/routes/files.yaml +17 -0
  213. package/examples/cookbook/routes/policies.yaml +28 -0
  214. package/examples/cookbook/routes/redirects.yaml +38 -0
  215. package/examples/cookbook/routes/responses.yaml +26 -0
  216. package/examples/cookbook/tests/requests.json +254 -0
  217. package/examples/cookbook/urlcode.yaml +22 -0
  218. package/examples/live-links/README.md +11 -0
  219. package/examples/live-links/tests/requests.json +6 -0
  220. package/examples/live-links/urlcode.yaml +16 -0
  221. package/examples/monitoring/blackbox-jobs.yaml +37 -0
  222. package/examples/monitoring/prometheus-rules.yaml +88 -0
  223. package/examples/monitoring/prometheus-scrape.yaml +20 -0
  224. package/examples/monitoring/vector.toml +65 -0
  225. package/examples/prerender/README.md +48 -0
  226. package/examples/prerender/functions/page.mjs +6 -0
  227. package/examples/prerender/middleware/template.mjs +29 -0
  228. package/examples/prerender/prerender.d.mts +4 -0
  229. package/examples/prerender/prerender.mjs +63 -0
  230. package/examples/prerender/tests/requests.json +39 -0
  231. package/examples/prerender/urlcode.yaml +50 -0
  232. package/examples/tunnel/dev-with-ngrok.sh +61 -0
  233. package/examples/vercel/README.md +13 -0
  234. package/examples/vercel/api/index.js +5 -0
  235. package/examples/vercel/package.json +8 -0
  236. package/examples/vercel/public/index.html +3 -0
  237. package/examples/vercel/public/notes.txt +1 -0
  238. package/examples/vercel/tests/requests.json +48 -0
  239. package/examples/vercel/urlcode.yaml +19 -0
  240. package/examples/vercel/vercel.json +10 -0
  241. package/llms.txt +46 -0
  242. package/package.json +123 -0
  243. package/schemas/urlcode.schema.json +1230 -0
  244. package/starters/default/.gitattributes +1 -0
  245. package/starters/default/Makefile +30 -0
  246. package/starters/default/README.md +34 -0
  247. package/starters/default/functions/hello.mjs +3 -0
  248. package/starters/default/gitignore.template +8 -0
  249. package/starters/default/middleware/headers.mjs +6 -0
  250. package/starters/default/routes/functions.yaml +20 -0
  251. package/starters/default/routes/marketing/links.yaml +7 -0
  252. package/starters/default/starter.json +5 -0
  253. package/starters/default/tests/requests.json +56 -0
  254. package/starters/default/urlcode.yaml +8 -0
@@ -0,0 +1,74 @@
1
+ import { validateHeaderName, validateHeaderValue } from './header-validation.js';
2
+ import { assert, HttpError } from './errors.js';
3
+
4
+
5
+
6
+
7
+
8
+ /** A static reply compiled from `respond`; the body is bytes so every host, including the Worker, shares the type. */
9
+
10
+ /** The declared HTTP surface of a route: response headers, request body policy and a static reply. */
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+ const encoder = new TextEncoder();
19
+ const byteLength = (value ) => encoder.encode(value).length;
20
+
21
+ const reserved = new Set(['connection','keep-alive','transfer-encoding','content-length','upgrade','trailer','proxy-authenticate','proxy-authorization','te','location','allow','content-range','accept-ranges','etag','last-modified','content-encoding','x-request-id','x-content-type-options']);
22
+ export function compileHttp(route ) {
23
+ const seen = new Set (); let size = 0;
24
+ const responseHeaders = route.responseHeaders = [];
25
+ for (const [name,value] of Object.entries(route.response?.headers || {})) {
26
+ const key = name.toLowerCase();
27
+ assert(!seen.has(key), 'Duplicate response header (case insensitive)'); seen.add(key);
28
+ assert(!reserved.has(key), 'Response header is owned by the runtime or handler');
29
+ assert(!Array.isArray(value) || key === 'set-cookie', 'Only Set-Cookie supports a header array');
30
+ assert(!(route.page || route.static || route.download) || !['content-type','content-disposition','cache-control'].includes(key), 'Configure asset metadata on its handler');
31
+ for (const item of Array.isArray(value) ? value : [value]) {
32
+ try { validateHeaderName(name); validateHeaderValue(name,item); } catch { assert(false,'Invalid response header'); }
33
+ assert(!/[\u0000-\u001f\u007f]/u.test(item), 'Control characters in response header');
34
+ size += Buffer.byteLength(name + item);
35
+ responseHeaders.push([key,item]);
36
+ }
37
+ }
38
+ assert(size <= 16384, 'Response headers exceed 16 KiB');
39
+ if (route.respond) {
40
+ const status = route.respond.status ?? 200;
41
+ assert(![206,304].includes(status), 'Use native asset handlers for partial/conditional responses');
42
+ const json = Object.hasOwn(route.respond,'json');
43
+ const body = Buffer.from(json ? JSON.stringify(route.respond.json) : route.respond.text || '');
44
+ assert(body.length <= 1048576, 'Declared response exceeds 1 MiB');
45
+ assert(![204,205].includes(status) || body.length === 0, '204/205 responses cannot declare a body');
46
+ if (json) assert(!responseHeaders.some(([key,value]) => key === 'content-type' && !/^application\/(?:[\w.+-]+\+)?json(?:;|$)/i.test(value)), 'JSON response requires a JSON content type');
47
+ route.reply = { status, headers: [['content-type',json ? 'application/json; charset=utf-8' : 'text/plain; charset=utf-8']], body };
48
+ }
49
+ }
50
+ export function checkRequest(route , body , headers , counts = {}) {
51
+ const policy = route.request?.body;
52
+ if (!policy) return;
53
+ if (body.length > (policy.maxBytes ?? 1048576)) throw new HttpError(413,'Request body too large');
54
+ if (!body.length) { if (policy.required) throw new HttpError(400,'Request body required'); return; }
55
+ if ((counts['content-type'] ?? 0) > 1) throw new HttpError(400,'Duplicate Content-Type');
56
+ if (headers.has('content-encoding') && headers.get('content-encoding') .toLowerCase() !== 'identity') throw new HttpError(415,'Unsupported content encoding');
57
+ const type = (headers.get('content-type') || '').split(';')[0] .trim().toLowerCase();
58
+ if (policy.contentTypes && !policy.contentTypes.includes(type)) throw new HttpError(415,'Unsupported media type');
59
+ if (policy.format) {
60
+ let text ;
61
+ try { text = new TextDecoder('utf-8',{fatal:true}).decode(body); } catch { throw new HttpError(400,'Body must be UTF-8'); }
62
+ if (policy.format === 'json') {
63
+ if (!/^application\/(?:[\w.+-]+\+)?json$/.test(type)) throw new HttpError(415,'Expected JSON media type');
64
+ try { JSON.parse(text); } catch { throw new HttpError(400,'Invalid JSON body'); }
65
+ }
66
+ }
67
+ }
68
+ export function decorateResponse(route , result ) {
69
+ if (!route.responseHeaders.length) return result;
70
+ const replaced = new Set(route.responseHeaders.map(([key]) => key));
71
+ const headers = [...result.headers.filter(([key]) => !replaced.has(key.toLowerCase())), ...route.responseHeaders];
72
+ if (headers.reduce((n,[key,value]) => n + byteLength(key + value),0) > 16384 || headers.length > 256) throw new HttpError(502,'Response headers too large');
73
+ return {...result,headers};
74
+ }
@@ -0,0 +1,84 @@
1
+ import { validateHeaderName, validateHeaderValue } from './header-validation.js';
2
+ import { HttpError } from './errors.js';
3
+
4
+
5
+
6
+ /** What a handler, policy or asset produced; every host turns it into a wire response here. */
7
+
8
+
9
+
10
+
11
+ /** The node:http ServerResponse surface this module writes to, kept structural so the module stays Node-free. */
12
+
13
+
14
+
15
+
16
+
17
+
18
+ // Hop-by-hop and runtime-owned headers a handler must never set on the wire.
19
+ export const forbiddenHeaders = new Set(['connection','keep-alive','transfer-encoding','content-length','upgrade','trailer','proxy-authenticate','proxy-authorization','te']);
20
+
21
+ // One place decides what a URLCode response *is*, independent of how a host
22
+ // delivers it. A Node server writes it to a socket; a Lambda returns it as
23
+ // JSON. Both go through here, so a project cannot behave differently on one
24
+ // host than another without this function changing.
25
+ export function prepareResponse(result , { requestId, method } ) {
26
+ const status = result.status;
27
+ if (!Number.isInteger(status) || status < 200 || status > 599) throw new HttpError(502, 'Invalid function response');
28
+ const headers = [], cookies = [];
29
+ for (const [key,value] of result.headers) {
30
+ if (forbiddenHeaders.has(key.toLowerCase())) continue;
31
+ validateHeaderName(key); validateHeaderValue(key,value);
32
+ if (key.toLowerCase() === 'set-cookie') cookies.push(value);
33
+ else headers.push([key,value]);
34
+ }
35
+ // A body these statuses must not carry, and HEAD, are suppressed once here
36
+ // rather than in each host's writer.
37
+ const bodyless = [204,205,304].includes(status);
38
+ const body = method === 'HEAD' || bodyless ? undefined : result.body;
39
+ // State the length rather than leaving a host to infer it. A Node server
40
+ // computes this itself, but a host that returns JSON does not, so the policy
41
+ // has to say it for every host to agree.
42
+ // HEAD states the length GET would send (RFC 9110 §8.6), so it is measured
43
+ // on the result's body before the body is dropped.
44
+ if (!bodyless) headers.push(['content-length', String(result.contentLength ?? (result.body?.length ?? 0))]);
45
+ headers.push(['x-request-id',requestId],['x-content-type-options','nosniff']);
46
+ if (!headers.some(([key]) => key.toLowerCase() === 'cache-control')) headers.push(['cache-control','no-store']);
47
+ return { status, headers, cookies, body };
48
+ }
49
+ export function writeResponse(res , result , options ) {
50
+ const prepared = prepareResponse(result, options);
51
+ for (const [key,value] of prepared.headers) res.setHeader(key,value);
52
+ if (prepared.cookies.length) res.setHeader('set-cookie',prepared.cookies);
53
+ res.statusCode = prepared.status;
54
+ res.end(prepared.body);
55
+ return prepared.status;
56
+ }
57
+
58
+ // The one shape of an error answer on every host. `headers` are the policy
59
+ // headers the host resolved for this error (security profile); they never
60
+ // replace the fixed set below, which is what keeps an error from being cached
61
+ // or sniffed whatever a project declares.
62
+ export function errorResponse(error , { requestId, method, headers = [] } ) {
63
+ const status = error instanceof HttpError ? error.status : 500;
64
+ const fixed = [['content-type','text/plain; charset=utf-8'],['cache-control','no-store'],['x-request-id',requestId],['x-content-type-options','nosniff']];
65
+ const taken = new Set(fixed.map(([key]) => key));
66
+ const extra = headers.filter(([key]) => !taken.has(key.toLowerCase()) && !forbiddenHeaders.has(key.toLowerCase()));
67
+ // Runtime error messages are fixed words, and the answer is text/plain
68
+ // with nosniff; markup characters are still stripped so the body can never
69
+ // be read as HTML by a client that ignores both.
70
+ const text = `${error instanceof HttpError ? String(error.message).replace(/[<>&"']/g, '') : 'Internal server error'}\n`;
71
+ const body = method === 'HEAD' ? undefined : text;
72
+ // Stated explicitly so every host agrees, as prepareResponse does for results.
73
+ return { status, headers: [...fixed, ['content-length', String(new TextEncoder().encode(text).length)], ...extra], body };
74
+ }
75
+ export function writeError(res , error , options ) {
76
+ const prepared = errorResponse(error, options);
77
+ if (res.headersSent) { res.destroy(); return prepared.status; }
78
+ for (const key of res.getHeaderNames()) res.removeHeader(key);
79
+ for (const [key,value] of prepared.headers) res.setHeader(key,value);
80
+ res.setHeader('connection','close');
81
+ res.statusCode = prepared.status;
82
+ res.end(prepared.body);
83
+ return prepared.status;
84
+ }
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ export { createRuntime } from './runtime.js';
2
+
3
+ export { startServer } from './server.js';
4
+
5
+ export { loadDocument, validateDocument, parseYaml } from './config.js';
6
+ export {openLinkStore} from './link-store.js';
7
+
8
+ export {startLinkApi} from './link-api.js';
9
+
10
+
11
+ export { events as observabilityEvents, validateObservers, createObserverSink, createMetrics, renderPrometheus } from './observability.js';
@@ -0,0 +1,136 @@
1
+ import http from 'node:http';
2
+ import {createHash,timingSafeEqual,randomUUID} from 'node:crypto';
3
+ import {readFile,lstat} from 'node:fs/promises';
4
+
5
+ import {outsideProject} from './link-store.js';
6
+
7
+
8
+ import {linkCollection,linkCode} from './link-records.js';
9
+ import {assert,HttpError} from './errors.js';
10
+ import {createJsonLogger} from './logging.js';
11
+
12
+ /** The store surface management needs: the pooled link store, or anything with the same contract. */
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+ export async function loadLinkToken(file ,project ) {
27
+ const path=await outsideProject(file,project);const info=await lstat(path);
28
+ assert(info.isFile()&&!info.isSymbolicLink()&&info.nlink===1&&info.size<=1024,'Invalid management token file');
29
+ assert(process.platform==='win32'||(info.mode&0o077)===0,'Management token file must be private (mode 600)');
30
+ const token=(await readFile(path,'utf8')).trim();
31
+ assert(/^[A-Za-z0-9_-]{43,256}$/.test(token),'Management token must contain at least 43 base64url characters');return token;
32
+ }
33
+ function body(req ) {
34
+ return new Promise((resolve,reject)=>{
35
+ let bytes=0;const chunks =[];
36
+ const cleanup=()=>{req.off('data',data);req.off('end',end);req.off('error',failed);req.off('aborted',failed);};
37
+ const failed=()=>{cleanup();reject(new HttpError(400,'Incomplete management body'));};
38
+ const data=(chunk )=>{
39
+ bytes+=chunk.length;
40
+ if(bytes>16384){cleanup();req.resume();reject(new HttpError(413,'Management body too large'));return;}
41
+ chunks.push(chunk);
42
+ };
43
+ const end=()=>{
44
+ cleanup();
45
+ if(!bytes){reject(new HttpError(400,'JSON body required'));return;}
46
+ try{resolve(JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(Buffer.concat(chunks))));}
47
+ catch{reject(new HttpError(400,'Invalid JSON body'));}
48
+ };
49
+ req.on('data',data);req.once('end',end);req.once('error',failed);req.once('aborted',failed);
50
+ });
51
+ }
52
+ const isRecord=(value ) =>value!==null && typeof value==='object' && !Array.isArray(value);
53
+ const mutations ={POST:'create',PUT:'update',DELETE:'delete'};
54
+ export async function startLinkApi({store,collection,token,authorize,host='127.0.0.1',port=3001,maxInFlightRequests=32,socketTimeoutMs=10000,log=createJsonLogger()} ) {
55
+ linkCollection(collection);assert(['127.0.0.1','::1'].includes(host),'Management must bind a literal loopback address; use a private authenticated tunnel');
56
+ assert(!authorize || store.atomicAudit===true,'Scoped management requires a store with atomic audit support');
57
+ assert(authorize===undefined || typeof authorize==='function','Invalid management authorizer');
58
+ assert(authorize || typeof token==='string'&&/^[A-Za-z0-9_-]{43,256}$/.test(token),'Invalid management token');
59
+ assert(Number.isInteger(maxInFlightRequests)&&maxInFlightRequests>=1&&maxInFlightRequests<=64,'Management admission must be 1–64');
60
+ assert(Number.isInteger(socketTimeoutMs)&&socketTimeoutMs>=100&&socketTimeoutMs<=60000,'Management socket timeout must be 100–60000 ms');
61
+ let inFlight=0,shuttingDown=false,closing ;
62
+ const digest=(value )=>createHash('sha256').update(value).digest();const expected=digest('Bearer '+token);
63
+ const server=http.createServer({maxHeaderSize:8192,headersTimeout:5000,requestTimeout:10000,keepAliveTimeout:5000},async(req,res)=>{
64
+ req.on('error',()=>{});res.on('error',()=>{});
65
+ const requestId=randomUUID(),started=performance.now();let principal ;let authenticated=false,action='request',admitted=false,reported=false;
66
+ const target=req.url??'',method=req.method??'';
67
+ const report=()=>{
68
+ if(reported)return;reported=true;if(admitted)inFlight--;
69
+ try{log({event:'management_request',timestamp:new Date().toISOString(),requestId,collection,action,authenticated,principal:principal?.id,status:res.headersSent?res.statusCode:0,outcome:res.writableFinished?'finished':'aborted',durationMs:Math.round((performance.now()-started)*100)/100});}catch{/* Logging must not fail requests. */}
70
+ };
71
+ res.once('finish',report);res.once('close',report);
72
+ const send=(status ,value ,headers ={})=>{const payload=value===undefined?undefined:JSON.stringify(value);res.writeHead(status,{'content-type':'application/json','cache-control':'no-store','x-content-type-options':'nosniff','x-request-id':requestId,...headers});res.end(payload);};
73
+ try {
74
+ if(shuttingDown || inFlight>=maxInFlightRequests)throw new HttpError(503,'Management capacity unavailable');
75
+ inFlight++;admitted=true;
76
+ let authCount=0;for(let i=0;i<req.rawHeaders.length;i+=2)if(req.rawHeaders[i]?.toLowerCase()==='authorization')authCount++;
77
+ if(authCount===1){
78
+ if(authorize){
79
+ const bearer=/^Bearer ([A-Za-z0-9_-]{43,256})$/.exec(req.headers.authorization||'');
80
+ if(bearer?.[1]!==undefined)principal=await authorize(bearer[1]);
81
+ }else if(timingSafeEqual(expected,digest(req.headers.authorization||'')))principal={id:'legacy-shared',collections:[collection],actions:['get','list','create','update','delete']};
82
+ }
83
+ if(!principal) {req.resume();send(401,{error:'Unauthorized'},{'www-authenticate':'Bearer',connection:'close'});return;}
84
+ authenticated=true;
85
+ if(req.headers.origin!==undefined)throw new HttpError(403,'Browser-origin management requests are unsupported');
86
+ if(!target.startsWith('/')||target.startsWith('//')||target.length>2048)throw new HttpError(400,'Invalid management target');
87
+ const url=new URL(target,'http://localhost');
88
+ if(target.split('?')[0]!==url.pathname)throw new HttpError(400,'Noncanonical management path');
89
+ const match=/^\/v1\/links(?:\/([A-Za-z0-9_-]{1,128}))?$/.exec(url.pathname);
90
+ if(!match)throw new HttpError(404,'Not found');
91
+ const code=match[1];if(code)linkCode(code);
92
+ if([...url.searchParams.keys()].some(k=>!['limit','after'].includes(k)) || (code && url.search))throw new HttpError(400,'Unsupported query');
93
+ const allowed=code?['GET','PUT','DELETE']:['GET','POST'];
94
+ if(!allowed.includes(method)){req.resume();send(405,{error:'Method not allowed'},{allow:allowed.join(', '),connection:'close'});return;}
95
+ action=method==='GET'?(code?'get':'list'):mutations[method]??'request';
96
+ if(!principal.collections.includes(collection) || !principal.actions.includes(action))throw new HttpError(403,'Management permission denied');
97
+ const audit ={actor:principal.id,requestId};
98
+ if(method==='GET'){
99
+ req.resume();
100
+ if(code){const value=await store.get(collection,code);if(!value)throw new HttpError(404,'Link not found');send(200,value,{etag:`"${value.version}"`});}
101
+ else {
102
+ if([...url.searchParams.keys()].some(k=>url.searchParams.getAll(k).length!==1))throw new HttpError(400,'Duplicate query');
103
+ const raw=url.searchParams.get('limit')??'100';if(!/^\d{1,3}$/.test(raw))throw new HttpError(400,'Invalid limit');
104
+ const items=await store.list(collection,{limit:Number(raw),after:url.searchParams.get('after')||''});
105
+ send(200,{items,nextAfter:items.length===Number(raw)?items.at(-1)?.code??null:null});
106
+ }
107
+ return;
108
+ }
109
+ if(url.search)throw new HttpError(400,'Query unsupported for mutations');
110
+ let expectedVersion ;
111
+ if(method!=='POST'){
112
+ const etag=req.headers['if-match'];if(!etag)throw new HttpError(428,'If-Match is required');
113
+ if(!/^"[1-9]\d{0,15}"$/.test(etag))throw new HttpError(400,'Invalid If-Match');expectedVersion=Number(etag.slice(1,-1));
114
+ }
115
+ if(method==='DELETE'){req.resume();await store.delete(collection,code??'',expectedVersion,audit);send(204);return;}
116
+ if(req.headers['content-encoding'] && req.headers['content-encoding']!=='identity')throw new HttpError(415,'Encoding unsupported');
117
+ if(req.headers['content-type']?.split(';')[0]?.trim().toLowerCase()!=='application/json')throw new HttpError(415,'Expected application/json');
118
+ const data=await body(req);
119
+ if(!isRecord(data))throw new HttpError(400,'Invalid record');
120
+ let value ;
121
+ if(method==='POST'){const {code:assigned,...record}=data;value=await store.create(collection,record,assigned,audit);}
122
+ else value=await store.update(collection,code??'',data,expectedVersion,audit);
123
+ send(method==='POST'?201:200,value,{etag:`"${value.version}"`});
124
+ }catch(e){req.resume();if(res.headersSent){res.destroy();return;}if(!res.destroyed){const status=e instanceof HttpError?e.status:503;send(status,{error:e instanceof HttpError?e.message:'Management service unavailable'},{connection:'close'});}}
125
+ });
126
+ server.setTimeout(socketTimeoutMs,socket=>socket.destroy());
127
+ server.maxConnections=64;server.maxRequestsPerSocket=100;
128
+ server.on('clientError',(_error,socket)=>{if(socket.writable)socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');});
129
+ await new Promise ((resolve,reject)=>{server.once('error',reject);server.listen(port,host,()=>{server.off('error',reject);resolve();});});
130
+ const address=server.address();
131
+ assert(address!==null && typeof address==='object','Management server has no address');
132
+ return {address,close(){
133
+ if(closing)return closing;shuttingDown=true;
134
+ closing=(async()=>{const timeout=setTimeout(()=>server.closeAllConnections(),10000);timeout.unref();try{await new Promise (resolve=>server.close(()=>resolve()));}finally{clearTimeout(timeout);}})();return closing;
135
+ }};
136
+ }
@@ -0,0 +1,141 @@
1
+ import {createHash} from 'node:crypto';
2
+ import {createReadStream} from 'node:fs';
3
+ import {createInterface} from 'node:readline';
4
+ import {isAbsolute} from 'node:path';
5
+ import {managementPolicy} from './management-policy.js';
6
+ import {openLinkStore} from './link-store.js';
7
+
8
+ import {startLinkApi,loadLinkToken} from './link-api.js';
9
+ import {linkCollection,linkCode,linkData} from './link-records.js';
10
+ import {assert} from './errors.js';
11
+ /** The parsed command-line values the links commands read; every flag is a string or absent. */
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+ const poolFlags=[['link-readers','readers',8],['link-read-limit','maxReads',32],['link-write-limit','maxWrites',32]] ;
23
+ export function linkPoolOptions(values ) {
24
+ const result ={};
25
+ for(const [flag,key,max] of poolFlags){
26
+ const value=values[flag];
27
+ if(value===undefined)continue;
28
+ assert(/^\d+$/.test(value)&&Number(value)>=1&&Number(value)<=max,`Invalid --${flag}`);result[key]=Number(value);
29
+ }
30
+ return result;
31
+ }
32
+ export function parseLinkBinding(value ,options ={}) {
33
+ if(value===undefined)return undefined;
34
+ const index=value.indexOf('=');assert(index>0,'Use --link-store collection=/absolute/path.sqlite');
35
+ return {collection:linkCollection(value.slice(0,index)),file:value.slice(index+1),...options};
36
+ }
37
+ const isRecord=(value ) =>value!==null && typeof value==='object' && !Array.isArray(value);
38
+ export async function runLinkCommand(action ,values ,print ) {
39
+ assert(action!==undefined && ['init','create','get','list','update','delete','export','import','api'].includes(action),'Use links init/create/get/list/update/delete/export/import/api');
40
+ assert(values.store,'Links commands require --store with an absolute database path');
41
+ const collection=linkCollection(values.collection||'links');
42
+ const poolOptions=linkPoolOptions(values);
43
+ // Authentication material is checked before creating or opening a writable store.
44
+ if(values['auth-file'] && values['token-file'])throw new Error('Use either --auth-file or --token-file');
45
+ const project=values.project??'.';
46
+ const authorize=action==='api' && values['auth-file']?await managementPolicy(values['auth-file'],project):undefined;
47
+ if(authorize)await authorize('');
48
+ const token=action==='api' && !authorize?await loadLinkToken(values['token-file'],project):undefined;
49
+ const storeOptions ={file:values.store,project:values.project,readOnly:['get','list','export'].includes(action),...poolOptions};
50
+ const store=await openLinkStore(storeOptions);
51
+ try {
52
+ if(action==='api'){
53
+ const port=Number(values.port);assert(values.port!==undefined&&/^\d+$/.test(values.port)&&port>=0&&port<=65535,'Invalid port');
54
+ const api=await startLinkApi({store,collection,token,authorize,host:values.host,port});
55
+ print({event:'link-management-listening',address:api.address.address,port:api.address.port,collection});
56
+ await new Promise (resolve=>{
57
+ const stop=()=>{process.off('SIGINT',stop);process.off('SIGTERM',stop);resolve();};
58
+ process.once('SIGINT',stop);process.once('SIGTERM',stop);
59
+ });
60
+ await api.close();return;
61
+ }
62
+ let value ;
63
+ if(action==='init')value={event:'link-store-initialized'};
64
+ if(action==='get'){value=await store.get(collection,values.code??'');assert(value,'Link not found');}
65
+ if(action==='list')value=await store.list(collection,{limit:values.limit===undefined?100:Number(values.limit),after:values.after||''});
66
+ if(action==='create'||action==='update'){
67
+ assert(values.destination,'Use --destination with an HTTP(S) URL');
68
+ assert(values.enabled===undefined||['true','false'].includes(values.enabled),'Enabled must be true or false');
69
+ const data={url:values.destination,status:values.status===undefined?302:Number(values.status),enabled:values.enabled!=='false',expires:values.expires||null};
70
+ value=action==='create'?await store.create(collection,data,values.code):await store.update(collection,values.code??'',data,Number(values['if-version']));
71
+ }
72
+ if(action==='delete')value={deleted:await store.delete(collection,values.code??'',Number(values['if-version']))};
73
+ // Export and import stream their own NDJSON; they never buffer the store.
74
+ if(action==='export'){await exportLinks(store,values,print);return;}
75
+ if(action==='import'){print(await importLinks(store,values));return;}
76
+ print(value);
77
+ }finally{await store.close();}
78
+ }
79
+
80
+ // A restorable point-in-time snapshot: a header line carrying format, schema and
81
+ // snapshot identity, one line per record, and a completion line with the record
82
+ // count and a digest over every preceding line. Operator data goes to stdout
83
+ // deliberately; redirect it to a file only operators can read.
84
+ async function exportLinks(store ,values ,print ) {
85
+ const pageSize=values['page-size']===undefined?100:Number(values['page-size']);
86
+ assert(Number.isInteger(pageSize)&&pageSize>=1&&pageSize<=100,'Use --page-size 1–100');
87
+ const hash=createHash('sha256');
88
+ const line=(value )=>{hash.update(JSON.stringify(value)+'\n');print(value);};
89
+ const summary=await store.exportSnapshot(
90
+ {collection:values.collection===undefined?undefined:linkCollection(values.collection),pageSize},
91
+ {
92
+ onHeader:header=>line({event:'link-export-begin',format:header.format,schemaVersion:header.schemaVersion,
93
+ applicationId:header.applicationId,collection:header.collection,revision:header.revision,
94
+ records:header.records,generatedAt:header.generatedAt}),
95
+ onRecords:records=>{for(const record of records)line({record});},
96
+ });
97
+ print({event:'link-export-complete',exported:summary.exported,sha256:hash.digest('hex')});
98
+ }
99
+ async function importLinks(store ,values ) {
100
+ assert(typeof values.input==='string' && isAbsolute(values.input),'Use --input with an absolute export file path');
101
+ const hash=createHash('sha256');
102
+ const counts=new Map ();let imported=0,header ,complete ;
103
+ const stream=createReadStream(values.input,{encoding:'utf8'});
104
+ try {
105
+ for await (const text of createInterface({input:stream,crlfDelay:Infinity})) {
106
+ if(text==='')continue;
107
+ assert(Buffer.byteLength(text)<=65536,'Export line exceeds 65536 bytes');
108
+ assert(!complete,'Export file has content after its completion line');
109
+ let parsed ;try{parsed=JSON.parse(text);}catch{throw new Error('Export file is not valid NDJSON');}
110
+ const line =isRecord(parsed)?parsed:{};
111
+ if(line.event==='link-export-complete'){
112
+ assert(header,'Export file has no header line');
113
+ assert(hash.copy().digest('hex')===line.sha256,'Export digest does not match its content');
114
+ assert(line.exported===imported,'Export record count does not match its record lines');
115
+ complete=line;continue;
116
+ }
117
+ hash.update(text+'\n');
118
+ if(line.event==='link-export-begin'){
119
+ assert(!header && !imported,'Export file has more than one header line');
120
+ assert(line.format==='urlcode.links.v1' && line.schemaVersion===1 && line.applicationId===1431456835,
121
+ 'Unsupported export format, schema version or store identity');
122
+ header=line;continue;
123
+ }
124
+ assert(header,'Export records precede the header line');
125
+ assert(isRecord(line.record),'Unsupported export line');
126
+ const collection=linkCollection(line.record.collection),code=linkCode(line.record.code);
127
+ // Restore into empty collections only: this never overwrites live records.
128
+ if(!counts.has(collection)){
129
+ assert((await store.list(collection,{limit:1})).length===0,'Target collection already contains records');
130
+ counts.set(collection,0);
131
+ }
132
+ await store.create(collection,linkData({url:line.record.url,status:line.record.status,enabled:line.record.enabled,expires:line.record.expires}),code);
133
+ counts.set(collection,(counts.get(collection)??0)+1);imported++;
134
+ }
135
+ } finally { stream.destroy(); }
136
+ assert(complete,'Export file has no completion line; it is truncated');
137
+ // Versions and audit revisions are reassigned by this store, so management
138
+ // ETags taken against the exported database do not survive a restore.
139
+ return {event:'link-import-complete',imported,collections:Object.fromEntries(counts),
140
+ source:{revision:header?.revision,generatedAt:header?.generatedAt,sha256:complete.sha256},versionsReassigned:true};
141
+ }
@@ -0,0 +1,76 @@
1
+ import {assert} from './errors.js';
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+ // Trusted-operator observation of finished link requests. The observer is
10
+ // supplied by the embedding operator process, never by route YAML or guest
11
+ // code, and it runs after the response is over: it can never change, delay or
12
+ // fail a redirect. Work is bounded by an explicit queue; overload drops events
13
+ // and counts the drops rather than growing memory behind the operator's back.
14
+ const outcomes=new Set(['completed','aborted','missing','disabled','expired','invalid_code','invalid_record','unavailable']);
15
+ export function createLinkObserver(options , log = () => {}) {
16
+ if(options===undefined)return undefined;
17
+ assert(options && typeof options==='object' && !Array.isArray(options),'Link events must be an options object');
18
+ const {observe,includeCode=false,maxQueue=256,timeoutMs=1000}=options ;
19
+ assert(Object.keys(options).every(key=>['observe','includeCode','maxQueue','timeoutMs'].includes(key)),'Unsupported link event option');
20
+ assert(typeof observe==='function','Link events require an observe(event) function');
21
+ assert(typeof includeCode==='boolean','Link event code disclosure must be a boolean');
22
+ assert(Number.isInteger(maxQueue)&&maxQueue>=1&&maxQueue<=4096,'Link event queue must be 1–4096');
23
+ assert(Number.isInteger(timeoutMs)&&timeoutMs>=1&&timeoutMs<=10000,'Link event timeout must be 1–10000 ms');
24
+ const queue =[];
25
+ let delivered=0,dropped=0,failed=0,timedOut=0,closed=false,draining ;
26
+ const report =event=>{try{log(event);}catch{/* Logging cannot fail the observer. */}};
27
+ async function deliver(event ) {
28
+ let timer ;
29
+ try {
30
+ // A slow collector must not pin the queue: the budget is per event and the
31
+ // observer keeps running afterwards, whatever the abandoned call does.
32
+ await Promise.race([
33
+ Promise.resolve(observe (event)),
34
+ new Promise((_resolve,reject)=>{timer=setTimeout(()=>reject(new Error('timeout')),timeoutMs);timer.unref?.();}),
35
+ ]);
36
+ delivered++;
37
+ } catch(error) {
38
+ failed++;
39
+ const timeout=(error )?.message==='timeout';
40
+ if(timeout)timedOut++;
41
+ report({event:'link_observer',status:'failed',reason:timeout?'timeout':'error'});
42
+ } finally { clearTimeout(timer); }
43
+ }
44
+ async function pump() {
45
+ if(draining)return;
46
+ draining=(async()=>{while(queue.length)await deliver(queue.shift() );})();
47
+ try{await draining;}finally{draining=undefined;}
48
+ }
49
+ return {
50
+ // Called from the request path. It must stay synchronous and total.
51
+ emit(event ) {
52
+ try {
53
+ if(closed||!outcomes.has(event.outcome)){dropped++;return;}
54
+ if(queue.length>=maxQueue){
55
+ dropped++;
56
+ if(dropped===1||dropped%maxQueue===0)report({event:'link_observer',status:'dropped',dropped});
57
+ return;
58
+ }
59
+ const {code:_code,...redacted}=event;
60
+ queue.push(includeCode?{...event}:redacted);
61
+ void pump();
62
+ } catch { dropped++; }
63
+ },
64
+ stats:()=>({queued:queue.length,delivered,dropped,failed,timedOut,closed}),
65
+ async close() {
66
+ closed=true;
67
+ // Drain what was already accepted, then stop; a stuck collector cannot
68
+ // hold shutdown open past its own per-event budget plus this deadline.
69
+ void pump();
70
+ const deadline=new Promise (resolve=>{const timer=setTimeout(()=>resolve(false),timeoutMs*2+1000);timer.unref?.();});
71
+ while(draining)if(!await Promise.race([draining.then(()=>true,()=>true),deadline]))break;
72
+ dropped+=queue.length;queue.length=0;
73
+ return this.stats();
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,31 @@
1
+ import {randomBytes} from 'node:crypto';
2
+ import {HttpError} from './errors.js';
3
+
4
+ const check = (value,message) => { if(!value)throw new HttpError(400,message); };
5
+ export function linkCollection(value ) {
6
+ check(typeof value==='string' && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value),'Invalid link collection');return value;
7
+ }
8
+ export function linkCode(value ) {
9
+ check(typeof value==='string' && /^[A-Za-z0-9_-]{1,128}$/.test(value),'Invalid short code');return value;
10
+ }
11
+ export function linkVersion(value ) {
12
+ check(typeof value==='number' && Number.isSafeInteger(value) && value>0,'A positive expected version is required');return value;
13
+ }
14
+ export function linkData(value ) {
15
+ check(value && typeof value==='object' && !Array.isArray(value),'Invalid link record');
16
+ check(Object.keys(value).every(k=>['url','status','enabled','expires'].includes(k)),'Unsupported link field');
17
+ const record=value ;
18
+ check(typeof record.url==='string' && record.url.length<=8192 && !/[\s\u0000-\u001f\u007f\\]/u.test(record.url),'Invalid destination');
19
+ let url ;try{url=new URL(record.url);}catch{throw new HttpError(400,'Invalid destination');}
20
+ check(['http:','https:'].includes(url.protocol) && !url.username && !url.password,'Destination must be HTTP(S) without credentials');
21
+ check(Buffer.byteLength(url.href)<=8192,'Destination exceeds 8192 bytes');
22
+ const status=record.status??302,enabled=record.enabled??true,expires=record.expires??null;
23
+ check(typeof status==='number' && [301,302,303,307,308].includes(status),'Invalid redirect status');
24
+ check(typeof enabled==='boolean','Invalid enabled flag');
25
+ if(expires!==null){
26
+ check(typeof expires==='string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(expires) && Number.isFinite(Date.parse(expires)),'Invalid expiry');
27
+ check(new Date(expires).toISOString().replace('.000Z','Z')===expires.replace('.000Z','Z'),'Invalid expiry');
28
+ }
29
+ return {url:url.href,status,enabled,expires};
30
+ }
31
+ export const randomLinkCode=() =>randomBytes(12).toString('base64url');