@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
package/dist/openssh.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
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/openssh.ts
|
|
10
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
11
|
+
import { toBase64Url, fromBase64Url } from "@forgezero/access/security";
|
|
12
|
+
var ALGORITHM = "ssh-ed25519";
|
|
13
|
+
var ENCODER = new TextEncoder;
|
|
14
|
+
function sshString(value) {
|
|
15
|
+
const bytes = typeof value === "string" ? ENCODER.encode(value) : value;
|
|
16
|
+
const out = new Uint8Array(4 + bytes.length);
|
|
17
|
+
new DataView(out.buffer).setUint32(0, bytes.length, false);
|
|
18
|
+
out.set(bytes, 4);
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
var concat = (...parts) => {
|
|
22
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
23
|
+
const out = new Uint8Array(total);
|
|
24
|
+
let offset = 0;
|
|
25
|
+
for (const part of parts) {
|
|
26
|
+
out.set(part, offset);
|
|
27
|
+
offset += part.length;
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
};
|
|
31
|
+
function base64(bytes) {
|
|
32
|
+
const url = toBase64Url(bytes);
|
|
33
|
+
const standard = url.replaceAll("-", "+").replaceAll("_", "/");
|
|
34
|
+
const padding = (4 - standard.length % 4) % 4;
|
|
35
|
+
return standard + "=".repeat(padding);
|
|
36
|
+
}
|
|
37
|
+
var unbase64 = (value) => fromBase64Url(value.replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""));
|
|
38
|
+
|
|
39
|
+
class OpenSshError extends Error {
|
|
40
|
+
code;
|
|
41
|
+
constructor(code, message) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.name = "OpenSshError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function publicKeyBlob(publicKey) {
|
|
48
|
+
if (publicKey.length !== 32) {
|
|
49
|
+
throw new OpenSshError("BAD_KEY", "An ed25519 public key is 32 bytes.");
|
|
50
|
+
}
|
|
51
|
+
return concat(sshString(ALGORITHM), sshString(publicKey));
|
|
52
|
+
}
|
|
53
|
+
function authorizedKey(publicKey, comment = "") {
|
|
54
|
+
if (/[\r\n]/.test(comment)) {
|
|
55
|
+
throw new OpenSshError("BAD_COMMENT", "A comment cannot contain a newline — in authorized_keys that ends the line and starts another entry.");
|
|
56
|
+
}
|
|
57
|
+
const line = `${ALGORITHM} ${base64(publicKeyBlob(publicKey))}`;
|
|
58
|
+
return comment.trim() ? `${line} ${comment.trim()}` : line;
|
|
59
|
+
}
|
|
60
|
+
function fingerprint(publicKey) {
|
|
61
|
+
return `SHA256:${base64(sha256(publicKeyBlob(publicKey))).replace(/=+$/, "")}`;
|
|
62
|
+
}
|
|
63
|
+
function signatureBlob(signature) {
|
|
64
|
+
if (signature.length !== 64) {
|
|
65
|
+
throw new OpenSshError("BAD_KEY", "An ed25519 signature is 64 bytes.");
|
|
66
|
+
}
|
|
67
|
+
return concat(sshString(ALGORITHM), sshString(signature));
|
|
68
|
+
}
|
|
69
|
+
function parseAuthorizedKey(line) {
|
|
70
|
+
const parts = line.trim().split(/\s+/);
|
|
71
|
+
if (parts.length < 2)
|
|
72
|
+
throw new OpenSshError("BAD_BLOB", "That is not an authorized_keys line.");
|
|
73
|
+
const [prefix, encoded, ...rest] = parts;
|
|
74
|
+
if (prefix !== ALGORITHM) {
|
|
75
|
+
throw new OpenSshError("BAD_BLOB", `Only ${ALGORITHM} is supported here, not ${prefix}.`);
|
|
76
|
+
}
|
|
77
|
+
let blob;
|
|
78
|
+
try {
|
|
79
|
+
blob = unbase64(encoded);
|
|
80
|
+
} catch {
|
|
81
|
+
throw new OpenSshError("BAD_BLOB", "The key body is not valid base64.");
|
|
82
|
+
}
|
|
83
|
+
if (blob.length !== 51)
|
|
84
|
+
throw new OpenSshError("BAD_BLOB", "That is not an ed25519 key blob.");
|
|
85
|
+
const view = new DataView(blob.buffer, blob.byteOffset, blob.byteLength);
|
|
86
|
+
const nameLength = view.getUint32(0, false);
|
|
87
|
+
const name = new TextDecoder().decode(blob.slice(4, 4 + nameLength));
|
|
88
|
+
if (name !== ALGORITHM) {
|
|
89
|
+
throw new OpenSshError("BAD_BLOB", `The blob says "${name}" while the line says "${prefix}". sshd reads the blob, so this key would be refused at login.`);
|
|
90
|
+
}
|
|
91
|
+
const keyLength = view.getUint32(4 + nameLength, false);
|
|
92
|
+
if (keyLength !== 32)
|
|
93
|
+
throw new OpenSshError("BAD_BLOB", "The embedded key is not 32 bytes.");
|
|
94
|
+
return {
|
|
95
|
+
publicKey: blob.slice(4 + nameLength + 4),
|
|
96
|
+
comment: rest.join(" ")
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
export {
|
|
100
|
+
signatureBlob,
|
|
101
|
+
publicKeyBlob,
|
|
102
|
+
parseAuthorizedKey,
|
|
103
|
+
fingerprint,
|
|
104
|
+
authorizedKey,
|
|
105
|
+
OpenSshError
|
|
106
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `otpauth://` URI, which is what a QR code actually contains.
|
|
3
|
+
*
|
|
4
|
+
* Every site that offers "scan this" encodes the same thing: a secret, who it
|
|
5
|
+
* belongs to, and the parameters needed to reproduce a code. Nothing about it
|
|
6
|
+
* is standardised beyond a Google wiki page, so implementations disagree at the
|
|
7
|
+
* edges — and each disagreement is a code that comes out wrong for one issuer
|
|
8
|
+
* and right for every other, which is the hardest kind of bug to be told about.
|
|
9
|
+
*
|
|
10
|
+
* So this parses strictly and refuses what it cannot reproduce, rather than
|
|
11
|
+
* guessing a default and generating six digits that will never be accepted.
|
|
12
|
+
*
|
|
13
|
+
* ## The label is not the issuer, except when it is
|
|
14
|
+
*
|
|
15
|
+
* otpauth://totp/GitHub:alice@example.com?issuer=GitHub&secret=...
|
|
16
|
+
*
|
|
17
|
+
* The prefix before the colon and the `issuer` parameter are the same claim
|
|
18
|
+
* made twice, and they DISAGREE in the wild. The parameter wins — it is
|
|
19
|
+
* unambiguous, while the label prefix cannot be told apart from an account name
|
|
20
|
+
* that happens to contain a colon.
|
|
21
|
+
*/
|
|
22
|
+
export interface OtpAuth {
|
|
23
|
+
/** `totp`. `hotp` is counter-based and deliberately unsupported. */
|
|
24
|
+
type: 'totp';
|
|
25
|
+
/** Who issued it — "GitHub", "AWS". */
|
|
26
|
+
issuer: string;
|
|
27
|
+
/** The account at that issuer. May be empty; not every issuer sets one. */
|
|
28
|
+
account: string;
|
|
29
|
+
/** Base32, no padding, upper case. */
|
|
30
|
+
secret: string;
|
|
31
|
+
digits: number;
|
|
32
|
+
stepSeconds: number;
|
|
33
|
+
algorithm: 'SHA-1' | 'SHA-256' | 'SHA-512';
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Parse, or throw.
|
|
37
|
+
*
|
|
38
|
+
* There is no partial success. A URI missing its secret, or carrying digits we
|
|
39
|
+
* cannot produce, yields nothing usable — and storing it would mean an entry
|
|
40
|
+
* that looks enrolled and can never sign anybody in.
|
|
41
|
+
*/
|
|
42
|
+
export declare function parseOtpAuth(raw: string): OtpAuth;
|
|
43
|
+
/**
|
|
44
|
+
* Back to a URI, for handing to another authenticator.
|
|
45
|
+
*
|
|
46
|
+
* A vault somebody cannot leave is a vault they should not have joined, and for
|
|
47
|
+
* TOTP that means being able to export what was imported.
|
|
48
|
+
*
|
|
49
|
+
* The issuer appears TWICE — as the label prefix and as the parameter — because
|
|
50
|
+
* older authenticators read only one of the two, and which one is not knowable
|
|
51
|
+
* from here.
|
|
52
|
+
*/
|
|
53
|
+
export declare function formatOtpAuth(entry: OtpAuth): string;
|
|
54
|
+
/** A secret somebody typed by hand, rather than scanned. */
|
|
55
|
+
export declare function fromManualSecret(secret: string, issuer?: string, account?: string): OtpAuth;
|
|
56
|
+
/** Round-trip a secret we generated ourselves, for enrolling a NEW account. */
|
|
57
|
+
export declare const newOtpAuth: (secretBytes: Uint8Array, issuer: string, account: string) => OtpAuth;
|
package/dist/otpauth.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
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/totp.ts
|
|
10
|
+
import { timingSafeEqual } from "@forgezero/access/security";
|
|
11
|
+
|
|
12
|
+
class TotpError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.name = "TotpError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
21
|
+
function toBase32(bytes) {
|
|
22
|
+
let bits = 0;
|
|
23
|
+
let value = 0;
|
|
24
|
+
let output = "";
|
|
25
|
+
for (const byte of bytes) {
|
|
26
|
+
value = value << 8 | byte;
|
|
27
|
+
bits += 8;
|
|
28
|
+
while (bits >= 5) {
|
|
29
|
+
output += ALPHABET[value >>> bits - 5 & 31];
|
|
30
|
+
bits -= 5;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (bits > 0)
|
|
34
|
+
output += ALPHABET[value << 5 - bits & 31];
|
|
35
|
+
return output;
|
|
36
|
+
}
|
|
37
|
+
function fromBase32(text) {
|
|
38
|
+
const cleaned = text.toUpperCase().replace(/[\s=-]/g, "");
|
|
39
|
+
if (cleaned.length === 0)
|
|
40
|
+
throw new TotpError("BAD_SECRET", "The secret is empty.");
|
|
41
|
+
let bits = 0;
|
|
42
|
+
let value = 0;
|
|
43
|
+
const bytes = [];
|
|
44
|
+
for (const character of cleaned) {
|
|
45
|
+
const index = ALPHABET.indexOf(character);
|
|
46
|
+
if (index === -1) {
|
|
47
|
+
throw new TotpError("BAD_SECRET", `"${character}" is not a base32 character.`);
|
|
48
|
+
}
|
|
49
|
+
value = value << 5 | index;
|
|
50
|
+
bits += 5;
|
|
51
|
+
if (bits >= 8) {
|
|
52
|
+
bytes.push(value >>> bits - 8 & 255);
|
|
53
|
+
bits -= 8;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return new Uint8Array(bytes);
|
|
57
|
+
}
|
|
58
|
+
function generateSecret(bytes = 20) {
|
|
59
|
+
const buffer = new Uint8Array(bytes);
|
|
60
|
+
crypto.getRandomValues(buffer);
|
|
61
|
+
return toBase32(buffer);
|
|
62
|
+
}
|
|
63
|
+
var DEFAULT_STEP_SECONDS = 30;
|
|
64
|
+
var DEFAULT_DIGITS = 6;
|
|
65
|
+
var counterAt = (atMs, stepSeconds = DEFAULT_STEP_SECONDS) => Math.floor(atMs / 1000 / stepSeconds);
|
|
66
|
+
async function hmac(secret, counter, algorithm) {
|
|
67
|
+
const message = new Uint8Array(8);
|
|
68
|
+
let remaining = counter;
|
|
69
|
+
for (let index = 7;index >= 0; index -= 1) {
|
|
70
|
+
message[index] = remaining & 255;
|
|
71
|
+
remaining = Math.floor(remaining / 256);
|
|
72
|
+
}
|
|
73
|
+
const key = await crypto.subtle.importKey("raw", secret, { name: "HMAC", hash: algorithm }, false, ["sign"]);
|
|
74
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", key, message));
|
|
75
|
+
}
|
|
76
|
+
async function codeFor(secret, counter, options = {}) {
|
|
77
|
+
const digits = options.digits ?? DEFAULT_DIGITS;
|
|
78
|
+
if (digits < 6 || digits > 8) {
|
|
79
|
+
throw new TotpError("BAD_DIGITS", "A TOTP code is between six and eight digits.");
|
|
80
|
+
}
|
|
81
|
+
const mac = await hmac(fromBase32(secret), counter, options.algorithm ?? "SHA-1");
|
|
82
|
+
const offset = mac[mac.length - 1] & 15;
|
|
83
|
+
const binary = (mac[offset] & 127) << 24 | (mac[offset + 1] & 255) << 16 | (mac[offset + 2] & 255) << 8 | mac[offset + 3] & 255;
|
|
84
|
+
return String(binary % 10 ** digits).padStart(digits, "0");
|
|
85
|
+
}
|
|
86
|
+
var codeAt = (secret, atMs, options = {}) => codeFor(secret, counterAt(atMs, options.stepSeconds ?? DEFAULT_STEP_SECONDS), options);
|
|
87
|
+
async function verifyCode(secret, code, options = {}) {
|
|
88
|
+
const digits = options.digits ?? DEFAULT_DIGITS;
|
|
89
|
+
const cleaned = code.replace(/\s/g, "");
|
|
90
|
+
if (!new RegExp(`^\\d{${digits}}$`).test(cleaned)) {
|
|
91
|
+
return { ok: false, reason: "BAD_CODE" };
|
|
92
|
+
}
|
|
93
|
+
const step = options.stepSeconds ?? DEFAULT_STEP_SECONDS;
|
|
94
|
+
const window = options.window ?? 1;
|
|
95
|
+
const current = counterAt(options.atMs ?? Date.now(), step);
|
|
96
|
+
let matched;
|
|
97
|
+
for (let drift = -window;drift <= window; drift += 1) {
|
|
98
|
+
const counter = current + drift;
|
|
99
|
+
const expected = await codeFor(secret, counter, options);
|
|
100
|
+
if (timingSafeEqual(expected, cleaned))
|
|
101
|
+
matched = counter;
|
|
102
|
+
}
|
|
103
|
+
if (matched === undefined)
|
|
104
|
+
return { ok: false, reason: "BAD_CODE" };
|
|
105
|
+
if (options.lastCounter !== undefined && matched <= options.lastCounter) {
|
|
106
|
+
return { ok: false, counter: matched, reason: "REPLAYED" };
|
|
107
|
+
}
|
|
108
|
+
return { ok: true, counter: matched };
|
|
109
|
+
}
|
|
110
|
+
async function assertCode(secret, code, options = {}) {
|
|
111
|
+
const result = await verifyCode(secret, code, options);
|
|
112
|
+
if (!result.ok) {
|
|
113
|
+
throw new TotpError(result.reason ?? "BAD_CODE", result.reason === "REPLAYED" ? "That code has already been used. Wait for the next one." : "That code is not right. Check your authenticator and try again.");
|
|
114
|
+
}
|
|
115
|
+
return result.counter;
|
|
116
|
+
}
|
|
117
|
+
function enrolmentUri(args) {
|
|
118
|
+
const label = `${encodeURIComponent(args.issuer)}:${encodeURIComponent(args.account)}`;
|
|
119
|
+
const parameters = new URLSearchParams({
|
|
120
|
+
secret: args.secret,
|
|
121
|
+
issuer: args.issuer,
|
|
122
|
+
algorithm: (args.options?.algorithm ?? "SHA-1").replace("-", ""),
|
|
123
|
+
digits: String(args.options?.digits ?? DEFAULT_DIGITS),
|
|
124
|
+
period: String(args.options?.stepSeconds ?? DEFAULT_STEP_SECONDS)
|
|
125
|
+
});
|
|
126
|
+
return `otpauth://totp/${label}?${parameters.toString()}`;
|
|
127
|
+
}
|
|
128
|
+
var secondsRemaining = (atMs = Date.now(), stepSeconds = DEFAULT_STEP_SECONDS) => stepSeconds - Math.floor(atMs / 1000) % stepSeconds;
|
|
129
|
+
|
|
130
|
+
// src/otpauth.ts
|
|
131
|
+
var ALGORITHMS = {
|
|
132
|
+
SHA1: "SHA-1",
|
|
133
|
+
SHA256: "SHA-256",
|
|
134
|
+
SHA512: "SHA-512"
|
|
135
|
+
};
|
|
136
|
+
function parseOtpAuth(raw) {
|
|
137
|
+
let url;
|
|
138
|
+
try {
|
|
139
|
+
url = new URL(raw.trim());
|
|
140
|
+
} catch {
|
|
141
|
+
throw new TotpError("OTPAUTH_MALFORMED", "That is not an otpauth:// URI.");
|
|
142
|
+
}
|
|
143
|
+
if (url.protocol !== "otpauth:") {
|
|
144
|
+
throw new TotpError("OTPAUTH_MALFORMED", "That is not an otpauth:// URI.");
|
|
145
|
+
}
|
|
146
|
+
const afterScheme = raw.trim().slice("otpauth://".length);
|
|
147
|
+
const [typeAndLabel = "", ...rest] = afterScheme.split("?");
|
|
148
|
+
const query = new URLSearchParams(rest.join("?"));
|
|
149
|
+
const slash = typeAndLabel.indexOf("/");
|
|
150
|
+
const type = (slash === -1 ? typeAndLabel : typeAndLabel.slice(0, slash)).toLowerCase();
|
|
151
|
+
const label = decodeURIComponent(slash === -1 ? "" : typeAndLabel.slice(slash + 1));
|
|
152
|
+
if (type === "hotp") {
|
|
153
|
+
throw new TotpError("OTPAUTH_UNSUPPORTED", "Counter-based (hotp) codes are not supported.");
|
|
154
|
+
}
|
|
155
|
+
if (type !== "totp") {
|
|
156
|
+
throw new TotpError("OTPAUTH_UNSUPPORTED", `"${type}" is not a kind of code we generate.`);
|
|
157
|
+
}
|
|
158
|
+
const secret = (query.get("secret") ?? "").replace(/\s|=/g, "").toUpperCase();
|
|
159
|
+
if (!secret)
|
|
160
|
+
throw new TotpError("OTPAUTH_NO_SECRET", "The URI carries no secret.");
|
|
161
|
+
fromBase32(secret);
|
|
162
|
+
const colon = label.indexOf(":");
|
|
163
|
+
const labelIssuer = colon === -1 ? "" : label.slice(0, colon).trim();
|
|
164
|
+
const issuer = (query.get("issuer") ?? labelIssuer).trim();
|
|
165
|
+
const account = (colon === -1 ? label : label.slice(colon + 1)).trim();
|
|
166
|
+
const digits = Number(query.get("digits") ?? DEFAULT_DIGITS);
|
|
167
|
+
if (![6, 7, 8].includes(digits)) {
|
|
168
|
+
throw new TotpError("OTPAUTH_UNSUPPORTED", `${digits}-digit codes are not supported.`);
|
|
169
|
+
}
|
|
170
|
+
const stepSeconds = Number(query.get("period") ?? DEFAULT_STEP_SECONDS);
|
|
171
|
+
if (!Number.isInteger(stepSeconds) || stepSeconds < 15 || stepSeconds > 300) {
|
|
172
|
+
throw new TotpError("OTPAUTH_UNSUPPORTED", `A ${stepSeconds}s period is out of range.`);
|
|
173
|
+
}
|
|
174
|
+
const rawAlgorithm = (query.get("algorithm") ?? "SHA1").toUpperCase().replace("-", "");
|
|
175
|
+
const algorithm = ALGORITHMS[rawAlgorithm];
|
|
176
|
+
if (!algorithm) {
|
|
177
|
+
throw new TotpError("OTPAUTH_UNSUPPORTED", `${rawAlgorithm} is not an algorithm we support.`);
|
|
178
|
+
}
|
|
179
|
+
return { type: "totp", issuer, account, secret, digits, stepSeconds, algorithm };
|
|
180
|
+
}
|
|
181
|
+
function formatOtpAuth(entry) {
|
|
182
|
+
const label = entry.issuer ? `${encodeURIComponent(entry.issuer)}:${encodeURIComponent(entry.account)}` : encodeURIComponent(entry.account);
|
|
183
|
+
const query = new URLSearchParams({ secret: entry.secret });
|
|
184
|
+
if (entry.issuer)
|
|
185
|
+
query.set("issuer", entry.issuer);
|
|
186
|
+
if (entry.digits !== DEFAULT_DIGITS)
|
|
187
|
+
query.set("digits", String(entry.digits));
|
|
188
|
+
if (entry.stepSeconds !== DEFAULT_STEP_SECONDS)
|
|
189
|
+
query.set("period", String(entry.stepSeconds));
|
|
190
|
+
if (entry.algorithm !== "SHA-1")
|
|
191
|
+
query.set("algorithm", entry.algorithm.replace("-", ""));
|
|
192
|
+
return `otpauth://totp/${label}?${query.toString()}`;
|
|
193
|
+
}
|
|
194
|
+
function fromManualSecret(secret, issuer = "", account = "") {
|
|
195
|
+
const cleaned = secret.replace(/\s|=/g, "").toUpperCase();
|
|
196
|
+
if (!cleaned)
|
|
197
|
+
throw new TotpError("OTPAUTH_NO_SECRET", "Enter the secret the site showed you.");
|
|
198
|
+
fromBase32(cleaned);
|
|
199
|
+
return {
|
|
200
|
+
type: "totp",
|
|
201
|
+
issuer: issuer.trim(),
|
|
202
|
+
account: account.trim(),
|
|
203
|
+
secret: cleaned,
|
|
204
|
+
digits: DEFAULT_DIGITS,
|
|
205
|
+
stepSeconds: DEFAULT_STEP_SECONDS,
|
|
206
|
+
algorithm: "SHA-1"
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
var newOtpAuth = (secretBytes, issuer, account) => ({
|
|
210
|
+
type: "totp",
|
|
211
|
+
issuer,
|
|
212
|
+
account,
|
|
213
|
+
secret: toBase32(secretBytes),
|
|
214
|
+
digits: DEFAULT_DIGITS,
|
|
215
|
+
stepSeconds: DEFAULT_STEP_SECONDS,
|
|
216
|
+
algorithm: "SHA-1"
|
|
217
|
+
});
|
|
218
|
+
export {
|
|
219
|
+
parseOtpAuth,
|
|
220
|
+
newOtpAuth,
|
|
221
|
+
fromManualSecret,
|
|
222
|
+
formatOtpAuth
|
|
223
|
+
};
|
package/dist/outbox.d.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write the event with the record; deliver it afterwards.
|
|
3
|
+
*
|
|
4
|
+
* `emit` in `@forgezero/access/effects` is only as reliable as the thing it
|
|
5
|
+
* publishes into. Sending straight from the handler loses the event whenever the
|
|
6
|
+
* process dies between the commit and the send — and that window is not
|
|
7
|
+
* theoretical: it is exactly where a deploy, an OOM kill or a failover lands,
|
|
8
|
+
* which is to say the moments somebody is watching.
|
|
9
|
+
*
|
|
10
|
+
* The inversion is the whole idea. Instead of
|
|
11
|
+
*
|
|
12
|
+
* commit the record → send the event (event lost if we die here)
|
|
13
|
+
*
|
|
14
|
+
* do
|
|
15
|
+
*
|
|
16
|
+
* commit the record AND the event together → send later, retrying
|
|
17
|
+
*
|
|
18
|
+
* The event is a row written by the same transaction that wrote the record, so
|
|
19
|
+
* either both exist or neither does. Delivery becomes a separate, restartable
|
|
20
|
+
* problem rather than a thing that had one chance to work.
|
|
21
|
+
*
|
|
22
|
+
* ## What this guarantees, exactly
|
|
23
|
+
*
|
|
24
|
+
* **At least once.** Not exactly once. A delivery can succeed and the write that
|
|
25
|
+
* marks it delivered can fail immediately afterwards, and the event is then sent
|
|
26
|
+
* again. There is no arrangement of two systems that avoids this — the
|
|
27
|
+
* acknowledgement is itself a message that can be lost.
|
|
28
|
+
*
|
|
29
|
+
* So consumers must be idempotent, and every event carries an `id` stable across
|
|
30
|
+
* redeliveries to make that possible. A package claiming exactly-once here would
|
|
31
|
+
* be claiming to have solved the two-generals problem, and the practical result
|
|
32
|
+
* of believing it is a double credit.
|
|
33
|
+
*
|
|
34
|
+
* ## Order is per key, not global
|
|
35
|
+
*
|
|
36
|
+
* A global order means one delivery at a time and throughput collapses. No order
|
|
37
|
+
* at all means `entry.updated` can overtake `entry.created`, and a consumer sees
|
|
38
|
+
* a change to something it has never heard of. Events therefore carry an
|
|
39
|
+
* optional `key` — an aggregate id — and delivery is strictly sequential within
|
|
40
|
+
* one key and fully parallel across keys. Same shape as `@forgezero/runtime/queue`, for
|
|
41
|
+
* the same reason.
|
|
42
|
+
*/
|
|
43
|
+
export declare class OutboxError extends Error {
|
|
44
|
+
readonly code: 'NO_TRANSPORT' | 'NOT_CLAIMED' | 'UNKNOWN_EVENT';
|
|
45
|
+
constructor(code: 'NO_TRANSPORT' | 'NOT_CLAIMED' | 'UNKNOWN_EVENT', message: string);
|
|
46
|
+
}
|
|
47
|
+
export declare const EVENT_STATES: readonly ["pending", "delivering", "delivered", "dead"];
|
|
48
|
+
export type EventState = (typeof EVENT_STATES)[number];
|
|
49
|
+
export interface OutboxEvent {
|
|
50
|
+
id: string;
|
|
51
|
+
type: string;
|
|
52
|
+
/** The aggregate this concerns. Events sharing one are delivered in order. */
|
|
53
|
+
key?: string;
|
|
54
|
+
route?: string;
|
|
55
|
+
realm?: string;
|
|
56
|
+
payload: Record<string, unknown>;
|
|
57
|
+
state: EventState;
|
|
58
|
+
attempts: number;
|
|
59
|
+
/** Not eligible for delivery before this. Set by backoff after a failure. */
|
|
60
|
+
nextAttemptAtMs: number;
|
|
61
|
+
/** Set while a drainer holds it. A stale claim is reclaimable — see `claim`. */
|
|
62
|
+
claimedBy?: string;
|
|
63
|
+
claimedUntilMs?: number;
|
|
64
|
+
lastError?: string;
|
|
65
|
+
createdAtMs: number;
|
|
66
|
+
deliveredAtMs?: number;
|
|
67
|
+
}
|
|
68
|
+
/** What a caller publishes. Everything else is filled in. */
|
|
69
|
+
export interface PublishInput {
|
|
70
|
+
type: string;
|
|
71
|
+
key?: string;
|
|
72
|
+
route?: string;
|
|
73
|
+
realm?: string;
|
|
74
|
+
payload: Record<string, unknown>;
|
|
75
|
+
/**
|
|
76
|
+
* Supply this to make publishing idempotent.
|
|
77
|
+
*
|
|
78
|
+
* A handler that retries its own transaction would otherwise insert the event
|
|
79
|
+
* twice. With a stable id the second insert collides and is ignored, which is
|
|
80
|
+
* the behaviour every caller assumes they already have.
|
|
81
|
+
*/
|
|
82
|
+
id?: string;
|
|
83
|
+
/** Hold the event back until a time — a reminder, a scheduled notice. */
|
|
84
|
+
notBeforeMs?: number;
|
|
85
|
+
}
|
|
86
|
+
export interface DeliveryPolicy {
|
|
87
|
+
/** Attempts before the event is dead-lettered. */
|
|
88
|
+
maxAttempts: number;
|
|
89
|
+
/** First retry delay. Doubles each attempt, capped. */
|
|
90
|
+
baseDelayMs: number;
|
|
91
|
+
maxDelayMs: number;
|
|
92
|
+
/**
|
|
93
|
+
* Random fraction added to each delay, 0–1.
|
|
94
|
+
*
|
|
95
|
+
* Without it, a relay that was down for a minute gets every event that failed
|
|
96
|
+
* during the outage retried in the same millisecond, and goes down again. The
|
|
97
|
+
* failure mode is self-inflicted and completely avoidable.
|
|
98
|
+
*/
|
|
99
|
+
jitter: number;
|
|
100
|
+
/** How long a claim is honoured before another drainer may take the event. */
|
|
101
|
+
claimTtlMs: number;
|
|
102
|
+
}
|
|
103
|
+
export declare const DEFAULT_POLICY: DeliveryPolicy;
|
|
104
|
+
/**
|
|
105
|
+
* Exponential backoff with jitter.
|
|
106
|
+
*
|
|
107
|
+
* `random` is injectable so a test can assert the shape of the curve rather than
|
|
108
|
+
* a range — a bounds-only assertion passes for an implementation that returns
|
|
109
|
+
* the cap every time.
|
|
110
|
+
*/
|
|
111
|
+
export declare function backoffMs(attempts: number, policy?: DeliveryPolicy, random?: () => number): number;
|
|
112
|
+
/**
|
|
113
|
+
* Persistence.
|
|
114
|
+
*
|
|
115
|
+
* `insert` is the load-bearing method: it must run inside the SAME transaction
|
|
116
|
+
* as the business write. That is why the store is injected rather than opened
|
|
117
|
+
* here — only the caller knows what transaction is open, and an outbox that
|
|
118
|
+
* opens its own connection has thrown away the one property it exists for.
|
|
119
|
+
*/
|
|
120
|
+
export interface OutboxStore {
|
|
121
|
+
/** Returns false if an event with this id already exists. Never throws on collision. */
|
|
122
|
+
insert(event: OutboxEvent): Promise<boolean>;
|
|
123
|
+
/**
|
|
124
|
+
* Take up to `limit` deliverable events and mark them claimed.
|
|
125
|
+
*
|
|
126
|
+
* Must not return two events sharing a `key` in one batch, and must not
|
|
127
|
+
* return an event whose key has an older undelivered event — that is what
|
|
128
|
+
* makes per-key order hold. Must reclaim events whose `claimedUntilMs` has
|
|
129
|
+
* passed, or a drainer that crashes mid-batch strands them forever.
|
|
130
|
+
*/
|
|
131
|
+
claim(args: {
|
|
132
|
+
drainerId: string;
|
|
133
|
+
limit: number;
|
|
134
|
+
nowMs: number;
|
|
135
|
+
claimTtlMs: number;
|
|
136
|
+
}): Promise<OutboxEvent[]>;
|
|
137
|
+
settle(id: string, patch: Partial<OutboxEvent>): Promise<void>;
|
|
138
|
+
byState(state: EventState, limit?: number): Promise<OutboxEvent[]>;
|
|
139
|
+
get(id: string): Promise<OutboxEvent | null>;
|
|
140
|
+
}
|
|
141
|
+
export interface DeliveryResult {
|
|
142
|
+
ok: boolean;
|
|
143
|
+
/** A failure that will never succeed — a malformed payload, a 400. */
|
|
144
|
+
terminal?: boolean;
|
|
145
|
+
error?: string;
|
|
146
|
+
}
|
|
147
|
+
export interface Transport {
|
|
148
|
+
deliver(event: OutboxEvent): Promise<DeliveryResult>;
|
|
149
|
+
}
|
|
150
|
+
export interface OutboxOptions {
|
|
151
|
+
store: OutboxStore;
|
|
152
|
+
transport: Transport;
|
|
153
|
+
policy?: Partial<DeliveryPolicy>;
|
|
154
|
+
/** Identifies this drainer in a claim. Must differ per process. */
|
|
155
|
+
drainerId?: string;
|
|
156
|
+
now?: () => number;
|
|
157
|
+
random?: () => number;
|
|
158
|
+
/** Called when an event exhausts its attempts. The one hook worth wiring. */
|
|
159
|
+
onDeadLetter?: (event: OutboxEvent) => void;
|
|
160
|
+
}
|
|
161
|
+
export interface DrainReport {
|
|
162
|
+
claimed: number;
|
|
163
|
+
delivered: number;
|
|
164
|
+
failed: number;
|
|
165
|
+
dead: number;
|
|
166
|
+
}
|
|
167
|
+
export declare function createOutbox(options: OutboxOptions): {
|
|
168
|
+
/**
|
|
169
|
+
* Record an event. Call this INSIDE the transaction that writes the record.
|
|
170
|
+
*
|
|
171
|
+
* Nothing is sent here. That is the point — the send is what can fail, and
|
|
172
|
+
* moving it out of the request means the request cannot fail because of it.
|
|
173
|
+
*/
|
|
174
|
+
publish(input: PublishInput): Promise<OutboxEvent>;
|
|
175
|
+
/**
|
|
176
|
+
* Deliver a batch.
|
|
177
|
+
*
|
|
178
|
+
* Events are delivered concurrently because the store guarantees no two in
|
|
179
|
+
* one batch share a key — so parallelism here cannot reorder anything. That
|
|
180
|
+
* guarantee is the store's, and `memoryStore` below implements it exactly
|
|
181
|
+
* so a test cannot pass against a weaker one.
|
|
182
|
+
*/
|
|
183
|
+
drain(limit?: number): Promise<DrainReport>;
|
|
184
|
+
/** Everything that gave up. The queue somebody has to actually look at. */
|
|
185
|
+
deadLetter: (limit?: number) => Promise<OutboxEvent[]>;
|
|
186
|
+
/**
|
|
187
|
+
* Put a dead event back in the queue.
|
|
188
|
+
*
|
|
189
|
+
* Attempts reset, because the reason it died has usually been fixed by hand
|
|
190
|
+
* and starting at attempt eight would dead-letter it again on the first
|
|
191
|
+
* hiccup.
|
|
192
|
+
*/
|
|
193
|
+
retry(id: string): Promise<void>;
|
|
194
|
+
pending: (limit?: number) => Promise<OutboxEvent[]>;
|
|
195
|
+
drainerId: string;
|
|
196
|
+
};
|
|
197
|
+
export type Outbox = ReturnType<typeof createOutbox>;
|
|
198
|
+
/**
|
|
199
|
+
* The outbox as a job, for `@forgezero/runtime/jobs`.
|
|
200
|
+
*
|
|
201
|
+
* Returned as a plain spec rather than importing `defineJob`, so this package
|
|
202
|
+
* does not take a dependency on `jobs` for one object literal — and so a caller
|
|
203
|
+
* running its own loop is not forced to adopt a scheduler.
|
|
204
|
+
*/
|
|
205
|
+
export declare function outboxJob(outbox: Outbox, options?: {
|
|
206
|
+
key?: string;
|
|
207
|
+
everyMs?: number;
|
|
208
|
+
batch?: number;
|
|
209
|
+
}): {
|
|
210
|
+
key: string;
|
|
211
|
+
everyMs: number;
|
|
212
|
+
run: () => Promise<{
|
|
213
|
+
ok: boolean;
|
|
214
|
+
detail: {
|
|
215
|
+
claimed: number;
|
|
216
|
+
delivered: number;
|
|
217
|
+
failed: number;
|
|
218
|
+
dead: number;
|
|
219
|
+
};
|
|
220
|
+
}>;
|
|
221
|
+
};
|
|
222
|
+
/**
|
|
223
|
+
* An in-memory store that enforces every constraint a real one must.
|
|
224
|
+
*
|
|
225
|
+
* Specifically the per-key ordering rule: a test that passes against a store
|
|
226
|
+
* which happily returns two events for one key would pass here and reorder in
|
|
227
|
+
* production. Encoding the constraint in the reference implementation is the
|
|
228
|
+
* only way the interface documentation is load-bearing.
|
|
229
|
+
*/
|
|
230
|
+
export declare function memoryStore(): OutboxStore & {
|
|
231
|
+
all(): OutboxEvent[];
|
|
232
|
+
clear(): void;
|
|
233
|
+
};
|
|
234
|
+
export declare const VERSION = "0.1.0";
|