@pattern-js/runtime-node 0.4.0-main.f8ef342 → 0.5.0-main.79cf721
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/add-delegate.d.ts +23 -0
- package/dist/add-delegate.d.ts.map +1 -0
- package/dist/add-delegate.js +36 -0
- package/dist/add-delegate.js.map +1 -0
- package/dist/bin/pattern.js +41 -2
- package/dist/bin/pattern.js.map +1 -1
- package/dist/durable/liveness.d.ts +66 -0
- package/dist/durable/liveness.d.ts.map +1 -0
- package/dist/durable/liveness.js +88 -0
- package/dist/durable/liveness.js.map +1 -0
- package/dist/durable/sqlite.d.ts +64 -0
- package/dist/durable/sqlite.d.ts.map +1 -0
- package/dist/durable/sqlite.js +207 -0
- package/dist/durable/sqlite.js.map +1 -0
- package/dist/http.d.ts +38 -5
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +117 -29
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/load/recorder.d.ts +1 -1
- package/dist/load/recorder.d.ts.map +1 -1
- package/dist/load/recorder.js.map +1 -1
- package/dist/project.d.ts +26 -0
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +42 -3
- package/dist/project.js.map +1 -1
- package/dist/trace/memory.d.ts +2 -2
- package/dist/trace/memory.d.ts.map +1 -1
- package/dist/trace/memory.js.map +1 -1
- package/dist/trace/sqlite.d.ts +12 -3
- package/dist/trace/sqlite.d.ts.map +1 -1
- package/dist/trace/sqlite.js +31 -5
- package/dist/trace/sqlite.js.map +1 -1
- package/dist/worker/entry.js +31 -4
- package/dist/worker/entry.js.map +1 -1
- package/dist/worker-pool.d.ts +9 -1
- package/dist/worker-pool.d.ts.map +1 -1
- package/dist/worker-pool.js +16 -2
- package/dist/worker-pool.js.map +1 -1
- package/package.json +6 -6
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pattern-js/runtime-node — the durable RunLedger on Node's `node:sqlite`.
|
|
3
|
+
*
|
|
4
|
+
* Full-fidelity run records (exact trigger input, exact node outputs) for
|
|
5
|
+
* `durable: true` workflows — the substrate resume and re-run replay from.
|
|
6
|
+
* Hand-written SQL like the trace store: WAL for multi-process access,
|
|
7
|
+
* busy_timeout so a dev-server restart waits for the lock. Lives in
|
|
8
|
+
* `.pattern-data/ledger.db` (gitignored beside the identity + document stores)
|
|
9
|
+
* because, unlike the masked trace store, it records REAL values.
|
|
10
|
+
*
|
|
11
|
+
* Rows stuck `running` after a crash become `error: "interrupted"` — a
|
|
12
|
+
* resumable record instead of a stuck one. Which rows are stuck is decided by
|
|
13
|
+
* process LIVENESS (owner heartbeats, see ./liveness.ts), never by run age: a
|
|
14
|
+
* young run of a dead process is swept, a long run of a live sibling is not.
|
|
15
|
+
*/
|
|
16
|
+
import { mkdirSync } from "node:fs";
|
|
17
|
+
import { dirname } from "node:path";
|
|
18
|
+
import { Liveness } from "./liveness.js";
|
|
19
|
+
const SCHEMA = `
|
|
20
|
+
CREATE TABLE IF NOT EXISTS ledger_runs (
|
|
21
|
+
run_id TEXT PRIMARY KEY,
|
|
22
|
+
workflow_id TEXT NOT NULL,
|
|
23
|
+
workflow_hash TEXT NOT NULL,
|
|
24
|
+
trigger_node TEXT NOT NULL,
|
|
25
|
+
input TEXT NOT NULL,
|
|
26
|
+
params TEXT,
|
|
27
|
+
principal TEXT NOT NULL,
|
|
28
|
+
parent_run_id TEXT,
|
|
29
|
+
resumed_from TEXT,
|
|
30
|
+
root_run_id TEXT,
|
|
31
|
+
owner TEXT,
|
|
32
|
+
status TEXT NOT NULL,
|
|
33
|
+
error TEXT,
|
|
34
|
+
started_at REAL NOT NULL,
|
|
35
|
+
ended_at REAL
|
|
36
|
+
);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_ledger_runs_wf ON ledger_runs(workflow_id, started_at);
|
|
38
|
+
CREATE TABLE IF NOT EXISTS ledger_nodes (
|
|
39
|
+
run_id TEXT NOT NULL,
|
|
40
|
+
node_id TEXT NOT NULL,
|
|
41
|
+
status TEXT NOT NULL,
|
|
42
|
+
outputs TEXT,
|
|
43
|
+
pulsed TEXT,
|
|
44
|
+
streaming INTEGER NOT NULL DEFAULT 0,
|
|
45
|
+
unserializable INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
error TEXT,
|
|
47
|
+
started_at REAL,
|
|
48
|
+
ended_at REAL,
|
|
49
|
+
PRIMARY KEY (run_id, node_id)
|
|
50
|
+
);
|
|
51
|
+
`;
|
|
52
|
+
/**
|
|
53
|
+
* Additive schema evolution: columns that joined after 0.5.0's first cut. A
|
|
54
|
+
* fresh file gets them from SCHEMA; an existing one grows them here. Pre-1.0
|
|
55
|
+
* the ledger only ever ADDS columns, so a bare "is it there yet" suffices.
|
|
56
|
+
*/
|
|
57
|
+
const COLUMNS = [
|
|
58
|
+
["ledger_runs", "root_run_id", "TEXT"],
|
|
59
|
+
["ledger_runs", "owner", "TEXT"],
|
|
60
|
+
["ledger_nodes", "error", "TEXT"],
|
|
61
|
+
];
|
|
62
|
+
function ensureColumns(db) {
|
|
63
|
+
ensureColumnsFor(db, COLUMNS);
|
|
64
|
+
}
|
|
65
|
+
export class SqliteRunLedger {
|
|
66
|
+
db;
|
|
67
|
+
opts;
|
|
68
|
+
liveness;
|
|
69
|
+
constructor(db, opts = {}) {
|
|
70
|
+
this.db = db;
|
|
71
|
+
this.opts = opts;
|
|
72
|
+
// Register as an owner and heartbeat; every beat also sweeps, so a crash
|
|
73
|
+
// shortly before this boot is caught within one stale window.
|
|
74
|
+
this.liveness = new Liveness(db, opts, () => this.sweep()).start();
|
|
75
|
+
this.sweep();
|
|
76
|
+
this.prune();
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Convert runs stuck `running` under a DEAD owner into terminal, RESUMABLE
|
|
80
|
+
* records. Ownership, not age: a live sibling's long run is untouched; a
|
|
81
|
+
* young run whose process died is swept. Legacy owner-less rows use the
|
|
82
|
+
* one-minute age rule they were written under.
|
|
83
|
+
*/
|
|
84
|
+
sweep() {
|
|
85
|
+
const { sql, params } = this.liveness.orphaned("owner", "started_at");
|
|
86
|
+
const res = this.db
|
|
87
|
+
.prepare(`UPDATE ledger_runs SET status = 'error', error = ?, ended_at = ? WHERE status = 'running' AND ${sql}`)
|
|
88
|
+
.run(JSON.stringify({ message: "interrupted (process exited mid-run)" }), Date.now(), ...params);
|
|
89
|
+
return Number(res.changes);
|
|
90
|
+
}
|
|
91
|
+
begin(h) {
|
|
92
|
+
this.db
|
|
93
|
+
.prepare(`INSERT OR REPLACE INTO ledger_runs
|
|
94
|
+
(run_id, workflow_id, workflow_hash, trigger_node, input, params, principal,
|
|
95
|
+
parent_run_id, resumed_from, root_run_id, owner, status, error, started_at, ended_at)
|
|
96
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, NULL)`)
|
|
97
|
+
.run(h.runId, h.workflowId, h.workflowHash, h.triggerNodeId, JSON.stringify(h.input), h.params ? JSON.stringify(h.params) : null, JSON.stringify(h.principal), h.parentRunId ?? null, h.resumedFrom ?? null, h.rootRunId ?? null, this.liveness.ownerId, h.status, h.startedAt);
|
|
98
|
+
}
|
|
99
|
+
nodeStarted(runId, nodeId, at) {
|
|
100
|
+
this.db
|
|
101
|
+
.prepare("INSERT OR IGNORE INTO ledger_nodes (run_id, node_id, status, started_at) VALUES (?, ?, 'started', ?)")
|
|
102
|
+
.run(runId, nodeId, at);
|
|
103
|
+
}
|
|
104
|
+
nodeFinished(r) {
|
|
105
|
+
this.db
|
|
106
|
+
.prepare(`INSERT INTO ledger_nodes (run_id, node_id, status, outputs, pulsed, streaming, unserializable, error, started_at, ended_at)
|
|
107
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
108
|
+
ON CONFLICT (run_id, node_id) DO UPDATE SET
|
|
109
|
+
status = excluded.status, outputs = excluded.outputs, pulsed = excluded.pulsed,
|
|
110
|
+
streaming = excluded.streaming, unserializable = excluded.unserializable,
|
|
111
|
+
error = excluded.error, ended_at = excluded.ended_at`)
|
|
112
|
+
.run(r.runId, r.nodeId, r.status, r.outputs ? JSON.stringify(r.outputs) : null, r.pulsed ? JSON.stringify(r.pulsed) : null, r.streaming ? 1 : 0, r.unserializable ? 1 : 0, r.error ? JSON.stringify(r.error) : null, r.startedAt ?? null, r.endedAt ?? null);
|
|
113
|
+
}
|
|
114
|
+
end(runId, status, error) {
|
|
115
|
+
this.db
|
|
116
|
+
.prepare("UPDATE ledger_runs SET status = ?, error = ?, ended_at = ? WHERE run_id = ?")
|
|
117
|
+
.run(status, error ? JSON.stringify(error) : null, Date.now(), runId);
|
|
118
|
+
if (status !== "running")
|
|
119
|
+
this.prune();
|
|
120
|
+
}
|
|
121
|
+
async get(runId) {
|
|
122
|
+
const row = this.db.prepare("SELECT * FROM ledger_runs WHERE run_id = ?").get(runId);
|
|
123
|
+
if (!row)
|
|
124
|
+
return null;
|
|
125
|
+
const nodes = this.db.prepare("SELECT * FROM ledger_nodes WHERE run_id = ?").all(runId).map((n) => ({
|
|
126
|
+
runId,
|
|
127
|
+
nodeId: String(n.node_id),
|
|
128
|
+
status: String(n.status),
|
|
129
|
+
outputs: n.outputs ? JSON.parse(String(n.outputs)) : undefined,
|
|
130
|
+
pulsed: n.pulsed ? JSON.parse(String(n.pulsed)) : undefined,
|
|
131
|
+
streaming: Number(n.streaming) === 1 ? true : undefined,
|
|
132
|
+
unserializable: Number(n.unserializable) === 1 ? true : undefined,
|
|
133
|
+
error: n.error ? JSON.parse(String(n.error)) : undefined,
|
|
134
|
+
startedAt: n.started_at == null ? undefined : Number(n.started_at),
|
|
135
|
+
endedAt: n.ended_at == null ? undefined : Number(n.ended_at),
|
|
136
|
+
}));
|
|
137
|
+
return {
|
|
138
|
+
header: {
|
|
139
|
+
runId: String(row.run_id),
|
|
140
|
+
workflowId: String(row.workflow_id),
|
|
141
|
+
workflowHash: String(row.workflow_hash),
|
|
142
|
+
triggerNodeId: String(row.trigger_node),
|
|
143
|
+
input: JSON.parse(String(row.input)),
|
|
144
|
+
params: row.params ? JSON.parse(String(row.params)) : undefined,
|
|
145
|
+
principal: JSON.parse(String(row.principal)),
|
|
146
|
+
parentRunId: row.parent_run_id == null ? undefined : String(row.parent_run_id),
|
|
147
|
+
resumedFrom: row.resumed_from == null ? undefined : String(row.resumed_from),
|
|
148
|
+
rootRunId: row.root_run_id == null ? undefined : String(row.root_run_id),
|
|
149
|
+
status: String(row.status),
|
|
150
|
+
error: row.error ? JSON.parse(String(row.error)) : undefined,
|
|
151
|
+
startedAt: Number(row.started_at),
|
|
152
|
+
endedAt: row.ended_at == null ? undefined : Number(row.ended_at),
|
|
153
|
+
},
|
|
154
|
+
nodes,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
prune(opts) {
|
|
158
|
+
const keep = opts?.keep ?? this.opts.keep ?? 200;
|
|
159
|
+
const excess = this.db
|
|
160
|
+
.prepare(`SELECT run_id FROM ledger_runs WHERE status != 'running'
|
|
161
|
+
ORDER BY started_at DESC LIMIT -1 OFFSET ?`)
|
|
162
|
+
.all(keep);
|
|
163
|
+
for (const r of excess) {
|
|
164
|
+
this.db.prepare("DELETE FROM ledger_nodes WHERE run_id = ?").run(r.run_id);
|
|
165
|
+
this.db.prepare("DELETE FROM ledger_runs WHERE run_id = ?").run(r.run_id);
|
|
166
|
+
}
|
|
167
|
+
return excess.length;
|
|
168
|
+
}
|
|
169
|
+
close() {
|
|
170
|
+
this.liveness.close();
|
|
171
|
+
this.db.close();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Open (creating if needed) the sqlite-backed RunLedger at `path`. */
|
|
175
|
+
export function createRunLedger(path, opts = {}) {
|
|
176
|
+
let DatabaseSync;
|
|
177
|
+
try {
|
|
178
|
+
const mod = process.getBuiltinModule?.("node:sqlite");
|
|
179
|
+
DatabaseSync = mod?.DatabaseSync;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
/* fall through */
|
|
183
|
+
}
|
|
184
|
+
if (!DatabaseSync) {
|
|
185
|
+
throw new Error("node:sqlite is not available in this Node build — the RunLedger needs Node ≥22.5 (≥24 recommended).");
|
|
186
|
+
}
|
|
187
|
+
if (path !== ":memory:")
|
|
188
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
189
|
+
const db = new DatabaseSync(path);
|
|
190
|
+
// busy_timeout before the WAL switch — the switch takes locks, and a dev
|
|
191
|
+
// restart overlaps the old process's checkpoint-on-close.
|
|
192
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
193
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
194
|
+
db.exec(SCHEMA);
|
|
195
|
+
ensureColumns(db);
|
|
196
|
+
return new SqliteRunLedger(db, opts);
|
|
197
|
+
}
|
|
198
|
+
export { ensureColumnsFor };
|
|
199
|
+
/** Shared additive-migration helper (the trace store grows its `owner` column the same way). */
|
|
200
|
+
function ensureColumnsFor(db, columns) {
|
|
201
|
+
for (const [table, column, ddl] of columns) {
|
|
202
|
+
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
203
|
+
if (!cols.some((c) => c.name === column))
|
|
204
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
//# sourceMappingURL=sqlite.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sqlite.js","sourceRoot":"","sources":["../../src/durable/sqlite.ts"],"names":[],"mappings":"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"}
|
package/dist/http.d.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Response modes: `buffered`, `sse` (Server-Sent Events), `chunked`.
|
|
12
12
|
*/
|
|
13
|
-
import { type Server } from "node:http";
|
|
14
|
-
import { type Engine } from "@pattern-js/core";
|
|
13
|
+
import { type IncomingMessage, type Server } from "node:http";
|
|
14
|
+
import { RateLimiter, type Engine } from "@pattern-js/core";
|
|
15
15
|
export interface HttpHostOptions {
|
|
16
16
|
/**
|
|
17
17
|
* Default port for routes that don't declare their own `port`. If omitted,
|
|
@@ -25,7 +25,19 @@ export interface HttpHostOptions {
|
|
|
25
25
|
* Routes with `bodyMode: "stream"` are exempt — they never buffer.
|
|
26
26
|
*/
|
|
27
27
|
maxBodyBytes?: number;
|
|
28
|
+
/**
|
|
29
|
+
* How many reverse proxies in front of this host append to X-Forwarded-For.
|
|
30
|
+
* 0 (default) trusts no header: the client IP is the socket peer. Behind one
|
|
31
|
+
* load balancer, 1: the IP is the entry that proxy appended (the rightmost),
|
|
32
|
+
* which a client cannot forge. Defaults to the PATTERN_TRUST_PROXY env var.
|
|
33
|
+
*/
|
|
34
|
+
trustProxy?: number;
|
|
28
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* The client's address. Only the last `trustProxy` X-Forwarded-For hops are
|
|
38
|
+
* believed: anything to their left was written by the client and is forgeable.
|
|
39
|
+
*/
|
|
40
|
+
export declare function clientIp(req: IncomingMessage, trustProxy: number): string;
|
|
29
41
|
export declare class HttpHost {
|
|
30
42
|
private readonly engine;
|
|
31
43
|
private readonly opts;
|
|
@@ -39,6 +51,25 @@ export declare class HttpHost {
|
|
|
39
51
|
private readonly defaultPort;
|
|
40
52
|
private readonly serverListeners;
|
|
41
53
|
constructor(engine: Engine, opts?: HttpHostOptions);
|
|
54
|
+
/** Forwarded hops believed when resolving the client IP (see HttpHostOptions.trustProxy). */
|
|
55
|
+
private readonly trustProxy;
|
|
56
|
+
/** Per-route request budgets (`rateLimit` on boundary.http.request). */
|
|
57
|
+
readonly rateLimiter: RateLimiter;
|
|
58
|
+
/**
|
|
59
|
+
* Refuse a request the route's `requireAuth` doesn't admit (§9):
|
|
60
|
+
* - anonymous → 401; a browser GET asking for HTML is bounced to the
|
|
61
|
+
* advertised login page (identity registers AUTH_LOGIN_URL) with `next`;
|
|
62
|
+
* - signed in but missing a scope → 403, so a client can tell "sign in"
|
|
63
|
+
* from "upgrade".
|
|
64
|
+
* The body is a line of text when the client asks for HTML, else JSON
|
|
65
|
+
* (`{ error, reason }`), the same rule as the 429 answer.
|
|
66
|
+
*/
|
|
67
|
+
private deny;
|
|
68
|
+
/**
|
|
69
|
+
* Count one request against the route's budget; past it, answer 429 with
|
|
70
|
+
* Retry-After and report true so the caller stops before the run.
|
|
71
|
+
*/
|
|
72
|
+
private limited;
|
|
42
73
|
/** Derive routes from registered workflows, open servers, and watch for changes. */
|
|
43
74
|
start(): Promise<{
|
|
44
75
|
ports: number[];
|
|
@@ -46,9 +77,11 @@ export declare class HttpHost {
|
|
|
46
77
|
}>;
|
|
47
78
|
/**
|
|
48
79
|
* Loud, honest signal: with NO auth provider installed, any route that
|
|
49
|
-
* *declares* `requireAuth` can't be enforced (nobody can authenticate)
|
|
50
|
-
* engine
|
|
51
|
-
*
|
|
80
|
+
* *declares* `requireAuth` can't be enforced (nobody can authenticate). By
|
|
81
|
+
* default the engine DENIES those requests (the refusal names the fix); with
|
|
82
|
+
* the explicit `auth.unenforced: "open"` opt-in it serves them open. Say
|
|
83
|
+
* which at boot so neither is a silent surprise. Add a provider and the same
|
|
84
|
+
* declarations are enforced.
|
|
52
85
|
*/
|
|
53
86
|
private warnIfUnenforceableAuth;
|
|
54
87
|
/** Tail of the rebuild queue — see `rebuild()`. */
|
package/dist/http.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,
|
|
1
|
+
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAgB,KAAK,eAAe,EAAE,KAAK,MAAM,EAAuB,MAAM,WAAW,CAAC;AAIjG,OAAO,EAGL,WAAW,EAKX,KAAK,MAAM,EAMZ,MAAM,kBAAkB,CAAC;AAG1B,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iDAAiD;IACjD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAUzE;AAiLD,qBAAa,QAAQ;IAYjB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAZvB,OAAO,CAAC,OAAO,CAA6B;IAC5C;iFAC6E;IAC7E,OAAO,CAAC,OAAO,CAAkC;IACjD,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,IAAI,CAAkB;IAC9B,OAAO,CAAC,WAAW,CAAC,CAAa;IACjC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAqD;gBAGlE,MAAM,EAAE,MAAM,EACd,IAAI,GAAE,eAAoB;IAU7C,6FAA6F;IAC7F,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,wEAAwE;IACxE,QAAQ,CAAC,WAAW,cAAqB;IAEzC;;;;;;;;OAQG;IACH,OAAO,CAAC,IAAI;IAsBZ;;;OAGG;IACH,OAAO,CAAC,OAAO;IAkBf,oFAAoF;IAC9E,KAAK,IAAI,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;KAAE,CAAC;IASvE;;;;;;;OAOG;IACH,OAAO,CAAC,uBAAuB;IAsB/B,mDAAmD;IACnD,OAAO,CAAC,UAAU,CAAoC;IAEtD;;;;;OAKG;IACH,OAAO,CAAC,OAAO;YAOD,UAAU;IAexB,gEAAgE;IAChE,OAAO,CAAC,WAAW;IASnB,OAAO,CAAC,UAAU;IAmClB;;;;;;OAMG;YACW,QAAQ;IAuCtB,qEAAqE;IACrE,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,UAAU;IAoClB,2EAA2E;IAC3E,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAIxC;;;OAGG;IACH,QAAQ,CAAC,EAAE,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,GAAG,MAAM,IAAI;IAMhE,OAAO,CAAC,KAAK;YAaC,MAAM;IA+IpB,+EAA+E;YACjE,QAAQ;IAmEtB,OAAO,CAAC,SAAS;IAYX,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAO7B;AAiMD,+EAA+E;AAC/E,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,eAAe,GAAG,QAAQ,CAE/E"}
|
package/dist/http.js
CHANGED
|
@@ -14,8 +14,24 @@ import { Buffer } from "node:buffer";
|
|
|
14
14
|
import { createServer } from "node:http";
|
|
15
15
|
import { extname } from "node:path";
|
|
16
16
|
import { Readable } from "node:stream";
|
|
17
|
-
import { ANONYMOUS, AUTH_LOGIN_URL, jsonSchemaToZod, principalToUser, } from "@pattern-js/core";
|
|
17
|
+
import { ANONYMOUS, AUTH_LOGIN_URL, RateLimiter, RunCanceled, jsonSchemaToZod, parseDurationMs, principalToUser, } from "@pattern-js/core";
|
|
18
18
|
import { filesystems } from "./filesystem.js";
|
|
19
|
+
/**
|
|
20
|
+
* The client's address. Only the last `trustProxy` X-Forwarded-For hops are
|
|
21
|
+
* believed: anything to their left was written by the client and is forgeable.
|
|
22
|
+
*/
|
|
23
|
+
export function clientIp(req, trustProxy) {
|
|
24
|
+
const peer = (req.socket?.remoteAddress ?? "").replace(/^::ffff:/, "") || "unknown";
|
|
25
|
+
if (trustProxy <= 0)
|
|
26
|
+
return peer;
|
|
27
|
+
const raw = req.headers["x-forwarded-for"];
|
|
28
|
+
const hops = (Array.isArray(raw) ? raw.join(",") : (raw ?? ""))
|
|
29
|
+
.split(",")
|
|
30
|
+
.map((h) => h.trim())
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
const ip = hops[hops.length - trustProxy];
|
|
33
|
+
return ip ? ip.replace(/^::ffff:/, "") : peer;
|
|
34
|
+
}
|
|
19
35
|
const DEFAULT_MAX_BODY = 10 * 1024 * 1024;
|
|
20
36
|
/** Thrown by `readBody` when a buffered body exceeds the configured cap. */
|
|
21
37
|
class BodyTooLargeError extends Error {
|
|
@@ -161,6 +177,62 @@ export class HttpHost {
|
|
|
161
177
|
// Default = explicit `defaultPort` (e.g. from pattern.config.json http.port),
|
|
162
178
|
// else the PORT env var, else 3000.
|
|
163
179
|
this.defaultPort = opts.defaultPort ?? envPort() ?? 3000;
|
|
180
|
+
const envTrust = Number(process.env.PATTERN_TRUST_PROXY);
|
|
181
|
+
this.trustProxy = opts.trustProxy ?? (Number.isInteger(envTrust) && envTrust > 0 ? envTrust : 0);
|
|
182
|
+
}
|
|
183
|
+
/** Forwarded hops believed when resolving the client IP (see HttpHostOptions.trustProxy). */
|
|
184
|
+
trustProxy;
|
|
185
|
+
/** Per-route request budgets (`rateLimit` on boundary.http.request). */
|
|
186
|
+
rateLimiter = new RateLimiter();
|
|
187
|
+
/**
|
|
188
|
+
* Refuse a request the route's `requireAuth` doesn't admit (§9):
|
|
189
|
+
* - anonymous → 401; a browser GET asking for HTML is bounced to the
|
|
190
|
+
* advertised login page (identity registers AUTH_LOGIN_URL) with `next`;
|
|
191
|
+
* - signed in but missing a scope → 403, so a client can tell "sign in"
|
|
192
|
+
* from "upgrade".
|
|
193
|
+
* The body is a line of text when the client asks for HTML, else JSON
|
|
194
|
+
* (`{ error, reason }`), the same rule as the 429 answer.
|
|
195
|
+
*/
|
|
196
|
+
deny(req, res, principal, reason, mayRedirect) {
|
|
197
|
+
const accept = String(req.headers["accept"] ?? "");
|
|
198
|
+
const anonymous = principal.kind === "anonymous";
|
|
199
|
+
if (anonymous && mayRedirect && accept.includes("text/html")) {
|
|
200
|
+
const loginUrl = this.engine.service(AUTH_LOGIN_URL);
|
|
201
|
+
if (loginUrl) {
|
|
202
|
+
res.writeHead(302, { location: `${loginUrl}?next=${encodeURIComponent(req.url ?? "/")}` }).end();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const status = anonymous ? 401 : 403;
|
|
207
|
+
if (accept.includes("text/html")) {
|
|
208
|
+
res
|
|
209
|
+
.writeHead(status, { "content-type": "text/plain; charset=utf-8" })
|
|
210
|
+
.end(`${anonymous ? "Unauthorized" : "Forbidden"}: ${reason}`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
res
|
|
214
|
+
.writeHead(status, { "content-type": "application/json" })
|
|
215
|
+
.end(JSON.stringify({ error: anonymous ? "unauthorized" : "forbidden", reason }));
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Count one request against the route's budget; past it, answer 429 with
|
|
219
|
+
* Retry-After and report true so the caller stops before the run.
|
|
220
|
+
*/
|
|
221
|
+
limited(res, route, client, req) {
|
|
222
|
+
const rule = route.rateLimit;
|
|
223
|
+
const v = this.rateLimiter.hit(`${route.workflowId}:${route.trigger}:${client}`, rule);
|
|
224
|
+
if (v.ok)
|
|
225
|
+
return false;
|
|
226
|
+
const retryAfter = Math.max(1, Math.ceil(v.retryAfterMs / 1000));
|
|
227
|
+
const html = String(req.headers.accept ?? "").includes("text/html");
|
|
228
|
+
res.writeHead(429, {
|
|
229
|
+
"content-type": html ? "text/plain; charset=utf-8" : "application/json",
|
|
230
|
+
"retry-after": String(retryAfter),
|
|
231
|
+
});
|
|
232
|
+
res.end(html
|
|
233
|
+
? `Too many requests. Try again in ${retryAfter} seconds.`
|
|
234
|
+
: JSON.stringify({ error: "rate_limited", retryAfter }));
|
|
235
|
+
return true;
|
|
164
236
|
}
|
|
165
237
|
/** Derive routes from registered workflows, open servers, and watch for changes. */
|
|
166
238
|
async start() {
|
|
@@ -173,20 +245,30 @@ export class HttpHost {
|
|
|
173
245
|
}
|
|
174
246
|
/**
|
|
175
247
|
* Loud, honest signal: with NO auth provider installed, any route that
|
|
176
|
-
* *declares* `requireAuth` can't be enforced (nobody can authenticate)
|
|
177
|
-
* engine
|
|
178
|
-
*
|
|
248
|
+
* *declares* `requireAuth` can't be enforced (nobody can authenticate). By
|
|
249
|
+
* default the engine DENIES those requests (the refusal names the fix); with
|
|
250
|
+
* the explicit `auth.unenforced: "open"` opt-in it serves them open. Say
|
|
251
|
+
* which at boot so neither is a silent surprise. Add a provider and the same
|
|
252
|
+
* declarations are enforced.
|
|
179
253
|
*/
|
|
180
254
|
warnIfUnenforceableAuth() {
|
|
181
|
-
|
|
255
|
+
const posture = this.engine.authPosture();
|
|
256
|
+
if (posture === "enforced")
|
|
182
257
|
return;
|
|
183
258
|
const gated = [...this.routes, ...this.apps].filter((r) => r.requireAuth).length;
|
|
184
259
|
if (!gated)
|
|
185
260
|
return;
|
|
261
|
+
if (posture === "open") {
|
|
262
|
+
console.warn(`\n[pattern] ⚠ ${gated} route(s) declare requireAuth but NO auth provider is installed —\n` +
|
|
263
|
+
`[pattern] auth.unenforced is "open", so they serve UNAUTHENTICATED (anyone who can reach\n` +
|
|
264
|
+
`[pattern] the port has access). Add an auth provider (e.g. @pattern-js/mod-identity) to\n` +
|
|
265
|
+
`[pattern] enforce them. Routes without requireAuth are unaffected.\n`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
186
268
|
console.warn(`\n[pattern] ⚠ ${gated} route(s) declare requireAuth but NO auth provider is installed —\n` +
|
|
187
|
-
`[pattern] they
|
|
188
|
-
`[pattern]
|
|
189
|
-
`[pattern]
|
|
269
|
+
`[pattern] they REFUSE every request (401) until you add one (e.g. @pattern-js/mod-identity).\n` +
|
|
270
|
+
`[pattern] For local work without sign-in, set "auth": { "unenforced": "open" } in\n` +
|
|
271
|
+
`[pattern] pattern.config.json. Routes without requireAuth are unaffected.\n`);
|
|
190
272
|
}
|
|
191
273
|
/** Tail of the rebuild queue — see `rebuild()`. */
|
|
192
274
|
rebuilding = Promise.resolve();
|
|
@@ -254,6 +336,9 @@ export class HttpHost {
|
|
|
254
336
|
paramsSchema: cfg.params ? jsonSchemaToZod(cfg.params, { coerce: true }) : undefined,
|
|
255
337
|
requireAuth: cfg.requireAuth,
|
|
256
338
|
cancelOnDisconnect: Boolean(cfg.cancelOnDisconnect),
|
|
339
|
+
rateLimit: cfg.rateLimit
|
|
340
|
+
? { max: cfg.rateLimit.max, windowMs: parseDurationMs(cfg.rateLimit.window ?? "1m"), by: cfg.rateLimit.by ?? "ip" }
|
|
341
|
+
: undefined,
|
|
257
342
|
});
|
|
258
343
|
}
|
|
259
344
|
}
|
|
@@ -410,6 +495,10 @@ export class HttpHost {
|
|
|
410
495
|
res.writeHead(500).end("workflow not registered");
|
|
411
496
|
return;
|
|
412
497
|
}
|
|
498
|
+
// ── Rate limit (by IP: before any auth or body work is spent) ──
|
|
499
|
+
const ip = clientIp(req, this.trustProxy);
|
|
500
|
+
if (route.rateLimit?.by === "ip" && this.limited(res, route, `ip:${ip}`, req))
|
|
501
|
+
return;
|
|
413
502
|
// ── Auth (§9) ──
|
|
414
503
|
const headers = new Headers();
|
|
415
504
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
@@ -420,9 +509,12 @@ export class HttpHost {
|
|
|
420
509
|
}
|
|
421
510
|
const principal = await this.engine.authenticate({ headers, raw: req });
|
|
422
511
|
const auth = this.engine.authorize(principal, route.requireAuth);
|
|
423
|
-
if (!auth.ok)
|
|
424
|
-
|
|
425
|
-
|
|
512
|
+
if (!auth.ok)
|
|
513
|
+
return this.deny(req, res, principal, auth.reason, method === "GET");
|
|
514
|
+
if (route.rateLimit?.by === "user") {
|
|
515
|
+
const who = principal.kind === "user" ? `user:${principal.id}` : `ip:${ip}`;
|
|
516
|
+
if (this.limited(res, route, who, req))
|
|
517
|
+
return;
|
|
426
518
|
}
|
|
427
519
|
// ── Validate params, query & body against the declared JSON Schemas (§7) ──
|
|
428
520
|
const headersObj = {};
|
|
@@ -469,6 +561,7 @@ export class HttpHost {
|
|
|
469
561
|
path: url.pathname,
|
|
470
562
|
headers: headersObj,
|
|
471
563
|
cookies: parseCookies(headersObj.cookie),
|
|
564
|
+
ip,
|
|
472
565
|
query,
|
|
473
566
|
params: routeParams,
|
|
474
567
|
body,
|
|
@@ -490,7 +583,7 @@ export class HttpHost {
|
|
|
490
583
|
if (route.cancelOnDisconnect) {
|
|
491
584
|
res.on("close", () => {
|
|
492
585
|
if (!delivered)
|
|
493
|
-
ac.abort(new
|
|
586
|
+
ac.abort(new RunCanceled("client disconnected"));
|
|
494
587
|
});
|
|
495
588
|
}
|
|
496
589
|
let result;
|
|
@@ -503,7 +596,7 @@ export class HttpHost {
|
|
|
503
596
|
}
|
|
504
597
|
if (result.status === "error") {
|
|
505
598
|
delivered = true;
|
|
506
|
-
return writeError(res, result.error);
|
|
599
|
+
return writeError(res, result.error, result.runId);
|
|
507
600
|
}
|
|
508
601
|
const payload = firstOutgate(result, workflow, "boundary.http.response");
|
|
509
602
|
if (!payload) {
|
|
@@ -534,20 +627,8 @@ export class HttpHost {
|
|
|
534
627
|
}
|
|
535
628
|
const principal = await this.engine.authenticate({ headers, raw: req });
|
|
536
629
|
const auth = this.engine.authorize(principal, app.requireAuth);
|
|
537
|
-
if (!auth.ok)
|
|
538
|
-
|
|
539
|
-
// (§9) — the identity mod registers AUTH_LOGIN_URL; fetch/XHR callers
|
|
540
|
-
// still get the bare 401 and handle it client-side.
|
|
541
|
-
const loginUrl = this.engine.service(AUTH_LOGIN_URL);
|
|
542
|
-
const accept = String(req.headers["accept"] ?? "");
|
|
543
|
-
if (loginUrl && accept.includes("text/html")) {
|
|
544
|
-
const next = encodeURIComponent(req.url ?? "/");
|
|
545
|
-
res.writeHead(302, { location: `${loginUrl}?next=${next}` }).end();
|
|
546
|
-
return;
|
|
547
|
-
}
|
|
548
|
-
res.writeHead(401, { "content-type": "text/plain" }).end(`Unauthorized: ${auth.reason}`);
|
|
549
|
-
return;
|
|
550
|
-
}
|
|
630
|
+
if (!auth.ok)
|
|
631
|
+
return this.deny(req, res, principal, auth.reason, true);
|
|
551
632
|
}
|
|
552
633
|
const fs = filesystems(this.engine).get(app.filesystem);
|
|
553
634
|
if (!fs) {
|
|
@@ -779,11 +860,18 @@ async function* streamIter(stream) {
|
|
|
779
860
|
reader.releaseLock();
|
|
780
861
|
}
|
|
781
862
|
}
|
|
782
|
-
|
|
863
|
+
/**
|
|
864
|
+
* A failed run's HTTP answer. In development the error message helps; in
|
|
865
|
+
* production (NODE_ENV=production) it can carry internals (config names,
|
|
866
|
+
* account ids, paths) to an unauthenticated caller, so the client gets a
|
|
867
|
+
* generic error and the run id, and the operator reads the rest in the trace.
|
|
868
|
+
*/
|
|
869
|
+
function writeError(res, err, runId) {
|
|
783
870
|
const message = err instanceof Error ? err.message : String(err);
|
|
871
|
+
const production = process.env.NODE_ENV === "production";
|
|
784
872
|
if (!res.headersSent)
|
|
785
873
|
res.writeHead(500, { "content-type": "application/json" });
|
|
786
|
-
res.end(JSON.stringify({ error: message }));
|
|
874
|
+
res.end(JSON.stringify(production ? { error: "internal_error", ...(runId ? { runId } : {}) } : { error: message, ...(runId ? { runId } : {}) }));
|
|
787
875
|
}
|
|
788
876
|
function firstOutgate(result, workflow, opType) {
|
|
789
877
|
for (const [nodeId, payload] of Object.entries(result.outputs)) {
|