@jimhoyd/urlcode 0.4.0-alpha.1 → 0.4.0-alpha.3

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 (232) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +36 -22
  2. package/.claude/skills/urlcode-operations/SKILL.md +16 -22
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CONTRIBUTING.md +20 -16
  5. package/README.md +83 -80
  6. package/ROADMAP.md +61 -331
  7. package/SECURITY.md +23 -8
  8. package/dist/BUILD-MANIFEST.json +42 -47
  9. package/dist/agents-guide.js +18 -18
  10. package/dist/build-static.js +136 -0
  11. package/dist/capabilities.js +85 -31
  12. package/dist/capability-query.js +0 -1
  13. package/dist/cli.js +24 -32
  14. package/dist/compliance-rules/baseline.js +2 -10
  15. package/dist/compliance-rules/privacy.js +5 -16
  16. package/dist/compliance-rules/shared.js +0 -2
  17. package/dist/compliance.js +6 -8
  18. package/dist/config.js +15 -8
  19. package/dist/context.js +9 -10
  20. package/dist/examples.js +2 -2
  21. package/dist/explain-cli.js +7 -5
  22. package/dist/explain.js +10 -5
  23. package/dist/extensions.js +61 -2
  24. package/dist/function-sources.js +34 -2
  25. package/dist/function-worker.js +3 -1
  26. package/dist/functions.js +84 -13
  27. package/dist/guest-api.js +29 -3
  28. package/dist/index.js +3 -6
  29. package/dist/manifest.js +11 -7
  30. package/dist/mcp-authoring.js +2 -2
  31. package/dist/mcp.js +1 -1
  32. package/dist/observability.js +1 -21
  33. package/dist/policies/cache.js +8 -3
  34. package/dist/policies.js +3 -1
  35. package/dist/policy.js +28 -9
  36. package/dist/prerender.js +4 -0
  37. package/dist/project-tests.js +3 -3
  38. package/dist/readiness.js +35 -9
  39. package/dist/route-diff.js +12 -5
  40. package/dist/router.js +5 -7
  41. package/dist/runtime.js +77 -58
  42. package/dist/sandbox.js +48 -0
  43. package/dist/scaffold.js +0 -0
  44. package/dist/scripts/operational-drills.js +12 -54
  45. package/dist/server.js +3 -29
  46. package/dist/tooling.js +1 -1
  47. package/dist/trusted-functions.js +210 -0
  48. package/dist/types/build-static.d.ts +43 -0
  49. package/dist/types/capabilities.d.ts +14 -5
  50. package/dist/types/compliance-rules/shared.d.ts +0 -2
  51. package/dist/types/compliance.d.ts +0 -3
  52. package/dist/types/config.d.ts +2 -1
  53. package/dist/types/context.d.ts +2 -1
  54. package/dist/types/examples.d.ts +1 -1
  55. package/dist/types/explain.d.ts +6 -0
  56. package/dist/types/extensions.d.ts +55 -0
  57. package/dist/types/function-sources.d.ts +4 -0
  58. package/dist/types/functions.d.ts +48 -5
  59. package/dist/types/guest-api.d.ts +1 -0
  60. package/dist/types/index.d.ts +3 -6
  61. package/dist/types/manifest.d.ts +5 -3
  62. package/dist/types/observability.d.ts +1 -14
  63. package/dist/types/project-tests.d.ts +1 -2
  64. package/dist/types/readiness.d.ts +12 -3
  65. package/dist/types/router.d.ts +2 -1
  66. package/dist/types/runtime.d.ts +0 -27
  67. package/dist/types/sandbox.d.ts +12 -0
  68. package/dist/types/scaffold.d.ts +0 -2
  69. package/dist/types/server.d.ts +1 -4
  70. package/dist/types/tooling.d.ts +3 -3
  71. package/dist/types/trusted-functions.d.ts +29 -0
  72. package/dist/types/types.d.ts +22 -8
  73. package/dist/types/verify-deployment.d.ts +2 -2
  74. package/dist/types.js +25 -5
  75. package/dist/typescript-authoring.js +67 -18
  76. package/dist/verify-deployment.js +3 -3
  77. package/docs/AI-AUTHORING.md +109 -10
  78. package/docs/ASSETS.md +2 -1
  79. package/docs/AUTH-BACKUP.md +32 -0
  80. package/docs/AWS.md +4 -4
  81. package/docs/BEST-PRACTICES.md +17 -8
  82. package/docs/CAPABILITIES.md +30 -17
  83. package/docs/CAPACITY.md +128 -31
  84. package/docs/CI-FOLLOWUP-2026-09-19.md +97 -0
  85. package/docs/CI-RELEASE-AUDIT-2026-09-19.md +322 -0
  86. package/docs/CI.md +8 -3
  87. package/docs/CLOUDFLARE.md +1 -2
  88. package/docs/CODEBASE-AUDIT-2026-09-20.md +278 -0
  89. package/docs/COMPLIANCE.md +6 -9
  90. package/docs/DEPLOYMENT-CHECKS.md +1 -1
  91. package/docs/DEVELOPMENT-PIPELINE.md +181 -0
  92. package/docs/EXTENSIONS.md +193 -11
  93. package/docs/FRAMEWORK.md +53 -33
  94. package/docs/FUNCTION-SECURITY.md +173 -32
  95. package/docs/INSTALL.md +0 -5
  96. package/docs/LOAD-TESTING.md +4 -4
  97. package/docs/LOCAL-DEVELOPMENT.md +3 -0
  98. package/docs/MIDDLEWARE.md +27 -16
  99. package/docs/MONITORING.md +2 -19
  100. package/docs/OBSERVABILITY.md +7 -16
  101. package/docs/OPEN-DECISIONS.md +184 -0
  102. package/docs/OPERATIONAL-PROOF.md +26 -30
  103. package/docs/OPERATIONS.md +23 -32
  104. package/docs/POLICIES.md +27 -8
  105. package/docs/PRERENDER.md +25 -13
  106. package/docs/PROJECT-DIRECTION.md +35 -10
  107. package/docs/READINESS.md +6 -2
  108. package/docs/README.md +20 -41
  109. package/docs/RELEASE-0.4.0-alpha.3.md +50 -0
  110. package/docs/RELEASE-READINESS.md +50 -30
  111. package/docs/RELEASE-SECURITY.md +72 -86
  112. package/docs/RESILIENCE.md +16 -15
  113. package/docs/ROUTING.md +7 -10
  114. package/docs/SANDBOX-REVIEW.md +19 -6
  115. package/docs/SCAFFOLDING.md +0 -2
  116. package/docs/SECURITY-AUDIT.md +42 -2
  117. package/docs/SPECIFICATION.md +63 -27
  118. package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +288 -0
  119. package/docs/SPIKE-BUSINESS-SUITE.md +16 -0
  120. package/docs/SPIKE-CORE-LAYERING.md +368 -0
  121. package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +211 -0
  122. package/docs/SPIKE-LAMBDA-COMPILE.md +12 -2
  123. package/docs/STARTERS.md +1 -1
  124. package/docs/STATIC.md +105 -0
  125. package/docs/TOOLING.md +17 -12
  126. package/docs/TUNNELS.md +0 -3
  127. package/docs/TYPESCRIPT-AUTHORING.md +32 -12
  128. package/docs/TYPESCRIPT.md +25 -4
  129. package/docs/VERCEL.md +4 -5
  130. package/docs/VERSION-ALIGNMENT.md +46 -0
  131. package/docs/YAML-GUIDE.md +0 -3
  132. package/docs/YAML-REFERENCE.md +16 -6
  133. package/docs/archive/2026-09-19/EXTENSION-IMPLEMENTATION.md +68 -0
  134. package/docs/{MANAGEMENT-SECURITY.md → archive/2026-09-19/MANAGEMENT-SECURITY.md} +20 -0
  135. package/docs/{NEXT-PHASE-PLAN.md → archive/2026-09-19/NEXT-PHASE-PLAN.md} +26 -8
  136. package/docs/{NEXT-STEPS.md → archive/2026-09-19/NEXT-STEPS.md} +274 -43
  137. package/docs/archive/2026-09-19/OPEN-DECISIONS.md +277 -0
  138. package/docs/archive/2026-09-19/RELEASE-SECURITY.md +186 -0
  139. package/docs/archive/2026-09-19/ROADMAP.md +386 -0
  140. package/docs/{SPIKE-EXTENSION-MODEL.md → archive/2026-09-19/SPIKE-EXTENSION-MODEL.md} +12 -2
  141. package/docs/{SPIKE-EXTENSIONS.md → archive/2026-09-19/SPIKE-EXTENSIONS.md} +28 -12
  142. package/docs/archive/2026-09-19/SPIKE-MONOREPO.md +776 -0
  143. package/docs/{USABILITY-REVIEW.md → archive/2026-09-19/USABILITY-REVIEW.md} +18 -2
  144. package/docs/archive/README.md +27 -0
  145. package/docs/policies/agents.md +1 -1
  146. package/docs/policies/cache.md +13 -0
  147. package/docs/policies/compression.md +3 -2
  148. package/docs/policies/security.md +3 -2
  149. package/examples/assets/example.yaml +2 -2
  150. package/examples/aws/example.yaml +1 -1
  151. package/examples/cloudflare/example.yaml +1 -1
  152. package/examples/conditions/example.yaml +1 -1
  153. package/examples/cookbook/example.yaml +1 -1
  154. package/examples/cookbook/middleware/bucket.mjs +12 -2
  155. package/examples/cookbook/middleware/locale.mjs +7 -3
  156. package/examples/cookbook/route-index.json +1 -1
  157. package/examples/cookbook/routes/middleware.yaml +1 -1
  158. package/examples/egress/example.yaml +1 -1
  159. package/examples/extensions/example.yaml +1 -1
  160. package/examples/prerender/README.md +14 -6
  161. package/examples/prerender/example.yaml +1 -1
  162. package/examples/prerender/functions/page.mjs +4 -2
  163. package/examples/prerender/middleware/template.mjs +1 -1
  164. package/examples/prerender/prerender.mjs +1 -1
  165. package/examples/prerender/urlcode.yaml +8 -4
  166. package/examples/provider-conformance/example.yaml +1 -1
  167. package/examples/vercel/example.yaml +1 -1
  168. package/llms-full.txt +686 -239
  169. package/llms.txt +27 -15
  170. package/package.json +32 -5
  171. package/packaging/claude-plugin/.claude-plugin/plugin.json +2 -2
  172. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +36 -22
  173. package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +16 -22
  174. package/recipes/authenticated-json-api/README.md +4 -3
  175. package/recipes/authenticated-json-api/functions/profile.mjs +2 -1
  176. package/recipes/authenticated-json-api/recipe.yaml +2 -2
  177. package/recipes/contact-form/functions/contact.mjs +2 -1
  178. package/recipes/contact-form/recipe.yaml +3 -3
  179. package/recipes/cors-api/README.md +2 -2
  180. package/recipes/cors-api/recipe.yaml +2 -2
  181. package/recipes/health-page/README.md +1 -1
  182. package/recipes/health-page/recipe.yaml +1 -1
  183. package/recipes/json-api/README.md +1 -1
  184. package/recipes/json-api/recipe.yaml +4 -4
  185. package/recipes/middleware/README.md +8 -4
  186. package/recipes/middleware/middleware/bucket.mjs +12 -2
  187. package/recipes/middleware/middleware/locale.mjs +7 -3
  188. package/recipes/middleware/recipe.yaml +1 -1
  189. package/recipes/protected-download/README.md +1 -1
  190. package/recipes/protected-download/recipe.yaml +2 -2
  191. package/recipes/redirect/recipe.yaml +1 -1
  192. package/recipes/static-plus-api/README.md +2 -2
  193. package/recipes/static-plus-api/public/index.html +1 -1
  194. package/recipes/static-plus-api/recipe.yaml +2 -2
  195. package/recipes/static-plus-api/urlcode.yaml +1 -1
  196. package/recipes/typescript/README.md +2 -1
  197. package/recipes/typescript/recipe.yaml +5 -5
  198. package/recipes/webhook-receiver/README.md +5 -1
  199. package/recipes/webhook-receiver/recipe.yaml +2 -1
  200. package/recipes/webhook-receiver/urlcode.yaml +7 -0
  201. package/schemas/recipe.schema.json +4 -3
  202. package/schemas/urlcode.schema.json +14 -41
  203. package/skills/urlcode/SKILL.md +32 -11
  204. package/starters/default/AGENTS.md +18 -18
  205. package/starters/default/urlcode.yaml +0 -1
  206. package/dist/link-api.js +0 -136
  207. package/dist/link-cli.js +0 -141
  208. package/dist/link-events.js +0 -76
  209. package/dist/link-records.js +0 -31
  210. package/dist/link-store-worker.js +0 -150
  211. package/dist/link-store.js +0 -250
  212. package/dist/management-policy.js +0 -40
  213. package/dist/sqlite-version.js +0 -6
  214. package/dist/types/link-api.d.ts +0 -30
  215. package/dist/types/link-cli.d.ts +0 -37
  216. package/dist/types/link-events.d.ts +0 -27
  217. package/dist/types/link-records.d.ts +0 -11
  218. package/dist/types/link-store-worker.d.ts +0 -1
  219. package/dist/types/link-store.d.ts +0 -130
  220. package/dist/types/management-policy.d.ts +0 -8
  221. package/dist/types/sqlite-version.d.ts +0 -1
  222. package/docs/DYNAMIC-LINKS.md +0 -61
  223. package/docs/links/cli.md +0 -110
  224. package/docs/links/limits.md +0 -175
  225. package/docs/links/management-api.md +0 -80
  226. package/docs/links/pools.md +0 -75
  227. package/docs/links/setup.md +0 -135
  228. package/docs/yaml/links.md +0 -30
  229. package/examples/live-links/README.md +0 -11
  230. package/examples/live-links/example.yaml +0 -21
  231. package/examples/live-links/tests/requests.json +0 -6
  232. package/examples/live-links/urlcode.yaml +0 -16
package/dist/link-cli.js DELETED
@@ -1,141 +0,0 @@
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
- }
@@ -1,76 +0,0 @@
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
- }
@@ -1,31 +0,0 @@
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');
@@ -1,150 +0,0 @@
1
- import {createHash,randomUUID} from 'node:crypto';
2
- import {parentPort,workerData} from 'node:worker_threads';
3
- import {DatabaseSync, } from 'node:sqlite';
4
- import {linkCollection,linkCode,linkData,linkVersion,randomLinkCode} from './link-records.js';
5
- import {HttpError} from './errors.js';
6
-
7
- if(!parentPort)throw new Error('Link store worker requires a parent');
8
- const port=parentPort;
9
- const data=workerData ; // trust boundary: set by openConnection
10
- const post=(message ) =>port.postMessage(message);
11
- let db ;
12
- const error=(status ,message ) =>{throw new HttpError(status,message);};
13
-
14
- const row=(value ) =>({...value,enabled:value.enabled===1} ); // trust boundary: a urlcode_links row
15
- const integer=(value ) =>typeof value==='number'?value:Number(value);
16
- try {
17
- db=new DatabaseSync(data.file,{readOnly:data.readOnly,allowExtension:false});
18
- db.exec('PRAGMA busy_timeout=1000; PRAGMA trusted_schema=OFF;');
19
- const schemaVersion=db.prepare('PRAGMA user_version').get()?.user_version;
20
- const applicationId=db.prepare('PRAGMA application_id').get()?.application_id;
21
- const empty=db.prepare("SELECT count(*) AS count FROM sqlite_master WHERE type='table'").get()?.count===0;
22
- if(!(schemaVersion===1 && applicationId===1431456835) && !(schemaVersion===0 && applicationId===0 && empty && !data.readOnly))throw new Error('Unsupported database schema');
23
- if(!data.readOnly){
24
- db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;');
25
- if(schemaVersion===0)db.exec(`BEGIN IMMEDIATE;
26
- CREATE TABLE IF NOT EXISTS urlcode_link_meta (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL);
27
- INSERT OR IGNORE INTO urlcode_link_meta VALUES(1,0);
28
- CREATE TABLE IF NOT EXISTS urlcode_links (
29
- collection TEXT NOT NULL, code TEXT NOT NULL, url TEXT NOT NULL,
30
- status INTEGER NOT NULL, enabled INTEGER NOT NULL, expires TEXT,
31
- version INTEGER NOT NULL, PRIMARY KEY(collection,code));
32
- PRAGMA application_id=1431456835; PRAGMA user_version=1; COMMIT;`);
33
- }
34
- if(!data.readOnly)db.exec(`CREATE TABLE IF NOT EXISTS urlcode_link_audit (revision INTEGER PRIMARY KEY, timestamp TEXT NOT NULL, actor TEXT NOT NULL, request_id TEXT NOT NULL, collection TEXT NOT NULL, action TEXT NOT NULL, code_sha256 TEXT NOT NULL)`);
35
- if(db.prepare('PRAGMA journal_mode').get()?.journal_mode!=='wal')throw new Error('Link stores require WAL mode');
36
- const revision=db.prepare('SELECT revision FROM urlcode_link_meta WHERE id=1').get()?.revision;
37
- if(typeof revision!=='number' || !Number.isSafeInteger(revision) || revision<0)throw new Error('Invalid store revision');
38
- db.prepare('SELECT collection,code,url,status,enabled,expires,version FROM urlcode_links LIMIT 0').all();
39
- post({ready:true});
40
- }catch{try{db?.close();}catch{/* Initialization can fail before opening. */}post({failed:true});port.close();}
41
- // Every handler below runs after a successful open; the failure branch closed the port.
42
- const store=() =>db ?? error(503,'Link store unavailable');
43
- function get(collection ,code ) {
44
- const found=store().prepare('SELECT * FROM urlcode_links WHERE collection=? AND code=?').get(collection,code);
45
- return found ? row(found) : null;
46
- }
47
- function transaction (fn , audit , collection , operation , code ) {
48
- const db=store();
49
- db.exec('BEGIN IMMEDIATE');
50
- try{
51
- const value=fn();
52
- const actor=audit?.actor??'local-operator',requestId=audit?.requestId??randomUUID();
53
- if(typeof actor!=='string'||!/^[A-Za-z0-9_-]{1,64}$/.test(actor)||typeof requestId!=='string'||!/^[A-Za-z0-9_-]{1,64}$/.test(requestId))error(400,'Invalid audit identity');
54
- const revision=db.prepare('SELECT revision FROM urlcode_link_meta WHERE id=1').get()?.revision;
55
- const subject=value!==true&&value!==null?value.code:code;
56
- if(typeof subject!=='string')throw new Error('Audit subject missing');
57
- db.prepare('INSERT INTO urlcode_link_audit VALUES(?,?,?,?,?,?,?)').run(integer(revision),new Date().toISOString(),actor,requestId,collection,operation,createHash('sha256').update(subject).digest('hex'));
58
- db.exec('COMMIT');return value;
59
- }catch(e){db.exec('ROLLBACK');throw e;}
60
- }
61
- function version() {
62
- const db=store();
63
- const current=db.prepare('SELECT revision FROM urlcode_link_meta WHERE id=1').get()?.revision;
64
- if(typeof current!=='number' || !Number.isSafeInteger(current) || current>=Number.MAX_SAFE_INTEGER)throw new HttpError(503,'Link store revision exhausted');
65
- db.prepare('UPDATE urlcode_link_meta SET revision=? WHERE id=1').run(current+1);return current+1;
66
- }
67
- // A consistent operator export needs one read transaction spanning every page.
68
- // WAL gives this connection a point-in-time view while writers keep committing;
69
- // the transaction is deliberately bounded and belongs to a single connection.
70
- let exporting =null;
71
- function exportEnd() {
72
- if(!exporting)return false;
73
- exporting=null;
74
- try{store().exec('ROLLBACK');}catch{/* A lost transaction is already finished. */}
75
- return true;
76
- }
77
- function exportBegin(collection ) {
78
- const db=store();
79
- if(exporting)error(409,'An export is already in progress on this connection');
80
- if(collection!==undefined)linkCollection(collection);
81
- db.exec('BEGIN DEFERRED');
82
- try {
83
- // The first read inside the transaction is what pins the snapshot.
84
- const records=collection===undefined
85
- ? db.prepare('SELECT count(*) AS count FROM urlcode_links').get()?.count
86
- : db.prepare('SELECT count(*) AS count FROM urlcode_links WHERE collection=?').get(collection)?.count;
87
- const revision=db.prepare('SELECT revision FROM urlcode_link_meta WHERE id=1').get()?.revision;
88
- exporting={collection:collection??null};
89
- return {format:'urlcode.links.v1',schemaVersion:integer(db.prepare('PRAGMA user_version').get()?.user_version),
90
- applicationId:integer(db.prepare('PRAGMA application_id').get()?.application_id),
91
- collection:collection??null,revision:integer(revision),records:integer(records),generatedAt:new Date().toISOString()};
92
- }catch(e){try{db.exec('ROLLBACK');}catch{/* Reported through the original error. */}throw e;}
93
- }
94
- function exportPage({afterCollection='',afterCode='',limit=100} ) {
95
- const db=store();
96
- if(!exporting)throw new HttpError(409,'No export is in progress on this connection');
97
- if(!Number.isInteger(limit)||limit<1||limit>100)error(400,'Limit must be 1–100');
98
- if(afterCollection!=='')linkCollection(afterCollection);
99
- if(afterCode!=='')linkCode(afterCode);
100
- const rows=exporting.collection===null
101
- ? db.prepare('SELECT collection,code,url,status,enabled,expires,version FROM urlcode_links WHERE collection>? OR (collection=? AND code>?) ORDER BY collection,code LIMIT ?').all(afterCollection,afterCollection,afterCode,limit)
102
- : db.prepare('SELECT collection,code,url,status,enabled,expires,version FROM urlcode_links WHERE collection=? AND code>? ORDER BY code LIMIT ?').all(exporting.collection,afterCode,limit);
103
- return rows.map(row);
104
- }
105
- port.on('message',({id,operation,args} )=>{ // trust boundary: posted by openConnection
106
- try {
107
- if(operation==='close'){exportEnd();store().close();post({id,value:true});port.close();return;}
108
- if(operation==='exportBegin'){post({id,value:exportBegin(args.collection)});return;}
109
- if(operation==='exportPage'){
110
- try{post({id,value:exportPage(args)});}
111
- catch(e){exportEnd();throw e;}
112
- return;
113
- }
114
- if(operation==='exportEnd'){post({id,value:exportEnd()});return;}
115
- const collection=linkCollection(args.collection),{code}=args;
116
- let value ;
117
- if(operation==='get'){value=get(collection,linkCode(code));}
118
- else if(operation==='list'){
119
- const limit=args.limit??100,after=args.after??'';
120
- if(!Number.isInteger(limit)||limit<1||limit>100)error(400,'Limit must be 1–100');
121
- if(after!=='')linkCode(after);
122
- value=store().prepare('SELECT * FROM urlcode_links WHERE collection=? AND code>? ORDER BY code LIMIT ?').all(collection,after,limit).map(row);
123
- }else{
124
- if(data.readOnly)error(403,'Store is read-only');
125
- if(exporting)error(409,'An export is in progress on this connection');
126
- value=transaction(() =>{
127
- const db=store();
128
- if(operation==='create'){
129
- const record=linkData(args.data);
130
- const count=db.prepare('SELECT count(*) AS count FROM urlcode_links').get()?.count;
131
- if(typeof count==='number' && count>=100000)error(507,'Link store record limit reached');
132
- let assigned=code===undefined?randomLinkCode():linkCode(code);
133
- if(code!==undefined && get(collection,assigned))error(409,'Short code already exists');
134
- if(code===undefined){let attempts=0;while(get(collection,assigned)){if(++attempts>=5)error(503,'Code allocation failed');assigned=randomLinkCode();}}
135
- db.prepare('INSERT INTO urlcode_links VALUES(?,?,?,?,?,?,?)').run(collection,assigned,record.url,record.status,Number(record.enabled),record.expires,version());
136
- return get(collection,assigned);
137
- }
138
- if(!['update','delete'].includes(operation))error(400,'Unsupported store operation');
139
- const current=linkCode(code);const expectedVersion=linkVersion(args.expectedVersion);
140
- const previous=get(collection,current);if(!previous)throw new HttpError(404,'Link not found');
141
- if(previous.version!==expectedVersion)error(409,'Link version changed');
142
- if(operation==='delete'){version();db.prepare('DELETE FROM urlcode_links WHERE collection=? AND code=?').run(collection,current);return true;}
143
- const record=linkData(args.data);
144
- db.prepare('UPDATE urlcode_links SET url=?,status=?,enabled=?,expires=?,version=? WHERE collection=? AND code=?').run(record.url,record.status,Number(record.enabled),record.expires,version(),collection,current);
145
- return get(collection,current);
146
- },args.audit,collection,operation,code);
147
- }
148
- post({id,value});
149
- }catch(e){post({id,error:{status:e instanceof HttpError?e.status:503,message:e instanceof HttpError?e.message:'Link store unavailable'}});}
150
- });