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

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 (191) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +24 -8
  2. package/.claude/skills/urlcode-operations/SKILL.md +10 -16
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/README.md +75 -62
  5. package/ROADMAP.md +61 -16
  6. package/SECURITY.md +23 -8
  7. package/dist/BUILD-MANIFEST.json +38 -43
  8. package/dist/agents-guide.js +18 -18
  9. package/dist/build-static.js +134 -0
  10. package/dist/capabilities.js +85 -31
  11. package/dist/capability-query.js +0 -1
  12. package/dist/cli.js +23 -31
  13. package/dist/compliance-rules/baseline.js +2 -10
  14. package/dist/compliance-rules/privacy.js +5 -16
  15. package/dist/compliance-rules/shared.js +0 -2
  16. package/dist/compliance.js +6 -8
  17. package/dist/config.js +15 -8
  18. package/dist/context.js +9 -10
  19. package/dist/examples.js +2 -2
  20. package/dist/explain-cli.js +3 -3
  21. package/dist/explain.js +5 -6
  22. package/dist/extensions.js +61 -2
  23. package/dist/function-sources.js +34 -2
  24. package/dist/function-worker.js +3 -1
  25. package/dist/functions.js +84 -13
  26. package/dist/guest-api.js +29 -3
  27. package/dist/index.js +3 -6
  28. package/dist/manifest.js +5 -7
  29. package/dist/mcp.js +1 -1
  30. package/dist/observability.js +1 -21
  31. package/dist/policies/cache.js +8 -3
  32. package/dist/policy.js +28 -9
  33. package/dist/project-tests.js +3 -3
  34. package/dist/readiness.js +32 -9
  35. package/dist/router.js +5 -7
  36. package/dist/runtime.js +77 -58
  37. package/dist/sandbox.js +48 -0
  38. package/dist/scaffold.js +0 -0
  39. package/dist/scripts/operational-drills.js +12 -54
  40. package/dist/server.js +3 -29
  41. package/dist/tooling.js +1 -1
  42. package/dist/trusted-functions.js +210 -0
  43. package/dist/types/build-static.d.ts +43 -0
  44. package/dist/types/capabilities.d.ts +14 -5
  45. package/dist/types/compliance-rules/shared.d.ts +0 -2
  46. package/dist/types/compliance.d.ts +0 -3
  47. package/dist/types/config.d.ts +2 -1
  48. package/dist/types/context.d.ts +2 -1
  49. package/dist/types/examples.d.ts +1 -1
  50. package/dist/types/extensions.d.ts +55 -0
  51. package/dist/types/function-sources.d.ts +4 -0
  52. package/dist/types/functions.d.ts +48 -5
  53. package/dist/types/guest-api.d.ts +1 -0
  54. package/dist/types/index.d.ts +3 -6
  55. package/dist/types/manifest.d.ts +1 -3
  56. package/dist/types/observability.d.ts +1 -14
  57. package/dist/types/project-tests.d.ts +1 -2
  58. package/dist/types/readiness.d.ts +10 -3
  59. package/dist/types/router.d.ts +2 -1
  60. package/dist/types/runtime.d.ts +0 -27
  61. package/dist/types/sandbox.d.ts +12 -0
  62. package/dist/types/scaffold.d.ts +0 -2
  63. package/dist/types/server.d.ts +1 -4
  64. package/dist/types/tooling.d.ts +3 -3
  65. package/dist/types/trusted-functions.d.ts +29 -0
  66. package/dist/types/types.d.ts +14 -7
  67. package/dist/types/verify-deployment.d.ts +2 -2
  68. package/dist/types.js +17 -4
  69. package/dist/typescript-authoring.js +55 -17
  70. package/dist/verify-deployment.js +3 -3
  71. package/docs/AI-AUTHORING.md +104 -9
  72. package/docs/AWS.md +4 -4
  73. package/docs/BEST-PRACTICES.md +3 -2
  74. package/docs/CAPABILITIES.md +29 -16
  75. package/docs/CAPACITY.md +128 -31
  76. package/docs/CLOUDFLARE.md +1 -2
  77. package/docs/COMPLIANCE.md +6 -9
  78. package/docs/DEPLOYMENT-CHECKS.md +1 -1
  79. package/docs/EXTENSIONS.md +183 -11
  80. package/docs/FRAMEWORK.md +35 -19
  81. package/docs/FUNCTION-SECURITY.md +129 -32
  82. package/docs/INSTALL.md +0 -5
  83. package/docs/LOAD-TESTING.md +4 -4
  84. package/docs/MIDDLEWARE.md +27 -16
  85. package/docs/MONITORING.md +2 -19
  86. package/docs/NEXT-PHASE-PLAN.md +12 -4
  87. package/docs/NEXT-STEPS.md +259 -40
  88. package/docs/OBSERVABILITY.md +7 -16
  89. package/docs/OPEN-DECISIONS.md +212 -0
  90. package/docs/OPERATIONAL-PROOF.md +26 -30
  91. package/docs/OPERATIONS.md +23 -32
  92. package/docs/POLICIES.md +15 -4
  93. package/docs/PRERENDER.md +2 -2
  94. package/docs/PROJECT-DIRECTION.md +34 -9
  95. package/docs/README.md +13 -11
  96. package/docs/RELEASE-READINESS.md +49 -29
  97. package/docs/RELEASE-SECURITY.md +89 -3
  98. package/docs/RESILIENCE.md +16 -15
  99. package/docs/ROUTING.md +8 -10
  100. package/docs/SANDBOX-REVIEW.md +19 -6
  101. package/docs/SCAFFOLDING.md +0 -2
  102. package/docs/SECURITY-AUDIT.md +40 -0
  103. package/docs/SPECIFICATION.md +60 -26
  104. package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +287 -0
  105. package/docs/SPIKE-BUSINESS-SUITE.md +8 -0
  106. package/docs/SPIKE-CORE-LAYERING.md +337 -0
  107. package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +209 -0
  108. package/docs/SPIKE-EXTENSION-MODEL.md +2 -2
  109. package/docs/SPIKE-EXTENSIONS.md +5 -0
  110. package/docs/SPIKE-LAMBDA-COMPILE.md +4 -2
  111. package/docs/SPIKE-MONOREPO.md +322 -0
  112. package/docs/STARTERS.md +1 -1
  113. package/docs/STATIC.md +94 -0
  114. package/docs/TOOLING.md +13 -9
  115. package/docs/TUNNELS.md +0 -3
  116. package/docs/TYPESCRIPT-AUTHORING.md +27 -12
  117. package/docs/TYPESCRIPT.md +25 -4
  118. package/docs/USABILITY-REVIEW.md +7 -1
  119. package/docs/VERCEL.md +4 -5
  120. package/docs/VERSION-ALIGNMENT.md +205 -0
  121. package/docs/YAML-GUIDE.md +0 -3
  122. package/docs/YAML-REFERENCE.md +16 -6
  123. package/docs/policies/cache.md +13 -0
  124. package/examples/assets/example.yaml +1 -1
  125. package/examples/aws/example.yaml +1 -1
  126. package/examples/cloudflare/example.yaml +1 -1
  127. package/examples/conditions/example.yaml +1 -1
  128. package/examples/cookbook/example.yaml +1 -1
  129. package/examples/cookbook/middleware/bucket.mjs +12 -2
  130. package/examples/cookbook/middleware/locale.mjs +7 -3
  131. package/examples/egress/example.yaml +1 -1
  132. package/examples/extensions/example.yaml +1 -1
  133. package/examples/prerender/README.md +2 -2
  134. package/examples/prerender/example.yaml +1 -1
  135. package/examples/provider-conformance/example.yaml +1 -1
  136. package/examples/vercel/example.yaml +1 -1
  137. package/llms-full.txt +594 -219
  138. package/llms.txt +23 -12
  139. package/package.json +10 -3
  140. package/packaging/claude-plugin/.claude-plugin/plugin.json +1 -1
  141. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +24 -8
  142. package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +10 -16
  143. package/recipes/authenticated-json-api/recipe.yaml +1 -1
  144. package/recipes/contact-form/recipe.yaml +1 -1
  145. package/recipes/cors-api/recipe.yaml +1 -1
  146. package/recipes/health-page/recipe.yaml +1 -1
  147. package/recipes/json-api/recipe.yaml +1 -1
  148. package/recipes/middleware/middleware/bucket.mjs +12 -2
  149. package/recipes/middleware/middleware/locale.mjs +7 -3
  150. package/recipes/middleware/recipe.yaml +1 -1
  151. package/recipes/protected-download/recipe.yaml +1 -1
  152. package/recipes/redirect/recipe.yaml +1 -1
  153. package/recipes/static-plus-api/recipe.yaml +1 -1
  154. package/recipes/typescript/README.md +2 -1
  155. package/recipes/typescript/recipe.yaml +1 -1
  156. package/recipes/webhook-receiver/README.md +5 -1
  157. package/recipes/webhook-receiver/recipe.yaml +2 -1
  158. package/recipes/webhook-receiver/urlcode.yaml +7 -0
  159. package/schemas/recipe.schema.json +4 -3
  160. package/schemas/urlcode.schema.json +14 -41
  161. package/skills/urlcode/SKILL.md +29 -8
  162. package/starters/default/AGENTS.md +18 -18
  163. package/starters/default/urlcode.yaml +0 -1
  164. package/dist/link-api.js +0 -136
  165. package/dist/link-cli.js +0 -141
  166. package/dist/link-events.js +0 -76
  167. package/dist/link-records.js +0 -31
  168. package/dist/link-store-worker.js +0 -150
  169. package/dist/link-store.js +0 -250
  170. package/dist/management-policy.js +0 -40
  171. package/dist/sqlite-version.js +0 -6
  172. package/dist/types/link-api.d.ts +0 -30
  173. package/dist/types/link-cli.d.ts +0 -37
  174. package/dist/types/link-events.d.ts +0 -27
  175. package/dist/types/link-records.d.ts +0 -11
  176. package/dist/types/link-store-worker.d.ts +0 -1
  177. package/dist/types/link-store.d.ts +0 -130
  178. package/dist/types/management-policy.d.ts +0 -8
  179. package/dist/types/sqlite-version.d.ts +0 -1
  180. package/docs/DYNAMIC-LINKS.md +0 -61
  181. package/docs/MANAGEMENT-SECURITY.md +0 -82
  182. package/docs/links/cli.md +0 -110
  183. package/docs/links/limits.md +0 -175
  184. package/docs/links/management-api.md +0 -80
  185. package/docs/links/pools.md +0 -75
  186. package/docs/links/setup.md +0 -135
  187. package/docs/yaml/links.md +0 -30
  188. package/examples/live-links/README.md +0 -11
  189. package/examples/live-links/example.yaml +0 -21
  190. package/examples/live-links/tests/requests.json +0 -6
  191. package/examples/live-links/urlcode.yaml +0 -16
@@ -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
- });
@@ -1,250 +0,0 @@
1
- import {Worker} from 'node:worker_threads';
2
- import {realpath,lstat,open} from 'node:fs/promises';
3
- import {dirname,basename,join,relative,isAbsolute,sep} from 'node:path';
4
- import {ConfigError,HttpError,assert} from './errors.js';
5
- import {supportsConcurrentWal} from './sqlite-version.js';
6
-
7
-
8
- /** A stored link as the worker returns it. */
9
-
10
-
11
-
12
-
13
-
14
-
15
-
16
-
17
-
18
-
19
-
20
- // The worker protocol: one command per operation, one reply per command. The
21
- // worker validates every argument again; these types name the contract.
22
-
23
-
24
-
25
-
26
-
27
-
28
-
29
-
30
-
31
-
32
-
33
- /** One worker-backed SQLite connection. */
34
-
35
-
36
-
37
-
38
-
39
-
40
-
41
-
42
-
43
-
44
-
45
-
46
- /** The pooled store openLinkStore returns. */
47
-
48
-
49
-
50
-
51
-
52
-
53
-
54
-
55
-
56
-
57
-
58
-
59
-
60
-
61
- export async function outsideProject(file ,project ) {
62
- assert(typeof file==='string' && isAbsolute(file),'Operator file must use an absolute path');
63
- const parent=await realpath(dirname(file));const actual=join(parent,basename(file));
64
- const root=await realpath(project);const rel=relative(root,actual);
65
- assert(isAbsolute(rel)||rel==='..'||rel.startsWith('..'+sep),'Operator file must be outside the application project');
66
- return actual;
67
- }
68
- // A backstop against a worker that will never answer, not a performance budget:
69
- // a bad build or an unreadable file reports itself in milliseconds, while a cold,
70
- // heavily loaded machine can legitimately take seconds to boot a worker thread
71
- // and open SQLite. Set well clear of that, because refusing to start a store the
72
- // machine would have opened is the worse failure.
73
- const startupMs=15000;
74
-
75
- const isCode=(error ,code ) =>error instanceof Error && 'code' in error && error.code===code;
76
- async function openConnection({file,project='.',readOnly=false,log=()=>{}} ) {
77
- assert(supportsConcurrentWal(process.versions.sqlite),`Live links require a Node build with patched SQLite (3.51.3+, 3.50.7 or 3.44.6); this build has ${process.versions.sqlite}. Upgrade Node`);
78
- const path=await outsideProject(file,project);
79
- if(!readOnly){try{const handle=await open(path,'wx',0o600);await handle.close();}catch(e){if(!isCode(e,'EEXIST'))throw e;}}
80
- const info=await lstat(path);
81
- assert(info.isFile() && !info.isSymbolicLink() && info.nlink===1,'Link store must be a regular operator-owned file');
82
- const pending=new Map ();
83
- let worker ,sequence=0,healthy=false,closed=false,closing ,attempts=0,respawnTimer ;
84
- const report=(status ,extra ={})=>{try{log({event:'link_store_worker',status,readOnly,...extra});}catch{/* Logging cannot fail the store. */}};
85
- const fail=()=>{healthy=false;for(const {reject,timer} of pending.values()){clearTimeout(timer);reject(new HttpError(503,'Link store unavailable'));}pending.clear();};
86
- // A dead connection must not latch the store off: one slow query or an abrupt
87
- // worker exit is recoverable, and the records themselves live on disk.
88
- function scheduleRespawn() {
89
- if(closed)return;
90
- attempts++;
91
- const delayMs=Math.min(30000,250*2**Math.min(attempts-1,7));
92
- report('restarting',{attempt:attempts,delayMs});
93
- respawnTimer=setTimeout(()=>{respawnTimer=undefined;if(closed)return;void launch().catch(()=>scheduleRespawn());},delayMs);
94
- respawnTimer.unref();
95
- }
96
- async function launch() {
97
- const workerData ={file:path,readOnly};
98
- const instance=new Worker(new URL('./link-store-worker.js',import.meta.url),{workerData,env:{},execArgv:[],stdout:true,stderr:true,resourceLimits:{maxOldGenerationSizeMb:64}});
99
- worker=instance;instance.stdout.resume();instance.stderr.resume();
100
- try {
101
- await new Promise ((resolve,reject)=>{
102
- let started=false,settled=false;
103
- const settle=(error )=>{if(settled)return;settled=true;if(error)reject(error);else resolve();};
104
- const timer=setTimeout(()=>settle(new ConfigError(`Link store initialization failed: no ready signal within ${startupMs}ms`)),startupMs);
105
- instance.on('message',(message )=>{ // trust boundary: the worker's own protocol
106
- if('ready' in message){if(!started){started=true;healthy=true;clearTimeout(timer);report('started');settle();}return;}
107
- if('failed' in message){clearTimeout(timer);healthy=false;settle(new ConfigError('Link store initialization failed: the worker could not open the store'));return;}
108
- const request=pending.get(message.id);if(!request)return;
109
- // An answered operation, success or rejection, proves this connection is
110
- // serving again; a worker that starts cleanly but dies on every operation
111
- // must keep backing off rather than restarting in a tight loop.
112
- attempts=0;clearTimeout(request.timer);pending.delete(message.id);
113
- if('error' in message)request.reject(new HttpError(message.error.status,message.error.message));else request.resolve(message.value);
114
- });
115
- const down=()=>{
116
- clearTimeout(timer);
117
- const wasStarted=started;started=false;
118
- if(worker===instance)fail();
119
- settle(new ConfigError('Link store initialization failed: the worker exited or errored during start'));
120
- // Replace only a connection that had been serving; a failed activation
121
- // is reported to the caller instead of retried behind its back.
122
- if(wasStarted&&!closed&&worker===instance)scheduleRespawn();
123
- };
124
- instance.on('error',down);instance.on('exit',down);
125
- });
126
- } catch(error) {
127
- // An initialization error must not escape while its worker still owns the DB.
128
- await instance.terminate();
129
- throw error;
130
- }
131
- }
132
- await launch();
133
- function call (operation ,args ={},internal=false) {
134
- const instance=worker;
135
- if(!healthy||!instance||(!internal&&(closed||pending.size>=32)))return Promise.reject(new HttpError(503,'Link store capacity unavailable'));
136
- const id=++sequence;
137
- return new Promise ((resolve,reject)=>{
138
- const timer=setTimeout(()=>{if(worker===instance){fail();void instance.terminate();}},5000);
139
- pending.set(id,{resolve:value=>resolve(value ),reject,timer}); // trust boundary: the worker answers each operation with its own value type
140
- const command ={id,operation,args};
141
- try{instance.postMessage(command);}
142
- catch{clearTimeout(timer);pending.delete(id);reject(new HttpError(400,'Invalid store arguments'));}
143
- });
144
- }
145
- return {
146
- get healthy(){return healthy&&!closed;},
147
- get:(collection,code)=>call('get',{collection,code}),
148
- list:(collection,options={})=>call('list',{collection,...options}),
149
- exportBegin:(options={})=>call('exportBegin',options),
150
- exportPage:(options={})=>call('exportPage',options),
151
- exportEnd:()=>call('exportEnd',{}),
152
- create:(collection,data,code,audit)=>call('create',{collection,data,code,audit}),
153
- update:(collection,code,data,expectedVersion,audit)=>call('update',{collection,code,data,expectedVersion,audit}),
154
- delete:(collection,code,expectedVersion,audit)=>call('delete',{collection,code,expectedVersion,audit}),
155
- close(){
156
- if(closing)return closing;
157
- closed=true;
158
- if(respawnTimer){clearTimeout(respawnTimer);respawnTimer=undefined;}
159
- // Reserve shutdown admission and enqueue it after all accepted operations.
160
- closing=(async()=>{const instance=worker;try{if(healthy)await call('close',{},true);}finally{fail();await instance?.terminate();}})();
161
- return closing;
162
- },
163
- };
164
- }
165
-
166
- // SQLite permits concurrent readers but serializes writes. Keep independent
167
- // admission budgets so management work cannot consume redirect read capacity.
168
- export async function openLinkStore({file,project='.',readOnly=false,readers=2,maxReads=32,maxWrites=32,log=()=>{}} ={}) {
169
- assert(Number.isInteger(readers)&&readers>=1&&readers<=8,'Link readers must be 1–8');
170
- for(const value of [maxReads,maxWrites])assert(Number.isInteger(value)&&value>=1&&value<=32,'Link pool limits must be 1–32');
171
- const connections =[];let writer ;
172
- try {
173
- // Initialize before opening read-only connections on a new database.
174
- if(!readOnly){writer=await openConnection({file,project,log});connections.push(writer);}
175
- const read =[];
176
- for(let i=0;i<readers;i++){const connection=await openConnection({file,project,readOnly:true,log});connections.push(connection);read.push(connection);}
177
- return pooledStore(read,writer,maxReads,maxWrites);
178
- }catch(error){await Promise.allSettled(connections.map(connection=>connection.close()));throw error;}
179
- }
180
-
181
-
182
- function pooledStore(read ,writer ,maxReads ,maxWrites ) {
183
- const group=(connections ,limit ) =>({connections:connections.map(connection=>({connection,inFlight:0})),limit,inFlight:0,completed:0,failed:0,rejected:0,durationMs:0});
184
- const reads=group(read,maxReads),writes=group(writer?[writer]:[],maxWrites);
185
- let closed=false,closing ,exporting=false;
186
- const healthy=(pool )=>!closed&&pool.connections.length>0&&pool.connections.every(slot=>slot.connection.healthy);
187
- async function run (pool ,operation ) {
188
- if(closed){pool.rejected++;throw new HttpError(503,'Link store unavailable');}
189
- if(!pool.connections.length){pool.rejected++;throw new HttpError(403,'Store is read-only');}
190
- const slot=pool.connections.filter(slot=>slot.connection.healthy).sort((a,b)=>a.inFlight-b.inFlight)[0];
191
- if(!slot || pool.inFlight>=pool.limit){pool.rejected++;throw new HttpError(503,'Link pool capacity unavailable');}
192
- pool.inFlight++;slot.inFlight++;const started=performance.now();
193
- try{const value=await operation(slot.connection);pool.completed++;return value;}
194
- catch(error){pool.failed++;throw error;}
195
- finally{pool.inFlight--;slot.inFlight--;pool.durationMs+=performance.now()-started;}
196
- }
197
- // One bounded, point-in-time export at a time, pinned to a single reader and
198
- // holding that reader's admission for its whole life so it cannot outgrow the
199
- // pool's budget. Writers keep committing; this view does not see them.
200
- async function exportSnapshot({collection,pageSize=100,deadlineMs=60000} ={},{onHeader=()=>{},onRecords=()=>{}} ={}) {
201
- assert(Number.isInteger(pageSize)&&pageSize>=1&&pageSize<=100,'Export page size must be 1–100');
202
- assert(Number.isInteger(deadlineMs)&&deadlineMs>=1000&&deadlineMs<=600000,'Export deadline must be 1000–600000 ms');
203
- assert(typeof onHeader==='function' && typeof onRecords==='function','Export handlers must be functions');
204
- if(closed){reads.rejected++;throw new HttpError(503,'Link store unavailable');}
205
- if(exporting){reads.rejected++;throw new HttpError(409,'An export is already in progress');}
206
- const slot=reads.connections.filter(slot=>slot.connection.healthy).sort((a,b)=>a.inFlight-b.inFlight)[0];
207
- if(!slot||reads.inFlight>=reads.limit){reads.rejected++;throw new HttpError(503,'Link pool capacity unavailable');}
208
- exporting=true;reads.inFlight++;slot.inFlight++;
209
- const started=performance.now(),expires=Date.now()+deadlineMs;
210
- try {
211
- const header=await slot.connection.exportBegin(collection===undefined?{}:{collection});
212
- let exported=0,afterCollection='',afterCode='';
213
- try {
214
- // Identity and the snapshot's revision are handed over before any record,
215
- // so a truncated stream is recognizable rather than silently short.
216
- await onHeader(header);
217
- for(;;){
218
- if(Date.now()>expires)throw new HttpError(503,'Export deadline exceeded');
219
- const records=await slot.connection.exportPage({afterCollection,afterCode,limit:pageSize});
220
- const last=records.at(-1);
221
- if(!last)break;
222
- await onRecords(records);
223
- exported+=records.length;
224
- afterCollection=last.collection;afterCode=last.code;
225
- }
226
- } finally { await slot.connection.exportEnd().catch(()=>{}); }
227
- reads.completed++;
228
- return {...header,exported};
229
- } catch(error){reads.failed++;throw error;}
230
- finally{exporting=false;reads.inFlight--;slot.inFlight--;reads.durationMs+=performance.now()-started;}
231
- }
232
- const stats=(pool ) =>({connections:pool.connections.length,healthyConnections:pool.connections.filter(s=>s.connection.healthy).length,limit:pool.limit,inFlight:pool.inFlight,completed:pool.completed,failed:pool.failed,rejected:pool.rejected,durationMs:pool.durationMs});
233
- return {
234
- get readHealthy(){return healthy(reads);},
235
- get writeHealthy(){return healthy(writes);},
236
- get healthy(){return healthy(reads)&&(!writer||healthy(writes));},
237
- atomicAudit:true,
238
- stats:()=>({closed,exporting,read:stats(reads),write:stats(writes)}),
239
- exportSnapshot,
240
- get:(collection,code)=>run(reads,c=>c.get(collection,code)),list:(collection,options)=>run(reads,c=>c.list(collection,options)),
241
- create:(collection,data,code,audit)=>run(writes,c=>c.create(collection,data,code,audit)),update:(collection,code,data,expectedVersion,audit)=>run(writes,c=>c.update(collection,code,data,expectedVersion,audit)),delete:(collection,code,expectedVersion,audit)=>run(writes,c=>c.delete(collection,code,expectedVersion,audit)),
242
- close(){
243
- if(closing)return closing;closed=true;
244
- closing=(async()=>{
245
- const results=await Promise.allSettled([...read,...(writer?[writer]:[])].map(connection=>connection.close()));
246
- const failed=results.find((result) =>result.status==='rejected');if(failed)throw failed.reason;
247
- })();return closing;
248
- },
249
- };
250
- }
@@ -1,40 +0,0 @@
1
- import {constants} from 'node:fs';
2
- import {open} from 'node:fs/promises';
3
- import {createHash,timingSafeEqual} from 'node:crypto';
4
- import {outsideProject} from './link-store.js';
5
- import {assert} from './errors.js';
6
- /** A credential from the operator's management policy file, once it authenticated a request. */
7
-
8
-
9
-
10
- const actions =['get','list','create','update','delete'];
11
- const isRecord=(value ) =>value!==null && typeof value==='object' && !Array.isArray(value);
12
- export async function managementPolicy(file ,project ) {
13
- const path=await outsideProject(file,project);
14
- return async token=>{
15
- const handle=await open(path,constants.O_RDONLY | (constants.O_NOFOLLOW||0));
16
- let document ;
17
- try {
18
- const info=await handle.stat();
19
- assert(info.isFile() && info.nlink===1 && info.size<=65536 && (process.platform==='win32'||(info.mode&0o077)===0),'Invalid private management policy');
20
- const buffer=Buffer.alloc(65537);let offset=0;
21
- while(offset<buffer.length){const {bytesRead}=await handle.read(buffer,offset,buffer.length-offset,null);if(!bytesRead)break;offset+=bytesRead;}
22
- assert(offset<=65536,'Management policy exceeds 64 KiB');
23
- document=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(buffer.subarray(0,offset)));
24
- } finally {await handle.close();}
25
- assert(isRecord(document) && document.version===1 && Object.keys(document).every(k=>['version','credentials'].includes(k)) && Array.isArray(document.credentials) && document.credentials.length<=128,'Invalid management policy');
26
- const ids=new Set (),hashes=new Set ();let principal ;
27
- const digest=createHash('sha256').update(token).digest();
28
- for(const entry of document.credentials ){
29
- assert(isRecord(entry) && Object.keys(entry).every(k=>['id','sha256','expires','revoked','collections','actions'].includes(k)) && typeof entry.id==='string' && /^[A-Za-z0-9_-]{1,64}$/.test(entry.id) && !ids.has(entry.id),'Invalid credential identity');ids.add(entry.id);
30
- assert(typeof entry.sha256==='string' && /^[a-f0-9]{64}$/.test(entry.sha256) && !hashes.has(entry.sha256),'Invalid credential hash');hashes.add(entry.sha256);
31
- assert(typeof entry.expires==='string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/.test(entry.expires) && Number.isFinite(Date.parse(entry.expires)) && new Date(entry.expires).toISOString()===entry.expires.replace('Z','.000Z'),'Credential expiry required');
32
- assert(entry.revoked===undefined || typeof entry.revoked==='boolean','Invalid revocation');
33
- assert(Array.isArray(entry.collections) && entry.collections.length>0 && entry.collections.length<=100 && entry.collections.every(v=>typeof v==='string' && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(v)),'Invalid collection scope');
34
- assert(Array.isArray(entry.actions) && entry.actions.length>0 && entry.actions.every(a=>actions.includes(a)),'Invalid action scope');
35
- const c=entry ; // validated field by field above
36
- if(timingSafeEqual(digest,Buffer.from(c.sha256,'hex')) && !c.revoked && Date.parse(c.expires)>Date.now())principal=c;
37
- }
38
- return principal;
39
- };
40
- }
@@ -1,6 +0,0 @@
1
- // https://www.sqlite.org/wal.html#the_wal_reset_bug
2
- export function supportsConcurrentWal(version ) {
3
- const [major=NaN,minor=NaN,patch=NaN]=String(version).split('.').map(Number);
4
- if(![major,minor,patch].every(Number.isInteger))return false;
5
- return major>3 || major===3 && (minor>51 || minor===51&&patch>=3 || minor===50&&patch>=7 || minor===44&&patch>=6);
6
- }
@@ -1,30 +0,0 @@
1
- import type { AddressInfo } from 'node:net';
2
- import type { AuditIdentity, LinkRow, ListOptions } from './link-store.ts';
3
- import type { ManagementAuthorizer } from './management-policy.ts';
4
- import type { LogFn } from './types.ts';
5
- /** The store surface management needs: the pooled link store, or anything with the same contract. */
6
- export interface ManagementStore {
7
- readonly atomicAudit?: boolean;
8
- get(collection: string, code: string): Promise<LinkRow | null>;
9
- list(collection: string, options?: ListOptions): Promise<LinkRow[]>;
10
- create(collection: string, data: unknown, code?: unknown, audit?: AuditIdentity | undefined): Promise<LinkRow>;
11
- update(collection: string, code: string, data: unknown, expectedVersion: unknown, audit?: AuditIdentity | undefined): Promise<LinkRow>;
12
- delete(collection: string, code: string, expectedVersion: unknown, audit?: AuditIdentity | undefined): Promise<boolean>;
13
- }
14
- export interface LinkApiOptions {
15
- store: ManagementStore;
16
- collection: string;
17
- token?: string | undefined;
18
- authorize?: ManagementAuthorizer | undefined;
19
- host?: string | undefined;
20
- port?: number | undefined;
21
- maxInFlightRequests?: number | undefined;
22
- socketTimeoutMs?: number | undefined;
23
- log?: LogFn | undefined;
24
- }
25
- export interface LinkApi {
26
- address: AddressInfo;
27
- close(): Promise<void>;
28
- }
29
- export declare function loadLinkToken(file: unknown, project: string): Promise<string>;
30
- export declare function startLinkApi({ store, collection, token, authorize, host, port, maxInFlightRequests, socketTimeoutMs, log }: LinkApiOptions): Promise<LinkApi>;
@@ -1,37 +0,0 @@
1
- /** The parsed command-line values the links commands read; every flag is a string or absent. */
2
- interface LinkCommandValues {
3
- project?: string | undefined;
4
- store?: string | undefined;
5
- collection?: string | undefined;
6
- code?: string | undefined;
7
- destination?: string | undefined;
8
- status?: string | undefined;
9
- enabled?: string | undefined;
10
- expires?: string | undefined;
11
- 'if-version'?: string | undefined;
12
- limit?: string | undefined;
13
- after?: string | undefined;
14
- input?: string | undefined;
15
- 'page-size'?: string | undefined;
16
- 'auth-file'?: string | undefined;
17
- 'token-file'?: string | undefined;
18
- port?: string | undefined;
19
- host?: string | undefined;
20
- 'link-readers'?: string | undefined;
21
- 'link-read-limit'?: string | undefined;
22
- 'link-write-limit'?: string | undefined;
23
- }
24
- export type Print = (value: unknown) => unknown;
25
- export interface LinkPoolOptions {
26
- readers?: number;
27
- maxReads?: number;
28
- maxWrites?: number;
29
- }
30
- export interface LinkBinding extends LinkPoolOptions {
31
- collection: string;
32
- file: string;
33
- }
34
- export declare function linkPoolOptions(values: LinkCommandValues): LinkPoolOptions;
35
- export declare function parseLinkBinding(value: string | undefined, options?: LinkPoolOptions): LinkBinding | undefined;
36
- export declare function runLinkCommand(action: string | undefined, values: LinkCommandValues, print: Print): Promise<void>;
37
- export {};
@@ -1,27 +0,0 @@
1
- export interface LinkEvent {
2
- outcome: string;
3
- code?: string | null;
4
- [field: string]: unknown;
5
- }
6
- export interface LinkObserverOptions {
7
- observe: (event: LinkEvent) => unknown;
8
- includeCode?: boolean;
9
- maxQueue?: number;
10
- timeoutMs?: number;
11
- }
12
- export interface LinkObserverStats {
13
- queued: number;
14
- delivered: number;
15
- dropped: number;
16
- failed: number;
17
- timedOut: number;
18
- closed: boolean;
19
- }
20
- export interface LinkObserver {
21
- emit(event: LinkEvent): void;
22
- stats(): LinkObserverStats;
23
- close(): Promise<LinkObserverStats>;
24
- }
25
- type Log = (event: Record<string, unknown>) => void;
26
- export declare function createLinkObserver(options: unknown, log?: Log): LinkObserver | undefined;
27
- export {};
@@ -1,11 +0,0 @@
1
- export interface LinkRecord {
2
- url: string;
3
- status: number;
4
- enabled: boolean;
5
- expires: string | null;
6
- }
7
- export declare function linkCollection(value: unknown): string;
8
- export declare function linkCode(value: unknown): string;
9
- export declare function linkVersion(value: unknown): number;
10
- export declare function linkData(value: unknown): LinkRecord;
11
- export declare const randomLinkCode: () => string;
@@ -1 +0,0 @@
1
- export {};