@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,254 @@
|
|
|
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/notify.ts
|
|
10
|
+
class NotifyError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = "NotifyError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
var CHANNELS = ["email"];
|
|
19
|
+
function escapeHtml(value) {
|
|
20
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
21
|
+
}
|
|
22
|
+
function assertHeaderSafe(value, field) {
|
|
23
|
+
if (/[\r\n]/.test(value)) {
|
|
24
|
+
throw new NotifyError("HEADER_INJECTION", `${field} contains a line break. A newline in a header ends it and starts another.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function defineTemplate(spec) {
|
|
28
|
+
return spec;
|
|
29
|
+
}
|
|
30
|
+
function render(spec, data) {
|
|
31
|
+
const missing = spec.data.filter((key) => data[key] === undefined || data[key] === null);
|
|
32
|
+
if (missing.length > 0) {
|
|
33
|
+
throw new NotifyError("MISSING_DATA", `Template "${spec.key}" needs ${missing.join(", ")}. Rendering without them would send a message with a blank where the value should be.`);
|
|
34
|
+
}
|
|
35
|
+
const raw = Object.fromEntries(spec.data.map((key) => [key, String(data[key])]));
|
|
36
|
+
const escaped = Object.fromEntries(spec.data.map((key) => [key, escapeHtml(raw[key])]));
|
|
37
|
+
const subject = spec.subject(raw);
|
|
38
|
+
assertHeaderSafe(subject, `Subject of "${spec.key}"`);
|
|
39
|
+
return {
|
|
40
|
+
subject,
|
|
41
|
+
text: spec.text(raw),
|
|
42
|
+
html: spec.html(escaped)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function preview(spec) {
|
|
46
|
+
if (!spec.sample) {
|
|
47
|
+
throw new NotifyError("MISSING_DATA", `Template "${spec.key}" declares no sample to preview.`);
|
|
48
|
+
}
|
|
49
|
+
return render(spec, spec.sample);
|
|
50
|
+
}
|
|
51
|
+
function createNotifier(options) {
|
|
52
|
+
const registry = new Map;
|
|
53
|
+
for (const spec of options.templates) {
|
|
54
|
+
if (registry.has(spec.key)) {
|
|
55
|
+
throw new NotifyError("DUPLICATE_TEMPLATE", `Two templates share the key "${spec.key}".`);
|
|
56
|
+
}
|
|
57
|
+
registry.set(spec.key, spec);
|
|
58
|
+
}
|
|
59
|
+
async function deliver(notification) {
|
|
60
|
+
const spec = registry.get(notification.template);
|
|
61
|
+
if (!spec) {
|
|
62
|
+
throw new NotifyError("UNKNOWN_TEMPLATE", `No template named "${notification.template}".`);
|
|
63
|
+
}
|
|
64
|
+
assertHeaderSafe(notification.to, "Recipient");
|
|
65
|
+
if (!spec.transactional && options.allows) {
|
|
66
|
+
const allowed = await options.allows(notification.to, spec.key);
|
|
67
|
+
if (!allowed)
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const rendered = render(spec, notification.data);
|
|
71
|
+
await options.transport.send({ to: notification.to, ...rendered, realm: notification.realm });
|
|
72
|
+
return rendered;
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
templates: registry,
|
|
76
|
+
template: (key) => registry.get(key),
|
|
77
|
+
send(notification) {
|
|
78
|
+
deliver(notification).catch((error) => options.onError?.(error, notification));
|
|
79
|
+
},
|
|
80
|
+
sendOrThrow: (notification) => deliver(notification),
|
|
81
|
+
render: (template, data) => {
|
|
82
|
+
const spec = registry.get(template);
|
|
83
|
+
if (!spec)
|
|
84
|
+
throw new NotifyError("UNKNOWN_TEMPLATE", `No template named "${template}".`);
|
|
85
|
+
return render(spec, data);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function layout(args) {
|
|
90
|
+
const brand = escapeHtml(args.brand ?? "ForgeZero");
|
|
91
|
+
return `<!doctype html>
|
|
92
|
+
<html><body style="margin:0;padding:0;background:#0b0d10;color:#e6e8eb;font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif">
|
|
93
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#0b0d10;padding:32px 16px">
|
|
94
|
+
<tr><td align="center">
|
|
95
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:520px;background:#14171c;border:1px solid #232830;border-radius:12px">
|
|
96
|
+
<tr><td style="padding:28px 28px 8px">
|
|
97
|
+
<div style="font-size:13px;letter-spacing:.14em;text-transform:uppercase;color:#8b93a1">${brand}</div>
|
|
98
|
+
<h1 style="margin:12px 0 0;font-size:20px;line-height:1.3;font-weight:600;color:#f2f4f7">${args.title}</h1>
|
|
99
|
+
</td></tr>
|
|
100
|
+
<tr><td style="padding:12px 28px 28px;font-size:15px;line-height:1.6;color:#c3c9d4">${args.body}</td></tr>
|
|
101
|
+
</table>
|
|
102
|
+
${args.footer ? `<div style="max-width:520px;margin:16px auto 0;font-size:12px;line-height:1.5;color:#6b7280">${args.footer}</div>` : ""}
|
|
103
|
+
</td></tr></table>
|
|
104
|
+
</body></html>`;
|
|
105
|
+
}
|
|
106
|
+
var codeBlock = (code) => `<div style="margin:20px 0;padding:16px;background:#0b0d10;border:1px solid #232830;border-radius:8px;text-align:center;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:28px;letter-spacing:.32em;color:#f2f4f7">${code}</div>`;
|
|
107
|
+
var button = (label, href) => `<div style="margin:24px 0"><a href="${href}" style="display:inline-block;padding:12px 20px;background:#e6e8eb;color:#0b0d10;border-radius:8px;font-weight:600;font-size:15px;text-decoration:none">${label}</a></div>`;
|
|
108
|
+
var VERSION = "0.1.0";
|
|
109
|
+
|
|
110
|
+
// src/notify-templates.ts
|
|
111
|
+
var FOOTER = "ForgeZero sent this because somebody asked to. If it was not you, nothing has changed yet — but tell us.";
|
|
112
|
+
var signInCode = defineTemplate({
|
|
113
|
+
key: "auth.sign-in-code",
|
|
114
|
+
channel: "email",
|
|
115
|
+
transactional: true,
|
|
116
|
+
data: ["code", "minutes"],
|
|
117
|
+
sample: { code: "418302", minutes: "10" },
|
|
118
|
+
subject: (data) => `${data.code} is your ForgeZero sign-in code`,
|
|
119
|
+
text: (data) => `${data.code} is your sign-in code. It expires in ${data.minutes} minutes.
|
|
120
|
+
|
|
121
|
+
If you did not try to sign in, someone has your email address and is trying to use it. The code alone will not let them in.`,
|
|
122
|
+
html: (data) => layout({
|
|
123
|
+
title: "Your sign-in code",
|
|
124
|
+
body: codeBlock(data.code) + `<p style="margin:0">Expires in ${data.minutes} minutes.</p>` + `<p style="margin:16px 0 0;color:#8b93a1">If you did not try to sign in, someone has your email address and is trying to use it. The code alone will not let them in.</p>`,
|
|
125
|
+
footer: FOOTER
|
|
126
|
+
})
|
|
127
|
+
});
|
|
128
|
+
var invitation = defineTemplate({
|
|
129
|
+
key: "onboarding.invitation",
|
|
130
|
+
channel: "email",
|
|
131
|
+
transactional: true,
|
|
132
|
+
data: ["inviter", "organisation", "link", "days"],
|
|
133
|
+
sample: {
|
|
134
|
+
inviter: "Aravind",
|
|
135
|
+
organisation: "AltPilot",
|
|
136
|
+
link: "https://forgezero.net/invite?token=abc",
|
|
137
|
+
days: "7"
|
|
138
|
+
},
|
|
139
|
+
subject: (data) => `${data.inviter} invited you to ${data.organisation} on ForgeZero`,
|
|
140
|
+
text: (data) => `${data.inviter} invited you to join ${data.organisation} on ForgeZero.
|
|
141
|
+
|
|
142
|
+
${data.link}
|
|
143
|
+
|
|
144
|
+
The link works once and expires in ${data.days} days.`,
|
|
145
|
+
html: (data) => layout({
|
|
146
|
+
title: `Join ${data.organisation}`,
|
|
147
|
+
body: `<p style="margin:0">${data.inviter} invited you to ForgeZero.</p>` + button("Accept the invitation", data.link) + `<p style="margin:0;color:#8b93a1">This link works once and expires in ${data.days} days.</p>`,
|
|
148
|
+
footer: FOOTER
|
|
149
|
+
})
|
|
150
|
+
});
|
|
151
|
+
var custodianEnrolment = defineTemplate({
|
|
152
|
+
key: "custody.enrolment",
|
|
153
|
+
channel: "email",
|
|
154
|
+
transactional: true,
|
|
155
|
+
data: ["organisation", "link", "threshold", "total"],
|
|
156
|
+
sample: {
|
|
157
|
+
organisation: "ForgeZero",
|
|
158
|
+
link: "https://forgezero.net/custody/ceremony",
|
|
159
|
+
threshold: "2",
|
|
160
|
+
total: "3"
|
|
161
|
+
},
|
|
162
|
+
subject: () => "You have been named a custodian",
|
|
163
|
+
text: (data) => `You are one of ${data.total} custodians for ${data.organisation}. Any ${data.threshold} of you can unlock the vault; fewer cannot.
|
|
164
|
+
|
|
165
|
+
Enrol here: ${data.link}
|
|
166
|
+
|
|
167
|
+
You will create a passkey and write down a recovery phrase. Both open your share — either one is enough, so losing the passkey is not losing access.`,
|
|
168
|
+
html: (data) => layout({
|
|
169
|
+
title: "You have been named a custodian",
|
|
170
|
+
body: `<p style="margin:0">You are one of ${data.total} custodians for ${data.organisation}. Any ${data.threshold} of you together can unlock the vault; fewer cannot.</p>` + button("Enrol as a custodian", data.link) + `<p style="margin:0;color:#8b93a1">You will create a passkey and write down a recovery phrase. Either one opens your share, so losing the passkey does not lock you out.</p>`,
|
|
171
|
+
footer: FOOTER
|
|
172
|
+
})
|
|
173
|
+
});
|
|
174
|
+
var ceremonyProposed = defineTemplate({
|
|
175
|
+
key: "custody.ceremony-proposed",
|
|
176
|
+
channel: "email",
|
|
177
|
+
transactional: true,
|
|
178
|
+
data: ["proposer", "change", "link", "needed"],
|
|
179
|
+
sample: {
|
|
180
|
+
proposer: "Aravind",
|
|
181
|
+
change: "Add a fourth custodian",
|
|
182
|
+
link: "https://forgezero.net/custody/ceremony",
|
|
183
|
+
needed: "2"
|
|
184
|
+
},
|
|
185
|
+
subject: () => "A custodian change needs your approval",
|
|
186
|
+
text: (data) => `${data.proposer} proposed: ${data.change}
|
|
187
|
+
|
|
188
|
+
It needs ${data.needed} custodian approvals before it takes effect.
|
|
189
|
+
|
|
190
|
+
${data.link}
|
|
191
|
+
|
|
192
|
+
If you did not expect this, do not approve it. A change to the custodian set changes who can open the vault.`,
|
|
193
|
+
html: (data) => layout({
|
|
194
|
+
title: "A custodian change needs your approval",
|
|
195
|
+
body: `<p style="margin:0">${data.proposer} proposed:</p>` + `<p style="margin:12px 0;padding:12px;background:#0b0d10;border-left:2px solid #3d4757;color:#f2f4f7">${data.change}</p>` + `<p style="margin:0">It takes ${data.needed} approvals before it takes effect.</p>` + button("Review the proposal", data.link) + `<p style="margin:0;color:#8b93a1">If you did not expect this, do not approve it. A change to the custodian set changes who can open the vault.</p>`,
|
|
196
|
+
footer: FOOTER
|
|
197
|
+
})
|
|
198
|
+
});
|
|
199
|
+
var newDevice = defineTemplate({
|
|
200
|
+
key: "security.new-device",
|
|
201
|
+
channel: "email",
|
|
202
|
+
transactional: true,
|
|
203
|
+
data: ["device", "location", "at"],
|
|
204
|
+
sample: { device: "Chrome on Linux", location: "Chennai, IN", at: "2026-07-31 14:02 UTC" },
|
|
205
|
+
subject: () => "New sign-in to your ForgeZero account",
|
|
206
|
+
text: (data) => `A new device signed in.
|
|
207
|
+
|
|
208
|
+
${data.device}
|
|
209
|
+
${data.location}
|
|
210
|
+
${data.at}
|
|
211
|
+
|
|
212
|
+
If this was you, nothing to do. If not, sign in and revoke the session — then re-run the ceremony if you are a custodian.`,
|
|
213
|
+
html: (data) => layout({
|
|
214
|
+
title: "New sign-in to your account",
|
|
215
|
+
body: `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 16px;font-size:15px;color:#c3c9d4">` + `<tr><td style="padding:2px 16px 2px 0;color:#8b93a1">Device</td><td>${data.device}</td></tr>` + `<tr><td style="padding:2px 16px 2px 0;color:#8b93a1">Location</td><td>${data.location}</td></tr>` + `<tr><td style="padding:2px 16px 2px 0;color:#8b93a1">When</td><td>${data.at}</td></tr>` + `</table>` + `<p style="margin:0;color:#8b93a1">If this was not you, sign in and revoke the session. If you are a custodian, re-run the ceremony afterwards.</p>`,
|
|
216
|
+
footer: FOOTER
|
|
217
|
+
})
|
|
218
|
+
});
|
|
219
|
+
var vaultLocked = defineTemplate({
|
|
220
|
+
key: "custody.vault-locked",
|
|
221
|
+
channel: "email",
|
|
222
|
+
transactional: true,
|
|
223
|
+
data: ["reason", "threshold", "link"],
|
|
224
|
+
sample: { reason: "The API process restarted", threshold: "2", link: "https://forgezero.net/custody/unlock" },
|
|
225
|
+
subject: () => "The vault is locked",
|
|
226
|
+
text: (data) => `The vault is locked: ${data.reason}
|
|
227
|
+
|
|
228
|
+
Until ${data.threshold} custodians unlock it, secrets cannot be read and deployments that need them will fail.
|
|
229
|
+
|
|
230
|
+
${data.link}`,
|
|
231
|
+
html: (data) => layout({
|
|
232
|
+
title: "The vault is locked",
|
|
233
|
+
body: `<p style="margin:0">${data.reason}</p>` + `<p style="margin:16px 0 0">Until ${data.threshold} custodians unlock it, secrets cannot be read and any deployment that needs one will fail.</p>` + button("Unlock the vault", data.link),
|
|
234
|
+
footer: FOOTER
|
|
235
|
+
})
|
|
236
|
+
});
|
|
237
|
+
var TEMPLATES = [
|
|
238
|
+
signInCode,
|
|
239
|
+
invitation,
|
|
240
|
+
custodianEnrolment,
|
|
241
|
+
ceremonyProposed,
|
|
242
|
+
newDevice,
|
|
243
|
+
vaultLocked
|
|
244
|
+
];
|
|
245
|
+
export {
|
|
246
|
+
vaultLocked,
|
|
247
|
+
signInCode,
|
|
248
|
+
newDevice,
|
|
249
|
+
invitation,
|
|
250
|
+
escapeHtml,
|
|
251
|
+
custodianEnrolment,
|
|
252
|
+
ceremonyProposed,
|
|
253
|
+
TEMPLATES
|
|
254
|
+
};
|
package/dist/notify.d.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The message, as opposed to the transport.
|
|
3
|
+
*
|
|
4
|
+
* `@forgezero/providers/email` can deliver an email. It has nothing to say about
|
|
5
|
+
* what the email contains, and that gap is where the bugs live: templates
|
|
6
|
+
* written inline in handlers cannot be previewed, cannot be tested, and get
|
|
7
|
+
* their escaping wrong exactly once — after which a user with an apostrophe in
|
|
8
|
+
* their name receives mail addressed to `O'Brien`, or worse, a display name
|
|
9
|
+
* containing a `<script>` tag arrives intact.
|
|
10
|
+
*
|
|
11
|
+
* ## Three refusals that are the point of the package
|
|
12
|
+
*
|
|
13
|
+
* 1. **Missing data is an error, not an empty string.** A template that says
|
|
14
|
+
* "Your code is {{code}}" rendered without `code` must not send "Your code
|
|
15
|
+
* is ". That email is delivered, looks fine in the logs, and is useless. The
|
|
16
|
+
* render throws instead.
|
|
17
|
+
*
|
|
18
|
+
* 2. **Escaping depends on the part.** The same value is HTML-escaped in the
|
|
19
|
+
* HTML body and left alone in the text body. A single escaping pass applied
|
|
20
|
+
* to both is how plaintext mail ends up full of `&`.
|
|
21
|
+
*
|
|
22
|
+
* 3. **A newline in a subject is refused.** A CR or LF in a header value ends
|
|
23
|
+
* the header and starts another — a display name carried into a subject line
|
|
24
|
+
* is a header-injection vector, and the recipient list is the header an
|
|
25
|
+
* attacker wants.
|
|
26
|
+
*
|
|
27
|
+
* ## Why text as well as HTML
|
|
28
|
+
*
|
|
29
|
+
* Not politeness. Some clients render only text, and an HTML-only message is a
|
|
30
|
+
* strong spam signal at most large mailbox providers — the deliverability cost
|
|
31
|
+
* lands on exactly the messages that must arrive, which are the security ones.
|
|
32
|
+
* A template must therefore declare both, and the type system enforces it.
|
|
33
|
+
*/
|
|
34
|
+
export declare class NotifyError extends Error {
|
|
35
|
+
readonly code: 'UNKNOWN_TEMPLATE' | 'MISSING_DATA' | 'HEADER_INJECTION' | 'NO_CHANNEL' | 'DUPLICATE_TEMPLATE';
|
|
36
|
+
constructor(code: 'UNKNOWN_TEMPLATE' | 'MISSING_DATA' | 'HEADER_INJECTION' | 'NO_CHANNEL' | 'DUPLICATE_TEMPLATE', message: string);
|
|
37
|
+
}
|
|
38
|
+
export declare const CHANNELS: readonly ["email"];
|
|
39
|
+
export type Channel = (typeof CHANNELS)[number];
|
|
40
|
+
/**
|
|
41
|
+
* A rendered message, ready for a transport.
|
|
42
|
+
*
|
|
43
|
+
* Deliberately not an `EmailMessage`: this package renders, the provider sends,
|
|
44
|
+
* and keeping the shapes separate is what lets a second channel arrive without
|
|
45
|
+
* every template gaining email-specific fields.
|
|
46
|
+
*/
|
|
47
|
+
export interface Rendered {
|
|
48
|
+
subject: string;
|
|
49
|
+
html: string;
|
|
50
|
+
text: string;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* What a template declares.
|
|
54
|
+
*
|
|
55
|
+
* `data` is a list of the keys it requires. Naming them is what turns "renders
|
|
56
|
+
* an empty string" into a thrown error, and it is also what lets `preview()`
|
|
57
|
+
* produce a sample without a caller supplying one.
|
|
58
|
+
*/
|
|
59
|
+
export interface TemplateSpec<K extends string = string> {
|
|
60
|
+
key: string;
|
|
61
|
+
channel: Channel;
|
|
62
|
+
/** Keys the body needs. A render missing any of them is refused. */
|
|
63
|
+
data: readonly K[];
|
|
64
|
+
subject: (data: Record<K, string>) => string;
|
|
65
|
+
text: (data: Record<K, string>) => string;
|
|
66
|
+
html: (data: Record<K, string>) => string;
|
|
67
|
+
/** Sample values, so a template can be previewed and tested without a caller. */
|
|
68
|
+
sample?: Record<K, string>;
|
|
69
|
+
/**
|
|
70
|
+
* Mark a message as one a user cannot unsubscribe from.
|
|
71
|
+
*
|
|
72
|
+
* Security mail — a sign-in code, a new-device alert — is transactional and
|
|
73
|
+
* must ignore marketing preferences. Getting this backwards in either
|
|
74
|
+
* direction is a legal problem in one direction and a lockout in the other.
|
|
75
|
+
*/
|
|
76
|
+
transactional?: boolean;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Escape for HTML text content and double-quoted attributes.
|
|
80
|
+
*
|
|
81
|
+
* `&` first, or the ampersands introduced by the later replacements get escaped
|
|
82
|
+
* a second time and the output shows `&lt;`. This ordering bug is common
|
|
83
|
+
* enough that the test suite asserts against it directly.
|
|
84
|
+
*/
|
|
85
|
+
export declare function escapeHtml(value: string): string;
|
|
86
|
+
/**
|
|
87
|
+
* Refuse a value that would break out of a header.
|
|
88
|
+
*
|
|
89
|
+
* A bare CR or LF terminates the header and begins a new one, so a display name
|
|
90
|
+
* carried into a subject can add `Bcc:`. Sanitising by stripping would hide the
|
|
91
|
+
* attempt; throwing surfaces it where it can be logged.
|
|
92
|
+
*/
|
|
93
|
+
export declare function assertHeaderSafe(value: string, field: string): void;
|
|
94
|
+
export declare function defineTemplate<K extends string>(spec: TemplateSpec<K>): TemplateSpec<K>;
|
|
95
|
+
/**
|
|
96
|
+
* Render one template.
|
|
97
|
+
*
|
|
98
|
+
* Every required key is checked before any function runs, so the error names
|
|
99
|
+
* *all* the missing keys rather than only the first — a caller fixing one at a
|
|
100
|
+
* time across four round trips is a worse experience than one honest list.
|
|
101
|
+
*/
|
|
102
|
+
export declare function render<K extends string>(spec: TemplateSpec<K>, data: Partial<Record<K, string | number>>): Rendered;
|
|
103
|
+
/** Render with the sample values, for a preview screen or a snapshot test. */
|
|
104
|
+
export declare function preview<K extends string>(spec: TemplateSpec<K>): Rendered;
|
|
105
|
+
export interface Transport {
|
|
106
|
+
send(message: {
|
|
107
|
+
to: string;
|
|
108
|
+
subject: string;
|
|
109
|
+
html: string;
|
|
110
|
+
text: string;
|
|
111
|
+
realm?: string;
|
|
112
|
+
}): Promise<unknown>;
|
|
113
|
+
}
|
|
114
|
+
export interface NotifierOptions {
|
|
115
|
+
templates: readonly TemplateSpec<never>[] | readonly TemplateSpec<string>[];
|
|
116
|
+
transport: Transport;
|
|
117
|
+
/**
|
|
118
|
+
* Whether this recipient still wants non-transactional mail.
|
|
119
|
+
*
|
|
120
|
+
* Consulted only for templates that are not `transactional`, so a preference
|
|
121
|
+
* check can never suppress a sign-in code. The default allows everything,
|
|
122
|
+
* because a project without preferences should not have to model them.
|
|
123
|
+
*/
|
|
124
|
+
allows?: (to: string, template: string) => boolean | Promise<boolean>;
|
|
125
|
+
/** Delivery failures land here. Never thrown at the caller — see `send`. */
|
|
126
|
+
onError?: (error: unknown, notification: Notification) => void;
|
|
127
|
+
}
|
|
128
|
+
export interface Notification {
|
|
129
|
+
to: string;
|
|
130
|
+
template: string;
|
|
131
|
+
data: Record<string, unknown>;
|
|
132
|
+
realm?: string;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The `Notifier` face that `@forgezero/access/effects` expects, plus the parts
|
|
136
|
+
* a handler needs directly.
|
|
137
|
+
*
|
|
138
|
+
* `send` never rejects. It is called from an effect, and an effect that throws
|
|
139
|
+
* takes down a request that has already succeeded — the user's password is
|
|
140
|
+
* changed and they see a 500 because the confirmation email bounced.
|
|
141
|
+
* `sendOrThrow` exists for the handful of places where delivery IS the operation
|
|
142
|
+
* (sending a sign-in code and reporting success is a lie if it did not send).
|
|
143
|
+
*/
|
|
144
|
+
export declare function createNotifier(options: NotifierOptions): {
|
|
145
|
+
templates: Map<string, TemplateSpec<string>>;
|
|
146
|
+
template: (key: string) => TemplateSpec<string> | undefined;
|
|
147
|
+
/** Fire and forget. Reports through `onError`, never at the caller. */
|
|
148
|
+
send(notification: Notification): void;
|
|
149
|
+
/** For the cases where sending IS the operation and a failure must surface. */
|
|
150
|
+
sendOrThrow: (notification: Notification) => Promise<Rendered | null>;
|
|
151
|
+
/** Render without sending — a preview screen, or a test. */
|
|
152
|
+
render: (template: string, data: Record<string, unknown>) => Rendered;
|
|
153
|
+
};
|
|
154
|
+
export type Notifier = ReturnType<typeof createNotifier>;
|
|
155
|
+
/**
|
|
156
|
+
* One wrapper, so a template author writes the message and not the boilerplate.
|
|
157
|
+
*
|
|
158
|
+
* Table-based and inline-styled on purpose. Several widely used mail clients
|
|
159
|
+
* still strip `<style>` blocks and support neither flexbox nor grid, so the
|
|
160
|
+
* modern layout that looks correct in a browser preview arrives as a single
|
|
161
|
+
* unstyled column — and the preview is the only place anyone ever checks.
|
|
162
|
+
*/
|
|
163
|
+
export declare function layout(args: {
|
|
164
|
+
title: string;
|
|
165
|
+
body: string;
|
|
166
|
+
footer?: string;
|
|
167
|
+
brand?: string;
|
|
168
|
+
}): string;
|
|
169
|
+
/** A code shown as digits a human can read aloud without losing their place. */
|
|
170
|
+
export declare const codeBlock: (code: string) => string;
|
|
171
|
+
export declare const button: (label: string, href: string) => string;
|
|
172
|
+
export declare const VERSION = "0.1.0";
|
package/dist/notify.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
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/notify.ts
|
|
10
|
+
class NotifyError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = "NotifyError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
var CHANNELS = ["email"];
|
|
19
|
+
function escapeHtml(value) {
|
|
20
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
21
|
+
}
|
|
22
|
+
function assertHeaderSafe(value, field) {
|
|
23
|
+
if (/[\r\n]/.test(value)) {
|
|
24
|
+
throw new NotifyError("HEADER_INJECTION", `${field} contains a line break. A newline in a header ends it and starts another.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function defineTemplate(spec) {
|
|
28
|
+
return spec;
|
|
29
|
+
}
|
|
30
|
+
function render(spec, data) {
|
|
31
|
+
const missing = spec.data.filter((key) => data[key] === undefined || data[key] === null);
|
|
32
|
+
if (missing.length > 0) {
|
|
33
|
+
throw new NotifyError("MISSING_DATA", `Template "${spec.key}" needs ${missing.join(", ")}. Rendering without them would send a message with a blank where the value should be.`);
|
|
34
|
+
}
|
|
35
|
+
const raw = Object.fromEntries(spec.data.map((key) => [key, String(data[key])]));
|
|
36
|
+
const escaped = Object.fromEntries(spec.data.map((key) => [key, escapeHtml(raw[key])]));
|
|
37
|
+
const subject = spec.subject(raw);
|
|
38
|
+
assertHeaderSafe(subject, `Subject of "${spec.key}"`);
|
|
39
|
+
return {
|
|
40
|
+
subject,
|
|
41
|
+
text: spec.text(raw),
|
|
42
|
+
html: spec.html(escaped)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function preview(spec) {
|
|
46
|
+
if (!spec.sample) {
|
|
47
|
+
throw new NotifyError("MISSING_DATA", `Template "${spec.key}" declares no sample to preview.`);
|
|
48
|
+
}
|
|
49
|
+
return render(spec, spec.sample);
|
|
50
|
+
}
|
|
51
|
+
function createNotifier(options) {
|
|
52
|
+
const registry = new Map;
|
|
53
|
+
for (const spec of options.templates) {
|
|
54
|
+
if (registry.has(spec.key)) {
|
|
55
|
+
throw new NotifyError("DUPLICATE_TEMPLATE", `Two templates share the key "${spec.key}".`);
|
|
56
|
+
}
|
|
57
|
+
registry.set(spec.key, spec);
|
|
58
|
+
}
|
|
59
|
+
async function deliver(notification) {
|
|
60
|
+
const spec = registry.get(notification.template);
|
|
61
|
+
if (!spec) {
|
|
62
|
+
throw new NotifyError("UNKNOWN_TEMPLATE", `No template named "${notification.template}".`);
|
|
63
|
+
}
|
|
64
|
+
assertHeaderSafe(notification.to, "Recipient");
|
|
65
|
+
if (!spec.transactional && options.allows) {
|
|
66
|
+
const allowed = await options.allows(notification.to, spec.key);
|
|
67
|
+
if (!allowed)
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const rendered = render(spec, notification.data);
|
|
71
|
+
await options.transport.send({ to: notification.to, ...rendered, realm: notification.realm });
|
|
72
|
+
return rendered;
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
templates: registry,
|
|
76
|
+
template: (key) => registry.get(key),
|
|
77
|
+
send(notification) {
|
|
78
|
+
deliver(notification).catch((error) => options.onError?.(error, notification));
|
|
79
|
+
},
|
|
80
|
+
sendOrThrow: (notification) => deliver(notification),
|
|
81
|
+
render: (template, data) => {
|
|
82
|
+
const spec = registry.get(template);
|
|
83
|
+
if (!spec)
|
|
84
|
+
throw new NotifyError("UNKNOWN_TEMPLATE", `No template named "${template}".`);
|
|
85
|
+
return render(spec, data);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function layout(args) {
|
|
90
|
+
const brand = escapeHtml(args.brand ?? "ForgeZero");
|
|
91
|
+
return `<!doctype html>
|
|
92
|
+
<html><body style="margin:0;padding:0;background:#0b0d10;color:#e6e8eb;font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif">
|
|
93
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#0b0d10;padding:32px 16px">
|
|
94
|
+
<tr><td align="center">
|
|
95
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:520px;background:#14171c;border:1px solid #232830;border-radius:12px">
|
|
96
|
+
<tr><td style="padding:28px 28px 8px">
|
|
97
|
+
<div style="font-size:13px;letter-spacing:.14em;text-transform:uppercase;color:#8b93a1">${brand}</div>
|
|
98
|
+
<h1 style="margin:12px 0 0;font-size:20px;line-height:1.3;font-weight:600;color:#f2f4f7">${args.title}</h1>
|
|
99
|
+
</td></tr>
|
|
100
|
+
<tr><td style="padding:12px 28px 28px;font-size:15px;line-height:1.6;color:#c3c9d4">${args.body}</td></tr>
|
|
101
|
+
</table>
|
|
102
|
+
${args.footer ? `<div style="max-width:520px;margin:16px auto 0;font-size:12px;line-height:1.5;color:#6b7280">${args.footer}</div>` : ""}
|
|
103
|
+
</td></tr></table>
|
|
104
|
+
</body></html>`;
|
|
105
|
+
}
|
|
106
|
+
var codeBlock = (code) => `<div style="margin:20px 0;padding:16px;background:#0b0d10;border:1px solid #232830;border-radius:8px;text-align:center;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:28px;letter-spacing:.32em;color:#f2f4f7">${code}</div>`;
|
|
107
|
+
var button = (label, href) => `<div style="margin:24px 0"><a href="${href}" style="display:inline-block;padding:12px 20px;background:#e6e8eb;color:#0b0d10;border-radius:8px;font-weight:600;font-size:15px;text-decoration:none">${label}</a></div>`;
|
|
108
|
+
var VERSION = "0.1.0";
|
|
109
|
+
export {
|
|
110
|
+
render,
|
|
111
|
+
preview,
|
|
112
|
+
layout,
|
|
113
|
+
escapeHtml,
|
|
114
|
+
defineTemplate,
|
|
115
|
+
createNotifier,
|
|
116
|
+
codeBlock,
|
|
117
|
+
button,
|
|
118
|
+
assertHeaderSafe,
|
|
119
|
+
VERSION,
|
|
120
|
+
NotifyError,
|
|
121
|
+
CHANNELS
|
|
122
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export declare class OpenSshError extends Error {
|
|
2
|
+
readonly code: 'BAD_KEY' | 'BAD_COMMENT' | 'BAD_BLOB';
|
|
3
|
+
constructor(code: 'BAD_KEY' | 'BAD_COMMENT' | 'BAD_BLOB', message: string);
|
|
4
|
+
}
|
|
5
|
+
/** The blob inside an authorized_keys line: name, then the 32-byte key. */
|
|
6
|
+
export declare function publicKeyBlob(publicKey: Uint8Array): Uint8Array;
|
|
7
|
+
/**
|
|
8
|
+
* One `authorized_keys` line.
|
|
9
|
+
*
|
|
10
|
+
* A comment with a newline in it would end the line early and turn the rest
|
|
11
|
+
* into a second entry — which on a file that grants login is a way to append an
|
|
12
|
+
* attacker's key by naming one. Refused rather than stripped, so nobody
|
|
13
|
+
* discovers their comment was silently rewritten.
|
|
14
|
+
*/
|
|
15
|
+
export declare function authorizedKey(publicKey: Uint8Array, comment?: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* The SHA-256 fingerprint `ssh-keygen -l` prints.
|
|
18
|
+
*
|
|
19
|
+
* Base64 of the digest with padding STRIPPED, which is what OpenSSH does — a
|
|
20
|
+
* fingerprint carrying `=` matches nothing an operator compares it against.
|
|
21
|
+
*/
|
|
22
|
+
export declare function fingerprint(publicKey: Uint8Array): string;
|
|
23
|
+
/** The signature blob an SSH agent returns: name, then the 64-byte signature. */
|
|
24
|
+
export declare function signatureBlob(signature: Uint8Array): Uint8Array;
|
|
25
|
+
/**
|
|
26
|
+
* Read a public key back out of an `authorized_keys` line.
|
|
27
|
+
*
|
|
28
|
+
* Used to confirm what was stored is what a server will accept, and to reject a
|
|
29
|
+
* pasted key of the wrong type before it is sealed. The algorithm is checked
|
|
30
|
+
* INSIDE the blob rather than from the prefix, because that is the one sshd
|
|
31
|
+
* reads.
|
|
32
|
+
*/
|
|
33
|
+
export declare function parseAuthorizedKey(line: string): {
|
|
34
|
+
publicKey: Uint8Array;
|
|
35
|
+
comment: string;
|
|
36
|
+
};
|