@forgezero/runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +89 -0
- package/contracts/foundry.toml +9 -0
- package/contracts/src/ColdVault.sol +206 -0
- package/contracts/src/DepositFactory.sol +202 -0
- package/contracts/src/DepositProxy.sol +72 -0
- package/contracts/src/IERC20.sol +7 -0
- package/contracts/src/MockTokens.sol +32 -0
- package/contracts/src/SafeTransferLib.sol +31 -0
- package/contracts/test/Custody.t.sol +361 -0
- package/contracts/test/Vectors.t.sol +45 -0
- package/dist/audit.d.ts +265 -0
- package/dist/audit.js +291 -0
- package/dist/backup.d.ts +243 -0
- package/dist/backup.js +302 -0
- package/dist/calendar.d.ts +136 -0
- package/dist/calendar.js +129 -0
- package/dist/compliance.d.ts +172 -0
- package/dist/compliance.js +168 -0
- package/dist/finance/binance.d.ts +27 -0
- package/dist/finance/binance.js +452 -0
- package/dist/finance/chain-addresses.d.ts +130 -0
- package/dist/finance/chain-addresses.js +462 -0
- package/dist/finance/chain-deposits.d.ts +193 -0
- package/dist/finance/chain-deposits.js +596 -0
- package/dist/finance/chain-reconcile.d.ts +112 -0
- package/dist/finance/chain-reconcile.js +76 -0
- package/dist/finance/chain-withdrawals.d.ts +223 -0
- package/dist/finance/chain-withdrawals.js +631 -0
- package/dist/finance/chain.d.ts +116 -0
- package/dist/finance/chain.js +316 -0
- package/dist/finance/commission.d.ts +155 -0
- package/dist/finance/commission.js +419 -0
- package/dist/finance/custody.d.ts +68 -0
- package/dist/finance/custody.js +107 -0
- package/dist/finance/derive.d.ts +115 -0
- package/dist/finance/derive.js +116 -0
- package/dist/finance/discounts.d.ts +98 -0
- package/dist/finance/discounts.js +90 -0
- package/dist/finance/ledger.d.ts +221 -0
- package/dist/finance/ledger.js +308 -0
- package/dist/finance/market.d.ts +209 -0
- package/dist/finance/market.js +112 -0
- package/dist/finance/money.d.ts +118 -0
- package/dist/finance/money.js +176 -0
- package/dist/finance/rates.d.ts +178 -0
- package/dist/finance/rates.js +292 -0
- package/dist/finance/storage.d.ts +113 -0
- package/dist/finance/storage.js +226 -0
- package/dist/finance/tax.d.ts +132 -0
- package/dist/finance/tax.js +291 -0
- package/dist/finance/transfers.d.ts +153 -0
- package/dist/finance/transfers.js +292 -0
- package/dist/finance/venues.d.ts +190 -0
- package/dist/finance/venues.js +251 -0
- package/dist/identity.d.ts +115 -0
- package/dist/identity.js +111 -0
- package/dist/importers.d.ts +87 -0
- package/dist/importers.js +250 -0
- package/dist/jobs.d.ts +171 -0
- package/dist/jobs.js +250 -0
- package/dist/notify-templates.d.ts +11 -0
- package/dist/notify-templates.js +254 -0
- package/dist/notify.d.ts +172 -0
- package/dist/notify.js +122 -0
- package/dist/openssh.d.ts +36 -0
- package/dist/openssh.js +106 -0
- package/dist/otpauth.d.ts +57 -0
- package/dist/otpauth.js +223 -0
- package/dist/outbox.d.ts +234 -0
- package/dist/outbox.js +236 -0
- package/dist/passkey.d.ts +120 -0
- package/dist/passkey.js +105 -0
- package/dist/phrase.d.ts +87 -0
- package/dist/phrase.js +87 -0
- package/dist/pipeline.d.ts +137 -0
- package/dist/pipeline.js +121 -0
- package/dist/queue.d.ts +243 -0
- package/dist/queue.js +246 -0
- package/dist/schema-typebox.d.ts +24 -0
- package/dist/schema-typebox.js +201 -0
- package/dist/schema.d.ts +134 -0
- package/dist/schema.js +169 -0
- package/dist/serial.d.ts +54 -0
- package/dist/serial.js +40 -0
- package/dist/slip10.d.ts +37 -0
- package/dist/slip10.js +74 -0
- package/dist/snp.d.ts +115 -0
- package/dist/snp.js +109 -0
- package/dist/ssh-agent.d.ts +70 -0
- package/dist/ssh-agent.js +141 -0
- package/dist/ssh-cert.d.ts +73 -0
- package/dist/ssh-cert.js +111 -0
- package/dist/totp.d.ts +104 -0
- package/dist/totp.js +143 -0
- package/package.json +248 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined")
|
|
5
|
+
return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/importers.ts
|
|
10
|
+
function normaliseName(raw) {
|
|
11
|
+
return raw.trim().replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/_{2,}/g, "_").toUpperCase();
|
|
12
|
+
}
|
|
13
|
+
function dedupe(secrets) {
|
|
14
|
+
const seen = new Map;
|
|
15
|
+
const skipped = [];
|
|
16
|
+
for (const secret of secrets) {
|
|
17
|
+
const existing = seen.get(secret.name);
|
|
18
|
+
if (existing) {
|
|
19
|
+
skipped.push({
|
|
20
|
+
at: secret.source,
|
|
21
|
+
reason: `${secret.name} was already taken by ${existing.source}. The first one was kept.`
|
|
22
|
+
});
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
seen.set(secret.name, secret);
|
|
26
|
+
}
|
|
27
|
+
return { secrets: [...seen.values()], skipped };
|
|
28
|
+
}
|
|
29
|
+
function parseEnv(text) {
|
|
30
|
+
const secrets = [];
|
|
31
|
+
const skipped = [];
|
|
32
|
+
const lines = text.split(/\r?\n/);
|
|
33
|
+
for (let index = 0;index < lines.length; index += 1) {
|
|
34
|
+
const at = `line ${index + 1}`;
|
|
35
|
+
let line = lines[index].trim();
|
|
36
|
+
if (!line || line.startsWith("#"))
|
|
37
|
+
continue;
|
|
38
|
+
if (line.startsWith("export "))
|
|
39
|
+
line = line.slice(7).trim();
|
|
40
|
+
const split = line.indexOf("=");
|
|
41
|
+
if (split <= 0) {
|
|
42
|
+
skipped.push({ at, reason: "No `=`, so there is no name and value here." });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const name = normaliseName(line.slice(0, split));
|
|
46
|
+
if (!name) {
|
|
47
|
+
skipped.push({ at, reason: "The name is empty once normalised." });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
let value = line.slice(split + 1).trim();
|
|
51
|
+
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2 || value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
|
|
52
|
+
value = value.slice(1, -1);
|
|
53
|
+
} else {
|
|
54
|
+
const comment = value.indexOf(" #");
|
|
55
|
+
if (comment >= 0)
|
|
56
|
+
value = value.slice(0, comment).trim();
|
|
57
|
+
}
|
|
58
|
+
if (!value) {
|
|
59
|
+
skipped.push({ at, reason: `${name} has no value. An empty secret is almost never intended.` });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
secrets.push({ name, value, source: at });
|
|
63
|
+
}
|
|
64
|
+
const result = dedupe(secrets);
|
|
65
|
+
return { secrets: result.secrets, skipped: [...skipped, ...result.skipped] };
|
|
66
|
+
}
|
|
67
|
+
function parseCsvRows(text) {
|
|
68
|
+
const rows = [];
|
|
69
|
+
let row = [];
|
|
70
|
+
let field = "";
|
|
71
|
+
let quoted = false;
|
|
72
|
+
for (let index = 0;index < text.length; index += 1) {
|
|
73
|
+
const char = text[index];
|
|
74
|
+
if (quoted) {
|
|
75
|
+
if (char === '"') {
|
|
76
|
+
if (text[index + 1] === '"') {
|
|
77
|
+
field += '"';
|
|
78
|
+
index += 1;
|
|
79
|
+
} else
|
|
80
|
+
quoted = false;
|
|
81
|
+
} else
|
|
82
|
+
field += char;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (char === '"')
|
|
86
|
+
quoted = true;
|
|
87
|
+
else if (char === ",") {
|
|
88
|
+
row.push(field);
|
|
89
|
+
field = "";
|
|
90
|
+
} else if (char === `
|
|
91
|
+
`) {
|
|
92
|
+
row.push(field.replace(/\r$/, ""));
|
|
93
|
+
rows.push(row);
|
|
94
|
+
row = [];
|
|
95
|
+
field = "";
|
|
96
|
+
} else
|
|
97
|
+
field += char;
|
|
98
|
+
}
|
|
99
|
+
if (field || row.length > 0) {
|
|
100
|
+
row.push(field.replace(/\r$/, ""));
|
|
101
|
+
rows.push(row);
|
|
102
|
+
}
|
|
103
|
+
return rows.filter((entry) => entry.some((cell) => cell.trim()));
|
|
104
|
+
}
|
|
105
|
+
function parseCsv(text) {
|
|
106
|
+
const rows = parseCsvRows(text);
|
|
107
|
+
if (rows.length < 2)
|
|
108
|
+
return { secrets: [], skipped: [{ at: "file", reason: "No rows below the header." }] };
|
|
109
|
+
const header = rows[0].map((cell) => cell.trim().toLowerCase());
|
|
110
|
+
const nameColumn = header.findIndex((cell) => ["name", "title", "key", "label"].includes(cell));
|
|
111
|
+
const valueColumn = header.findIndex((cell) => ["password", "value", "secret", "credential"].includes(cell));
|
|
112
|
+
if (nameColumn < 0 || valueColumn < 0) {
|
|
113
|
+
return {
|
|
114
|
+
secrets: [],
|
|
115
|
+
skipped: [
|
|
116
|
+
{
|
|
117
|
+
at: "header",
|
|
118
|
+
reason: "No name and value column found. Expected a header with one of name/title/key/label and one of password/value/secret/credential."
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const noteColumn = header.findIndex((cell) => ["notes", "note", "comment"].includes(cell));
|
|
124
|
+
const secrets = [];
|
|
125
|
+
const skipped = [];
|
|
126
|
+
for (let index = 1;index < rows.length; index += 1) {
|
|
127
|
+
const row = rows[index];
|
|
128
|
+
const at = `row ${index + 1}`;
|
|
129
|
+
const rawName = row[nameColumn]?.trim() ?? "";
|
|
130
|
+
const value = row[valueColumn] ?? "";
|
|
131
|
+
if (!rawName) {
|
|
132
|
+
skipped.push({ at, reason: "No name." });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (!value) {
|
|
136
|
+
skipped.push({ at, reason: `${normaliseName(rawName)} has no value.` });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
secrets.push({
|
|
140
|
+
name: normaliseName(rawName),
|
|
141
|
+
value,
|
|
142
|
+
source: `${at} (${rawName})`,
|
|
143
|
+
note: noteColumn >= 0 ? row[noteColumn]?.trim() || undefined : undefined
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const result = dedupe(secrets);
|
|
147
|
+
return { secrets: result.secrets, skipped: [...skipped, ...result.skipped] };
|
|
148
|
+
}
|
|
149
|
+
function parseBitwarden(text) {
|
|
150
|
+
let parsed;
|
|
151
|
+
try {
|
|
152
|
+
parsed = JSON.parse(text);
|
|
153
|
+
} catch {
|
|
154
|
+
return { secrets: [], skipped: [{ at: "file", reason: "Not valid JSON." }] };
|
|
155
|
+
}
|
|
156
|
+
if (parsed.encrypted) {
|
|
157
|
+
return {
|
|
158
|
+
secrets: [],
|
|
159
|
+
skipped: [
|
|
160
|
+
{
|
|
161
|
+
at: "file",
|
|
162
|
+
reason: "This is an encrypted Bitwarden export. Export again with encryption off — importing it as-is would store ciphertext nobody can read."
|
|
163
|
+
}
|
|
164
|
+
]
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const items = Array.isArray(parsed.items) ? parsed.items : [];
|
|
168
|
+
const secrets = [];
|
|
169
|
+
const skipped = [];
|
|
170
|
+
for (const raw of items) {
|
|
171
|
+
const item = raw;
|
|
172
|
+
const title = item.name?.trim() ?? "";
|
|
173
|
+
const at = title || "an untitled item";
|
|
174
|
+
if (!title) {
|
|
175
|
+
skipped.push({ at, reason: "No name to import it under." });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const value = item.type === 1 ? item.login?.password : item.type === 2 ? item.notes : undefined;
|
|
179
|
+
if (!value) {
|
|
180
|
+
skipped.push({
|
|
181
|
+
at,
|
|
182
|
+
reason: item.type === 1 || item.type === 2 ? "Nothing to import — the item has no password or note." : "Not a login or a secure note. Cards and identities are not machine credentials."
|
|
183
|
+
});
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
secrets.push({
|
|
187
|
+
name: normaliseName(title),
|
|
188
|
+
value,
|
|
189
|
+
source: title,
|
|
190
|
+
note: item.type === 1 ? item.notes?.trim() || undefined : undefined
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const result = dedupe(secrets);
|
|
194
|
+
return { secrets: result.secrets, skipped: [...skipped, ...result.skipped] };
|
|
195
|
+
}
|
|
196
|
+
function parseOnePassword(text) {
|
|
197
|
+
let parsed;
|
|
198
|
+
try {
|
|
199
|
+
parsed = JSON.parse(text);
|
|
200
|
+
} catch {
|
|
201
|
+
return parseCsv(text);
|
|
202
|
+
}
|
|
203
|
+
const items = Array.isArray(parsed.items) ? parsed.items : (parsed.accounts ?? []).flatMap((account) => {
|
|
204
|
+
const vaults = account.vaults ?? [];
|
|
205
|
+
return vaults.flatMap((vault) => vault.items ?? []);
|
|
206
|
+
});
|
|
207
|
+
const secrets = [];
|
|
208
|
+
const skipped = [];
|
|
209
|
+
for (const raw of items) {
|
|
210
|
+
const item = raw;
|
|
211
|
+
const inner = item.item ?? item;
|
|
212
|
+
const title = (inner.overview?.title ?? "").trim();
|
|
213
|
+
const at = title || "an untitled item";
|
|
214
|
+
if (!title) {
|
|
215
|
+
skipped.push({ at, reason: "No name to import it under." });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const fields = inner.details?.loginFields ?? [];
|
|
219
|
+
const value = inner.details?.password ?? fields.find((field) => field.designation === "password")?.value ?? undefined;
|
|
220
|
+
if (!value) {
|
|
221
|
+
skipped.push({ at, reason: "No password field on this item." });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
secrets.push({ name: normaliseName(title), value, source: title });
|
|
225
|
+
}
|
|
226
|
+
const result = dedupe(secrets);
|
|
227
|
+
return { secrets: result.secrets, skipped: [...skipped, ...result.skipped] };
|
|
228
|
+
}
|
|
229
|
+
var IMPORT_FORMATS = ["env", "csv", "bitwarden", "1password"];
|
|
230
|
+
function parseImport(format, text) {
|
|
231
|
+
switch (format) {
|
|
232
|
+
case "env":
|
|
233
|
+
return parseEnv(text);
|
|
234
|
+
case "csv":
|
|
235
|
+
return parseCsv(text);
|
|
236
|
+
case "bitwarden":
|
|
237
|
+
return parseBitwarden(text);
|
|
238
|
+
case "1password":
|
|
239
|
+
return parseOnePassword(text);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
export {
|
|
243
|
+
parseOnePassword,
|
|
244
|
+
parseImport,
|
|
245
|
+
parseEnv,
|
|
246
|
+
parseCsv,
|
|
247
|
+
parseBitwarden,
|
|
248
|
+
normaliseName,
|
|
249
|
+
IMPORT_FORMATS
|
|
250
|
+
};
|
package/dist/jobs.d.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jobs — background work that does not corrupt anything when it goes wrong.
|
|
3
|
+
*
|
|
4
|
+
* ForgeZero needs session and step-up reaping, provider health probes and
|
|
5
|
+
* invoice runs. An exchange needs a deposit scanner and a withdrawal processor.
|
|
6
|
+
* They are the same machinery, and the same four mistakes sink all of them:
|
|
7
|
+
*
|
|
8
|
+
* OVERLAP `setInterval` fires again while the previous run is still going.
|
|
9
|
+
* Two deposit scans reading the same block credit it twice.
|
|
10
|
+
* CURSOR the cursor advances past a batch that failed halfway, so the
|
|
11
|
+
* deposits in it are never seen again and the shortfall is found
|
|
12
|
+
* by a customer.
|
|
13
|
+
* DUPLICATE two processes run the same job because nothing coordinated them.
|
|
14
|
+
* SHUTDOWN a deploy kills a run mid-write, leaving a record half-updated.
|
|
15
|
+
*
|
|
16
|
+
* Each has a specific answer here: reschedule after completion rather than on
|
|
17
|
+
* an interval, advance only on a fully successful batch, take a fenced lock,
|
|
18
|
+
* and await in-flight work on stop.
|
|
19
|
+
*
|
|
20
|
+
* Zero dependencies. The lock and the cursor store are interfaces, so this runs
|
|
21
|
+
* against a database, Redis, or nothing at all in a test.
|
|
22
|
+
*/
|
|
23
|
+
/** Injected so a test does not sleep and a resumed run is reproducible. */
|
|
24
|
+
export interface Clock {
|
|
25
|
+
now(): number;
|
|
26
|
+
sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
export declare const systemClock: Clock;
|
|
29
|
+
/** `30s` → 30000. Throws rather than guessing — a wrong interval is silent. */
|
|
30
|
+
export declare function everyMs(interval: string | number): number;
|
|
31
|
+
/**
|
|
32
|
+
* A lease, not a mutex.
|
|
33
|
+
*
|
|
34
|
+
* `until` is what makes a crash survivable: a process that dies holding a plain
|
|
35
|
+
* mutex blocks the job for ever, and somebody clears it by hand at three in the
|
|
36
|
+
* morning. A lease expires on its own.
|
|
37
|
+
*
|
|
38
|
+
* `fence` increases every time the lock is granted. A run that stalls past its
|
|
39
|
+
* lease, wakes up and tries to write finds its fence is stale and stops —
|
|
40
|
+
* without it, two processes each believing they hold the lock both write.
|
|
41
|
+
*/
|
|
42
|
+
export interface Lease {
|
|
43
|
+
fence: number;
|
|
44
|
+
until: number;
|
|
45
|
+
}
|
|
46
|
+
export interface JobLock {
|
|
47
|
+
acquire(key: string, ttlMs: number): Promise<Lease | undefined>;
|
|
48
|
+
/** Extend a lease held by this fence. False means it was lost. */
|
|
49
|
+
renew(key: string, fence: number, ttlMs: number): Promise<boolean>;
|
|
50
|
+
release(key: string, fence: number): Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
/** Single process. Correct for one instance and honest about being no more. */
|
|
53
|
+
export declare function memoryLock(clock?: Clock): JobLock;
|
|
54
|
+
/**
|
|
55
|
+
* Backed by a store the caller supplies — one row per job key.
|
|
56
|
+
*
|
|
57
|
+
* The store's `claim` must be atomic. A read-then-write implementation hands
|
|
58
|
+
* the same lease to two processes under exactly the load that makes it matter.
|
|
59
|
+
*/
|
|
60
|
+
export interface LockStore {
|
|
61
|
+
claim(key: string, until: number, now: number): Promise<Lease | undefined>;
|
|
62
|
+
extend(key: string, fence: number, until: number): Promise<boolean>;
|
|
63
|
+
clear(key: string, fence: number): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
export declare function storeLock(store: LockStore, clock?: Clock): JobLock;
|
|
66
|
+
export interface JobContext {
|
|
67
|
+
/** Aborted on stop. A long run should pass this to its own I/O. */
|
|
68
|
+
signal: AbortSignal;
|
|
69
|
+
/** Still ours? False means the lease was lost and the run must stop writing. */
|
|
70
|
+
holdsLock(): Promise<boolean>;
|
|
71
|
+
log(message: string, detail?: Record<string, unknown>): void;
|
|
72
|
+
}
|
|
73
|
+
export interface JobResult {
|
|
74
|
+
/** Anything worth showing on a status screen. Kept in the report verbatim. */
|
|
75
|
+
[key: string]: unknown;
|
|
76
|
+
}
|
|
77
|
+
export interface JobSpec {
|
|
78
|
+
key: string;
|
|
79
|
+
label: string;
|
|
80
|
+
/** `30s`, `5m`, or milliseconds. Omit for a job only ever run by hand. */
|
|
81
|
+
every?: string | number;
|
|
82
|
+
run(context: JobContext): Promise<JobResult | void>;
|
|
83
|
+
/**
|
|
84
|
+
* Lease length. Defaults to four intervals, so a slow run is not evicted the
|
|
85
|
+
* moment it exceeds its period — the most common cause of two runs at once.
|
|
86
|
+
*/
|
|
87
|
+
leaseMs?: number;
|
|
88
|
+
/** Skip the lock. Only for work that is safe to run in every process. */
|
|
89
|
+
unlocked?: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Delay before the first run. Defaults to a per-job spread so eight processes
|
|
92
|
+
* booting together do not all hit the database in the same millisecond.
|
|
93
|
+
*/
|
|
94
|
+
startDelayMs?: number;
|
|
95
|
+
}
|
|
96
|
+
export declare function defineJob(spec: JobSpec): JobSpec;
|
|
97
|
+
export interface JobReport {
|
|
98
|
+
key: string;
|
|
99
|
+
label: string;
|
|
100
|
+
state: 'idle' | 'running' | 'paused' | 'stopped';
|
|
101
|
+
lastStartedAtMs?: number;
|
|
102
|
+
lastFinishedAtMs?: number;
|
|
103
|
+
lastDurationMs?: number;
|
|
104
|
+
lastResult?: JobResult;
|
|
105
|
+
lastError?: string;
|
|
106
|
+
consecutiveFailures: number;
|
|
107
|
+
runs: number;
|
|
108
|
+
skippedLocked: number;
|
|
109
|
+
}
|
|
110
|
+
export interface SchedulerOptions {
|
|
111
|
+
jobs: readonly JobSpec[];
|
|
112
|
+
lock?: JobLock;
|
|
113
|
+
clock?: Clock;
|
|
114
|
+
/** Called on every failure. Wire to telemetry; must not throw. */
|
|
115
|
+
onError?: (key: string, error: unknown) => void;
|
|
116
|
+
onLog?: (key: string, message: string, detail?: Record<string, unknown>) => void;
|
|
117
|
+
}
|
|
118
|
+
export declare function createScheduler(options: SchedulerOptions): {
|
|
119
|
+
start(): void;
|
|
120
|
+
/**
|
|
121
|
+
* Stop and WAIT. Returning before in-flight work settles is how a deploy
|
|
122
|
+
* leaves a record half-written — the process exits mid-transaction and the
|
|
123
|
+
* next boot finds state nothing can explain.
|
|
124
|
+
*/
|
|
125
|
+
stop(): Promise<void>;
|
|
126
|
+
/** Timers stop firing; in-flight work is left to finish. */
|
|
127
|
+
pause(): void;
|
|
128
|
+
resume(): void;
|
|
129
|
+
/** Run one now, off-schedule. Still takes the lock. */
|
|
130
|
+
runNow(key: string): Promise<JobReport>;
|
|
131
|
+
status: () => readonly JobReport[];
|
|
132
|
+
report: (key: string) => JobReport | undefined;
|
|
133
|
+
isPaused: () => boolean;
|
|
134
|
+
isRunning: () => boolean;
|
|
135
|
+
};
|
|
136
|
+
export type Scheduler = ReturnType<typeof createScheduler>;
|
|
137
|
+
export interface CursorStore {
|
|
138
|
+
read(key: string): Promise<string | undefined>;
|
|
139
|
+
/** Only ever called after a fully successful batch. */
|
|
140
|
+
write(key: string, cursor: string): Promise<void>;
|
|
141
|
+
}
|
|
142
|
+
export interface CursorBatch<T> {
|
|
143
|
+
items: readonly T[];
|
|
144
|
+
/** Where to resume. Written only if every item processed cleanly. */
|
|
145
|
+
next: string;
|
|
146
|
+
/** False stops this run — caught up. */
|
|
147
|
+
more?: boolean;
|
|
148
|
+
}
|
|
149
|
+
export interface CursorJobSpec<T> extends Omit<JobSpec, 'run'> {
|
|
150
|
+
store: CursorStore;
|
|
151
|
+
from: string;
|
|
152
|
+
fetch(cursor: string, context: JobContext): Promise<CursorBatch<T>>;
|
|
153
|
+
process(item: T, context: JobContext): Promise<void>;
|
|
154
|
+
/** Batches per run. Bounded so one run cannot hold the lease for ever. */
|
|
155
|
+
maxBatches?: number;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Walk forward from a stored position, advancing only on complete success.
|
|
159
|
+
*
|
|
160
|
+
* The rule that makes this safe is one line in the loop: the cursor is written
|
|
161
|
+
* AFTER every item in the batch processed, never before and never per item. If
|
|
162
|
+
* item three of ten throws, the whole batch is retried from the same cursor on
|
|
163
|
+
* the next run. That means `process` must be idempotent — items one and two will
|
|
164
|
+
* be seen again — and idempotent retries are strictly better than the
|
|
165
|
+
* alternative, which is deposits that are never seen at all.
|
|
166
|
+
*
|
|
167
|
+
* The lease is re-checked between batches. A run that stalled past its lease and
|
|
168
|
+
* woke up must not keep writing while another process is doing the same work.
|
|
169
|
+
*/
|
|
170
|
+
export declare function cursorJob<T>(spec: CursorJobSpec<T>): JobSpec;
|
|
171
|
+
export declare const VERSION = "0.1.0";
|
package/dist/jobs.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined")
|
|
5
|
+
return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/jobs.ts
|
|
10
|
+
var systemClock = {
|
|
11
|
+
now: () => Date.now(),
|
|
12
|
+
sleep: (ms, signal) => new Promise((resolve, reject) => {
|
|
13
|
+
if (signal?.aborted)
|
|
14
|
+
return reject(new Error("aborted"));
|
|
15
|
+
const timer = setTimeout(resolve, ms);
|
|
16
|
+
signal?.addEventListener("abort", () => {
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
reject(new Error("aborted"));
|
|
19
|
+
});
|
|
20
|
+
})
|
|
21
|
+
};
|
|
22
|
+
var UNITS = { s: 1000, m: 60000, h: 3600000, d: 86400000 };
|
|
23
|
+
function everyMs(interval) {
|
|
24
|
+
if (typeof interval === "number")
|
|
25
|
+
return interval;
|
|
26
|
+
const match = /^(\d+)([smhd])$/.exec(interval);
|
|
27
|
+
if (!match)
|
|
28
|
+
throw new Error(`"${interval}" is not an interval like 30s, 5m, 1h, 1d.`);
|
|
29
|
+
return Number(match[1]) * UNITS[match[2]];
|
|
30
|
+
}
|
|
31
|
+
function memoryLock(clock = systemClock) {
|
|
32
|
+
const held = new Map;
|
|
33
|
+
let fences = 0;
|
|
34
|
+
return {
|
|
35
|
+
async acquire(key, ttlMs) {
|
|
36
|
+
const current = held.get(key);
|
|
37
|
+
if (current && current.until > clock.now())
|
|
38
|
+
return;
|
|
39
|
+
fences += 1;
|
|
40
|
+
const lease = { fence: fences, until: clock.now() + ttlMs };
|
|
41
|
+
held.set(key, lease);
|
|
42
|
+
return lease;
|
|
43
|
+
},
|
|
44
|
+
async renew(key, fence, ttlMs) {
|
|
45
|
+
const current = held.get(key);
|
|
46
|
+
if (!current || current.fence !== fence)
|
|
47
|
+
return false;
|
|
48
|
+
current.until = clock.now() + ttlMs;
|
|
49
|
+
return true;
|
|
50
|
+
},
|
|
51
|
+
async release(key, fence) {
|
|
52
|
+
if (held.get(key)?.fence === fence)
|
|
53
|
+
held.delete(key);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function storeLock(store, clock = systemClock) {
|
|
58
|
+
return {
|
|
59
|
+
acquire: (key, ttlMs) => store.claim(key, clock.now() + ttlMs, clock.now()),
|
|
60
|
+
renew: (key, fence, ttlMs) => store.extend(key, fence, clock.now() + ttlMs),
|
|
61
|
+
release: (key, fence) => store.clear(key, fence)
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function defineJob(spec) {
|
|
65
|
+
if (!spec.key.trim())
|
|
66
|
+
throw new Error("A job needs a key — it is the lock key and the report key.");
|
|
67
|
+
if (spec.every !== undefined)
|
|
68
|
+
everyMs(spec.every);
|
|
69
|
+
return spec;
|
|
70
|
+
}
|
|
71
|
+
function spreadOf(key, ceiling) {
|
|
72
|
+
let hash = 0;
|
|
73
|
+
for (const character of key)
|
|
74
|
+
hash = hash * 31 + character.charCodeAt(0) >>> 0;
|
|
75
|
+
return hash % Math.max(1, ceiling);
|
|
76
|
+
}
|
|
77
|
+
function createScheduler(options) {
|
|
78
|
+
const clock = options.clock ?? systemClock;
|
|
79
|
+
const lock = options.lock ?? memoryLock(clock);
|
|
80
|
+
const jobs = new Map(options.jobs.map((job) => [job.key, job]));
|
|
81
|
+
const reports = new Map(options.jobs.map((job) => [
|
|
82
|
+
job.key,
|
|
83
|
+
{ key: job.key, label: job.label, state: "stopped", consecutiveFailures: 0, runs: 0, skippedLocked: 0 }
|
|
84
|
+
]));
|
|
85
|
+
const timers = new Map;
|
|
86
|
+
const inFlight = new Map;
|
|
87
|
+
let controller = new AbortController;
|
|
88
|
+
let paused = false;
|
|
89
|
+
let running = false;
|
|
90
|
+
async function execute(job) {
|
|
91
|
+
const report = reports.get(job.key);
|
|
92
|
+
const leaseMs = job.leaseMs ?? (job.every ? everyMs(job.every) * 4 : 60000);
|
|
93
|
+
let lease;
|
|
94
|
+
if (!job.unlocked) {
|
|
95
|
+
lease = await lock.acquire(job.key, leaseMs);
|
|
96
|
+
if (!lease) {
|
|
97
|
+
report.skippedLocked += 1;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
report.state = "running";
|
|
102
|
+
report.lastStartedAtMs = clock.now();
|
|
103
|
+
const startedAt = clock.now();
|
|
104
|
+
try {
|
|
105
|
+
const result = await job.run({
|
|
106
|
+
signal: controller.signal,
|
|
107
|
+
holdsLock: async () => lease ? lock.renew(job.key, lease.fence, leaseMs) : true,
|
|
108
|
+
log: (message, detail) => options.onLog?.(job.key, message, detail)
|
|
109
|
+
});
|
|
110
|
+
report.lastResult = result ?? undefined;
|
|
111
|
+
report.lastError = undefined;
|
|
112
|
+
report.consecutiveFailures = 0;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
report.lastError = error instanceof Error ? error.message : String(error);
|
|
115
|
+
report.consecutiveFailures += 1;
|
|
116
|
+
options.onError?.(job.key, error);
|
|
117
|
+
} finally {
|
|
118
|
+
report.runs += 1;
|
|
119
|
+
report.lastFinishedAtMs = clock.now();
|
|
120
|
+
report.lastDurationMs = clock.now() - startedAt;
|
|
121
|
+
report.state = paused ? "paused" : running ? "idle" : "stopped";
|
|
122
|
+
if (lease)
|
|
123
|
+
await lock.release(job.key, lease.fence).catch(() => {
|
|
124
|
+
return;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function schedule(job, delayMs) {
|
|
129
|
+
if (!running || job.every === undefined)
|
|
130
|
+
return;
|
|
131
|
+
timers.set(job.key, setTimeout(() => {
|
|
132
|
+
timers.delete(job.key);
|
|
133
|
+
if (!running || paused)
|
|
134
|
+
return;
|
|
135
|
+
const work = execute(job).finally(() => {
|
|
136
|
+
inFlight.delete(job.key);
|
|
137
|
+
schedule(job, everyMs(job.every));
|
|
138
|
+
});
|
|
139
|
+
inFlight.set(job.key, work);
|
|
140
|
+
}, delayMs));
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
start() {
|
|
144
|
+
if (running)
|
|
145
|
+
return;
|
|
146
|
+
running = true;
|
|
147
|
+
paused = false;
|
|
148
|
+
controller = new AbortController;
|
|
149
|
+
for (const job of jobs.values()) {
|
|
150
|
+
reports.get(job.key).state = "idle";
|
|
151
|
+
const interval = job.every === undefined ? 0 : everyMs(job.every);
|
|
152
|
+
schedule(job, job.startDelayMs ?? spreadOf(job.key, Math.min(interval, 30000)));
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
async stop() {
|
|
156
|
+
running = false;
|
|
157
|
+
controller.abort();
|
|
158
|
+
for (const timer of timers.values())
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
timers.clear();
|
|
161
|
+
await Promise.allSettled([...inFlight.values()]);
|
|
162
|
+
for (const report of reports.values())
|
|
163
|
+
report.state = "stopped";
|
|
164
|
+
},
|
|
165
|
+
pause() {
|
|
166
|
+
paused = true;
|
|
167
|
+
for (const timer of timers.values())
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
timers.clear();
|
|
170
|
+
for (const report of reports.values()) {
|
|
171
|
+
if (report.state !== "running")
|
|
172
|
+
report.state = "paused";
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
resume() {
|
|
176
|
+
if (!running || !paused)
|
|
177
|
+
return;
|
|
178
|
+
paused = false;
|
|
179
|
+
for (const job of jobs.values()) {
|
|
180
|
+
reports.get(job.key).state = "idle";
|
|
181
|
+
schedule(job, 0);
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
async runNow(key) {
|
|
185
|
+
const job = jobs.get(key);
|
|
186
|
+
if (!job)
|
|
187
|
+
throw new Error(`No job named "${key}".`);
|
|
188
|
+
const existing = inFlight.get(key);
|
|
189
|
+
if (existing)
|
|
190
|
+
await existing;
|
|
191
|
+
const work = execute(job).finally(() => inFlight.delete(key));
|
|
192
|
+
inFlight.set(key, work);
|
|
193
|
+
await work;
|
|
194
|
+
return { ...reports.get(key) };
|
|
195
|
+
},
|
|
196
|
+
status: () => [...reports.values()].map((report) => ({ ...report })),
|
|
197
|
+
report: (key) => {
|
|
198
|
+
const found = reports.get(key);
|
|
199
|
+
return found ? { ...found } : undefined;
|
|
200
|
+
},
|
|
201
|
+
isPaused: () => paused,
|
|
202
|
+
isRunning: () => running
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function cursorJob(spec) {
|
|
206
|
+
const maxBatches = spec.maxBatches ?? 10;
|
|
207
|
+
return defineJob({
|
|
208
|
+
key: spec.key,
|
|
209
|
+
label: spec.label,
|
|
210
|
+
every: spec.every,
|
|
211
|
+
leaseMs: spec.leaseMs,
|
|
212
|
+
unlocked: spec.unlocked,
|
|
213
|
+
startDelayMs: spec.startDelayMs,
|
|
214
|
+
async run(context) {
|
|
215
|
+
let cursor = await spec.store.read(spec.key) ?? spec.from;
|
|
216
|
+
let batches = 0;
|
|
217
|
+
let processed = 0;
|
|
218
|
+
while (batches < maxBatches) {
|
|
219
|
+
if (context.signal.aborted)
|
|
220
|
+
break;
|
|
221
|
+
if (!await context.holdsLock()) {
|
|
222
|
+
context.log("lease lost mid-run; stopping before any further write");
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
const batch = await spec.fetch(cursor, context);
|
|
226
|
+
for (const item of batch.items) {
|
|
227
|
+
await spec.process(item, context);
|
|
228
|
+
processed += 1;
|
|
229
|
+
}
|
|
230
|
+
await spec.store.write(spec.key, batch.next);
|
|
231
|
+
cursor = batch.next;
|
|
232
|
+
batches += 1;
|
|
233
|
+
if (batch.more === false || batch.items.length === 0)
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
return { cursor, batches, processed };
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
var VERSION = "0.1.0";
|
|
241
|
+
export {
|
|
242
|
+
systemClock,
|
|
243
|
+
storeLock,
|
|
244
|
+
memoryLock,
|
|
245
|
+
everyMs,
|
|
246
|
+
defineJob,
|
|
247
|
+
cursorJob,
|
|
248
|
+
createScheduler,
|
|
249
|
+
VERSION
|
|
250
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { escapeHtml, type TemplateSpec } from './notify';
|
|
2
|
+
export declare const signInCode: TemplateSpec<"code" | "minutes">;
|
|
3
|
+
export declare const invitation: TemplateSpec<"link" | "inviter" | "organisation" | "days">;
|
|
4
|
+
export declare const custodianEnrolment: TemplateSpec<"link" | "organisation" | "threshold" | "total">;
|
|
5
|
+
export declare const ceremonyProposed: TemplateSpec<"link" | "proposer" | "change" | "needed">;
|
|
6
|
+
export declare const newDevice: TemplateSpec<"at" | "device" | "location">;
|
|
7
|
+
export declare const vaultLocked: TemplateSpec<"reason" | "link" | "threshold">;
|
|
8
|
+
export declare const TEMPLATES: readonly [TemplateSpec<"code" | "minutes">, TemplateSpec<"link" | "inviter" | "organisation" | "days">, TemplateSpec<"link" | "organisation" | "threshold" | "total">, TemplateSpec<"link" | "proposer" | "change" | "needed">, TemplateSpec<"at" | "device" | "location">, TemplateSpec<"reason" | "link" | "threshold">];
|
|
9
|
+
export type TemplateKey = (typeof TEMPLATES)[number]['key'];
|
|
10
|
+
/** Escape helper re-exported so a caller building a body fragment need not import two modules. */
|
|
11
|
+
export { escapeHtml };
|