@augurworks/augur 0.15.1
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/CANVAS.md +858 -0
- package/INSTALL.md +603 -0
- package/LICENSE +27 -0
- package/README.md +150 -0
- package/TRADEMARKS.md +72 -0
- package/agents/README.md +65 -0
- package/agents/canon-extract.md +117 -0
- package/agents/canon.md +151 -0
- package/agents/canvas.md +88 -0
- package/agents/currency.md +74 -0
- package/agents/drafts.md +92 -0
- package/agents/identity.md +148 -0
- package/agents/prototype-contract.md +69 -0
- package/agents/publishing.md +324 -0
- package/agents/review-feedback.md +76 -0
- package/agents/space-json.md +132 -0
- package/agents/ui-skill.md +101 -0
- package/agents/working-marks.md +86 -0
- package/brand/augur-eye.svg +3 -0
- package/brand/augur-icon.svg +41 -0
- package/brand/augur-mark.svg +5 -0
- package/brand/lentianova-bulky.otf +0 -0
- package/brand/lentianova-median.otf +0 -0
- package/brand/lentianova.woff2 +0 -0
- package/build.js +8409 -0
- package/changelog.md +133 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/package.json +57 -0
- package/pitis/PETS.md +121 -0
- package/pitis/README.md +117 -0
- package/pitis/index.html +266 -0
- package/pitis/piti.build.js +81 -0
- package/pitis/piti.js +929 -0
- package/pitis/roast-agent.md +185 -0
- package/realtime/src/index.js +119 -0
- package/realtime/wrangler.example.toml +44 -0
- package/scripts/adopt.mjs +106 -0
- package/scripts/board-snapshot.mjs +197 -0
- package/scripts/build-seed-pack.mjs +33 -0
- package/scripts/bundle-rekey.mjs +112 -0
- package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
- package/scripts/canon-extract.mjs +578 -0
- package/scripts/canon.mjs +599 -0
- package/scripts/canvas-screen.mjs +333 -0
- package/scripts/check-local.mjs +136 -0
- package/scripts/clawd-art.mjs +75 -0
- package/scripts/clawd-canvas.mjs +741 -0
- package/scripts/cli.mjs +100 -0
- package/scripts/clone.mjs +422 -0
- package/scripts/close.mjs +30 -0
- package/scripts/connect.mjs +103 -0
- package/scripts/deploy.mjs +157 -0
- package/scripts/dev.mjs +54 -0
- package/scripts/doc-lint.mjs +143 -0
- package/scripts/export.mjs +300 -0
- package/scripts/fork.mjs +64 -0
- package/scripts/freeze.mjs +74 -0
- package/scripts/frontdoor-parity.mjs +180 -0
- package/scripts/hook.mjs +67 -0
- package/scripts/identity-rekey.mjs +112 -0
- package/scripts/init.mjs +122 -0
- package/scripts/land.mjs +40 -0
- package/scripts/lib/adapters.mjs +156 -0
- package/scripts/lib/board-snapshot.mjs +511 -0
- package/scripts/lib/draft.mjs +385 -0
- package/scripts/lib/graduate.mjs +276 -0
- package/scripts/lib/instance.mjs +74 -0
- package/scripts/lib/marks.mjs +107 -0
- package/scripts/lib/materialize.mjs +138 -0
- package/scripts/lib/merge3.mjs +124 -0
- package/scripts/lib/offline-posture.mjs +120 -0
- package/scripts/lib/offline-respawn.mjs +18 -0
- package/scripts/lib/offline-wrangler.mjs +78 -0
- package/scripts/lib/publish-compose.mjs +12 -0
- package/scripts/lib/publish-conflict.mjs +78 -0
- package/scripts/lib/publish-evidence.mjs +144 -0
- package/scripts/lib/refine-compare.mjs +140 -0
- package/scripts/lib/refine-ledger.mjs +150 -0
- package/scripts/lib/refine-png.mjs +149 -0
- package/scripts/lib/seed-pack-build.mjs +141 -0
- package/scripts/lib/state-compare.mjs +68 -0
- package/scripts/lib/store.mjs +122 -0
- package/scripts/login.mjs +84 -0
- package/scripts/mark.mjs +112 -0
- package/scripts/migrate-board-keys.mjs +164 -0
- package/scripts/migrate.mjs +265 -0
- package/scripts/no-foreign-vocabulary.mjs +216 -0
- package/scripts/no-product-names.mjs +157 -0
- package/scripts/no-tenant-globals.mjs +1134 -0
- package/scripts/offline.mjs +247 -0
- package/scripts/og.mjs +170 -0
- package/scripts/one-tenant-resolver.mjs +146 -0
- package/scripts/open.mjs +63 -0
- package/scripts/publish.mjs +1041 -0
- package/scripts/read.mjs +31 -0
- package/scripts/refine.mjs +455 -0
- package/scripts/release-drift.mjs +138 -0
- package/scripts/restore.mjs +322 -0
- package/scripts/review.mjs +125 -0
- package/scripts/save.mjs +21 -0
- package/scripts/shell-lint.mjs +189 -0
- package/scripts/ship.mjs +460 -0
- package/scripts/shoot.mjs +264 -0
- package/scripts/smoke-invite.mjs +113 -0
- package/scripts/state-inventory.mjs +223 -0
- package/scripts/status.mjs +177 -0
- package/scripts/sync.mjs +27 -0
- package/scripts/tenant-do-rehearsal.mjs +1314 -0
- package/scripts/ui-copy-lint.mjs +202 -0
- package/scripts/watch.mjs +32 -0
- package/scripts/wrangler-preflight.mjs +246 -0
- package/seed/CANON.md +43 -0
- package/seed/README.md +119 -0
- package/seed/prototype-status.json +9 -0
- package/seed/registry.json +88 -0
- package/seed/skills/starter-ui/SKILL.md +52 -0
- package/seed/skills/starter-ui/skill.json +11 -0
- package/seed/skills/starter-ui/starter-tokens.css +73 -0
- package/seed/skills/starter-ui/starter-ui.css +318 -0
- package/seed/skills/starter-ui/starter-ui.js +50 -0
- package/seed/space.json +5 -0
- package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
- package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
- package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
- package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
- package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
- package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
- package/seed/threads.json +52 -0
- package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
- package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
- package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
- package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
- package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
- package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
- package/src/_worker.js +12668 -0
- package/src/board-key.mjs +57 -0
- package/src/board-room.mjs +881 -0
- package/src/bundle-keys.mjs +138 -0
- package/src/canon/collect-in-browser.js +215 -0
- package/src/canon/emit.mjs +232 -0
- package/src/canon/observe.mjs +260 -0
- package/src/canon/schema.mjs +451 -0
- package/src/canvas/DSEG-LICENSE.txt +95 -0
- package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
- package/src/canvas/canvas.css +885 -0
- package/src/canvas/canvas.js +5780 -0
- package/src/canvas/capture.js +485 -0
- package/src/chrome/appchrome.mjs +568 -0
- package/src/currency.mjs +203 -0
- package/src/delete-confirmation.mjs +254 -0
- package/src/drafts/drafts.js +326 -0
- package/src/entry.js +58 -0
- package/src/galleries.mjs +400 -0
- package/src/health-cron.mjs +173 -0
- package/src/identity.json +1 -0
- package/src/kv-codec.mjs +135 -0
- package/src/kv-identity.mjs +259 -0
- package/src/mail.mjs +599 -0
- package/src/provenance.mjs +74 -0
- package/src/publish-compose.mjs +363 -0
- package/src/publish-fork.mjs +221 -0
- package/src/publish-units.mjs +44 -0
- package/src/purge.mjs +81 -0
- package/src/review/COMMENTING-UX.md +306 -0
- package/src/review/cat.png +0 -0
- package/src/review/comment-cursor.svg +7 -0
- package/src/review/comments.js +1738 -0
- package/src/room-ticket.mjs +96 -0
- package/src/roster-chip.mjs +32 -0
- package/src/seed-pack.mjs +261 -0
- package/src/state-inventory.mjs +249 -0
- package/src/sw-logic.mjs +48 -0
- package/src/tenant-cache.mjs +141 -0
- package/src/tenant-context.mjs +386 -0
- package/src/tenant-do.js +2940 -0
- package/src/tenant-host.mjs +144 -0
- package/src/tenant-quotas.mjs +140 -0
- package/src/unit-core.mjs +103 -0
- package/src/unit-object.mjs +338 -0
- package/templates/README.md +67 -0
- package/templates/shell/deploy.yml +145 -0
- package/templates/shell/engine-bump.yml +142 -0
- package/templates/shell/health.yml +233 -0
- package/templates/shell/kv-backup.yml +201 -0
- package/templates/shell/roster-update.yml +110 -0
- package/templates/shell/space-preflight.yml +35 -0
- package/templates/shell/space-rebake.yml +79 -0
- package/templates/shell/store-backup.yml +152 -0
- package/templates/shell/wrangler.example.toml +266 -0
- package/templates/space/publish.yml +91 -0
package/src/mail.mjs
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
// The mail transport — one function the rest of the engine calls to send a message.
|
|
2
|
+
//
|
|
3
|
+
// THE DEGRADE PATH IS THE FEATURE. An invite has always been a link an admin copies out
|
|
4
|
+
// of the admin panel and sends themselves. Email does not replace that; it rides on top
|
|
5
|
+
// of it. So `sendMail` NEVER throws and NEVER blocks: it returns a verdict, the caller
|
|
6
|
+
// hands back the link either way, and the three states an operator can be in all end
|
|
7
|
+
// with a working invite:
|
|
8
|
+
//
|
|
9
|
+
// no provider configured → reason "unconfigured", nothing sent, nothing logged, the
|
|
10
|
+
// panel behaves exactly as it did before mail existed.
|
|
11
|
+
// provider misconfigured → reason "misconfigured" naming the setting that is missing.
|
|
12
|
+
// provider down / refused → reason "failed" carrying the provider's own words.
|
|
13
|
+
//
|
|
14
|
+
// Every one of those is REPORTED, never swallowed: the admin API puts the verdict in its
|
|
15
|
+
// JSON and the panel shows it next to the link. A provider outage costs you the
|
|
16
|
+
// convenience of the send, never the invite.
|
|
17
|
+
//
|
|
18
|
+
// HTTP ONLY, NEVER SMTP. This runs in a Cloudflare Worker, which has no outbound TCP
|
|
19
|
+
// sockets. A provider is a shape of HTTP request, and adding one is adding an entry to
|
|
20
|
+
// DRIVERS below.
|
|
21
|
+
//
|
|
22
|
+
// NO INSTANCE VALUES LIVE HERE. Which provider, which endpoint, which key, which
|
|
23
|
+
// address the mail comes from — all of it is runtime worker env, set per deployment
|
|
24
|
+
// (see mailConfig). The engine carries the interface and the message wording, and
|
|
25
|
+
// nothing that identifies a deployment.
|
|
26
|
+
|
|
27
|
+
// ---- Configuration ----------------------------------------------------------------
|
|
28
|
+
// Runtime worker env, per deployment. Unset MAIL_PROVIDER is the supported default:
|
|
29
|
+
// a deployment that never configures mail is a deployment that hands out links.
|
|
30
|
+
//
|
|
31
|
+
// MAIL_PROVIDER driver name — a key of DRIVERS below. Unset ⇒ mail is off.
|
|
32
|
+
// MAIL_FROM the sending identity: "Name <address@example.org>" or a bare
|
|
33
|
+
// address. Use a domain you control the DNS for — SPF, DKIM and
|
|
34
|
+
// DMARC have to pass on it or the mail lands in spam.
|
|
35
|
+
// MAIL_API_KEY the provider credential (a secret, never a plain env var).
|
|
36
|
+
// MAIL_API_URL the endpoint. Required by drivers that have no way to derive one;
|
|
37
|
+
// always allowed as an override.
|
|
38
|
+
// MAIL_PROJECT_ID the provider-side account/project the sends are billed to, where
|
|
39
|
+
// the provider wants one in the body.
|
|
40
|
+
// MAIL_REGION the provider region, where the endpoint is per-region.
|
|
41
|
+
const str = (v) => (typeof v === "string" ? v.trim() : "");
|
|
42
|
+
|
|
43
|
+
// "Display Name <someone@example.org>" → {name, email}. A bare address is the common
|
|
44
|
+
// case and gets an empty name. Anything unparseable yields an empty email, which
|
|
45
|
+
// sendMail reports as misconfigured rather than sending from nowhere.
|
|
46
|
+
export function parseAddress(raw) {
|
|
47
|
+
const s = str(raw);
|
|
48
|
+
const m = /^\s*(.*?)\s*<\s*([^<>\s]+@[^<>\s]+)\s*>\s*$/.exec(s);
|
|
49
|
+
if (m) return { name: m[1].replace(/^"|"$/g, "").trim(), email: m[2] };
|
|
50
|
+
return { name: "", email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s) ? s : "" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function mailConfig(env) {
|
|
54
|
+
const provider = str(env && env.MAIL_PROVIDER).toLowerCase();
|
|
55
|
+
if (!provider) return null;
|
|
56
|
+
return {
|
|
57
|
+
provider,
|
|
58
|
+
from: parseAddress(env && env.MAIL_FROM),
|
|
59
|
+
apiKey: str(env && env.MAIL_API_KEY),
|
|
60
|
+
apiUrl: str(env && env.MAIL_API_URL),
|
|
61
|
+
projectId: str(env && env.MAIL_PROJECT_ID),
|
|
62
|
+
region: str(env && env.MAIL_REGION),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Is mail switched on for this deployment at all? Callers use it to decide whether to
|
|
67
|
+
// SAY anything about email — with no provider the panel should look untouched.
|
|
68
|
+
export function mailConfigured(env) {
|
|
69
|
+
return !!str(env && env.MAIL_PROVIDER);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---- Drivers ------------------------------------------------------------------------
|
|
73
|
+
// A driver is three small answers: where to POST, what to put in the request, and how to
|
|
74
|
+
// read an id back out. It owns the wire shape only — the endpoint host stays in config so
|
|
75
|
+
// a region change or a provider's own migration is a setting, not an engine release.
|
|
76
|
+
//
|
|
77
|
+
// `missing(cfg)` returns the names of the settings this driver cannot work without, so a
|
|
78
|
+
// half-configured deployment gets told which env var to set instead of a 400 from a
|
|
79
|
+
// vendor.
|
|
80
|
+
export const DRIVERS = Object.freeze({
|
|
81
|
+
// Transactional Email over Scaleway's HTTP API (v1alpha1). Per-region endpoint, so
|
|
82
|
+
// MAIL_REGION is required unless MAIL_API_URL spells the whole thing out. Auth is a
|
|
83
|
+
// secret key in X-Auth-Token; the project the send is billed to goes in the body.
|
|
84
|
+
scaleway: {
|
|
85
|
+
endpoint(cfg) {
|
|
86
|
+
if (cfg.apiUrl) return cfg.apiUrl;
|
|
87
|
+
if (!cfg.region) return "";
|
|
88
|
+
return `https://api.scaleway.com/transactional-email/v1alpha1/regions/${encodeURIComponent(cfg.region)}/emails`;
|
|
89
|
+
},
|
|
90
|
+
missing(cfg) {
|
|
91
|
+
const out = [];
|
|
92
|
+
if (!cfg.apiKey) out.push("MAIL_API_KEY");
|
|
93
|
+
if (!cfg.projectId) out.push("MAIL_PROJECT_ID");
|
|
94
|
+
if (!cfg.apiUrl && !cfg.region) out.push("MAIL_REGION");
|
|
95
|
+
return out;
|
|
96
|
+
},
|
|
97
|
+
request(cfg, msg) {
|
|
98
|
+
const from = { email: cfg.from.email };
|
|
99
|
+
if (cfg.from.name) from.name = cfg.from.name;
|
|
100
|
+
return {
|
|
101
|
+
headers: { "X-Auth-Token": cfg.apiKey, "Content-Type": "application/json" },
|
|
102
|
+
body: JSON.stringify({
|
|
103
|
+
from,
|
|
104
|
+
to: [{ email: msg.to }],
|
|
105
|
+
subject: msg.subject,
|
|
106
|
+
text: msg.text,
|
|
107
|
+
html: msg.html,
|
|
108
|
+
project_id: cfg.projectId,
|
|
109
|
+
}),
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
id(json) {
|
|
113
|
+
const first = json && Array.isArray(json.emails) ? json.emails[0] : null;
|
|
114
|
+
return (first && typeof first.id === "string") ? first.id : "";
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
// The escape hatch: POST the rendered message as plain JSON to whatever URL the
|
|
119
|
+
// deployment names, bearer-authenticated. Anyone whose provider is not in this file
|
|
120
|
+
// puts a dozen-line relay in front of it and configures this, rather than forking the
|
|
121
|
+
// engine to add a driver. It also carries `template`, so a relay that would rather own
|
|
122
|
+
// the wording can ignore the rendered bodies and use its own.
|
|
123
|
+
http: {
|
|
124
|
+
endpoint(cfg) { return cfg.apiUrl; },
|
|
125
|
+
missing(cfg) {
|
|
126
|
+
const out = [];
|
|
127
|
+
if (!cfg.apiUrl) out.push("MAIL_API_URL");
|
|
128
|
+
if (!cfg.apiKey) out.push("MAIL_API_KEY");
|
|
129
|
+
return out;
|
|
130
|
+
},
|
|
131
|
+
request(cfg, msg) {
|
|
132
|
+
return {
|
|
133
|
+
headers: { Authorization: `Bearer ${cfg.apiKey}`, "Content-Type": "application/json" },
|
|
134
|
+
body: JSON.stringify({
|
|
135
|
+
from: cfg.from.email,
|
|
136
|
+
fromName: cfg.from.name || "",
|
|
137
|
+
to: msg.to,
|
|
138
|
+
subject: msg.subject,
|
|
139
|
+
text: msg.text,
|
|
140
|
+
html: msg.html,
|
|
141
|
+
template: msg.template,
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
id(json) { return json && typeof json.id === "string" ? json.id : ""; },
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ---- Templates ----------------------------------------------------------------------
|
|
150
|
+
// Three messages, because three are what the product actually sends: someone confirming
|
|
151
|
+
// the address they signed up with, someone invited to an existing workspace, and someone
|
|
152
|
+
// whose credential was reset. All three say the same thing in the end — here is a link,
|
|
153
|
+
// it works once, ignore this if it wasn't you.
|
|
154
|
+
//
|
|
155
|
+
// Wording is deliberately plain and deployment-neutral. `workspace` is whatever the
|
|
156
|
+
// caller passes (the host it is serving, or the workspace's name), so the engine names
|
|
157
|
+
// no deployment.
|
|
158
|
+
//
|
|
159
|
+
// Every template renders BOTH a text and an HTML body. Text is not a fallback, it is
|
|
160
|
+
// half the message: plenty of clients show it, and a text part is one of the cheapest
|
|
161
|
+
// things you can do for deliverability.
|
|
162
|
+
|
|
163
|
+
const escHtml = (s) => String(s == null ? "" : s)
|
|
164
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
165
|
+
|
|
166
|
+
// One visual shell for all three, inline-styled and image-free: mail clients strip
|
|
167
|
+
// <style> blocks and block remote images, and a remote image would leak a read receipt
|
|
168
|
+
// and an instance hostname into every message.
|
|
169
|
+
function htmlShell({ heading, lines, link, action, footer }) {
|
|
170
|
+
const body = lines.map((l) => `<p style="margin:0 0 14px">${escHtml(l)}</p>`).join("\n ");
|
|
171
|
+
return `<!doctype html>
|
|
172
|
+
<html><body style="margin:0;padding:24px;background:#fbfbfd;font:15px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#16171a">
|
|
173
|
+
<div style="max-width:520px;margin:0 auto;padding:28px;background:#ffffff;border:1px solid rgba(16,17,26,0.09);border-radius:14px">
|
|
174
|
+
<h1 style="margin:0 0 18px;font-size:19px;font-weight:600;letter-spacing:-0.015em">${escHtml(heading)}</h1>
|
|
175
|
+
<div style="color:#2c2f36">
|
|
176
|
+
${body}
|
|
177
|
+
</div>
|
|
178
|
+
<p style="margin:22px 0 18px">
|
|
179
|
+
<a href="${escHtml(link)}" style="display:inline-block;padding:11px 18px;border-radius:9px;background:#2c2150;color:#ffffff;text-decoration:none;font-weight:600">${escHtml(action)}</a>
|
|
180
|
+
</p>
|
|
181
|
+
<p style="margin:0 0 14px;font-size:13px;color:#5b626e">If the button does nothing, copy this address into your browser:<br />
|
|
182
|
+
<a href="${escHtml(link)}" style="color:#4f46e5;word-break:break-all">${escHtml(link)}</a></p>
|
|
183
|
+
<p style="margin:0;font-size:13px;color:#9aa0ab">${escHtml(footer)}</p>
|
|
184
|
+
</div>
|
|
185
|
+
</body></html>`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function textBody({ lines, link, footer }) {
|
|
189
|
+
return `${lines.join("\n\n")}\n\n${link}\n\n${footer}\n`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A link is single-use and short-lived, and saying so is what stops a puzzled recipient
|
|
193
|
+
// from sitting on it for a week. The caller passes the window it actually minted.
|
|
194
|
+
const expiryLine = (hours) => {
|
|
195
|
+
const h = Number(hours);
|
|
196
|
+
if (!Number.isFinite(h) || h <= 0) return "The link can be used once.";
|
|
197
|
+
if (h % 24 === 0) {
|
|
198
|
+
const d = h / 24;
|
|
199
|
+
return `The link can be used once, and stops working in ${d} day${d === 1 ? "" : "s"}.`;
|
|
200
|
+
}
|
|
201
|
+
return `The link can be used once, and stops working in ${h} hour${h === 1 ? "" : "s"}.`;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export const TEMPLATES = Object.freeze({
|
|
205
|
+
// Someone typed an address into a signup form. Nothing exists yet — this proves the
|
|
206
|
+
// address is theirs before anything is provisioned against it.
|
|
207
|
+
"signup-verify": (v) => {
|
|
208
|
+
const lines = [
|
|
209
|
+
"Confirm this address to finish setting up your workspace.",
|
|
210
|
+
expiryLine(v.expiresHours),
|
|
211
|
+
];
|
|
212
|
+
return {
|
|
213
|
+
subject: "Confirm your email address",
|
|
214
|
+
text: textBody({ lines, link: v.link, footer: "If you didn't ask for this, ignore this message. Nothing was created." }),
|
|
215
|
+
html: htmlShell({
|
|
216
|
+
heading: "Confirm your email address",
|
|
217
|
+
lines, link: v.link, action: "Confirm address",
|
|
218
|
+
footer: "If you didn't ask for this, ignore this message. Nothing was created.",
|
|
219
|
+
}),
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
// An admin added someone to a workspace's roster. The link sets their password and
|
|
224
|
+
// signs them in, which is the whole of "accepting" an invite.
|
|
225
|
+
// `passwordless` is where the link LANDS, decided by the deployment (its SESSION_KEYS
|
|
226
|
+
// flag), and the message must describe that landing: a mail promising "choose a
|
|
227
|
+
// password" above a link that signs the person straight in reads as a phishing tell,
|
|
228
|
+
// and the reverse promises a sign-in the deployment will answer with a password form.
|
|
229
|
+
"roster-invite": (v) => {
|
|
230
|
+
const who = str(v.inviter);
|
|
231
|
+
const lines = [
|
|
232
|
+
who ? `${who} invited you to ${v.workspace}.` : `You have been invited to ${v.workspace}.`,
|
|
233
|
+
v.passwordless ? "Open the link and you're in — there is no password to set." : "Choose a password and you're in.",
|
|
234
|
+
expiryLine(v.expiresHours),
|
|
235
|
+
];
|
|
236
|
+
return {
|
|
237
|
+
subject: `You're invited to ${str(v.workspace) || "a workspace"}`,
|
|
238
|
+
text: textBody({ lines, link: v.link, footer: "If you weren't expecting this, ignore this message." }),
|
|
239
|
+
html: htmlShell({
|
|
240
|
+
heading: "You've been invited",
|
|
241
|
+
lines, link: v.link, action: v.passwordless ? "Accept invitation" : "Set your password",
|
|
242
|
+
footer: "If you weren't expecting this, ignore this message.",
|
|
243
|
+
}),
|
|
244
|
+
};
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
// The credential is already gone by the time this is sent — reset revokes and mints the
|
|
248
|
+
// link in one action, so there is never a live password alongside a pending link. The
|
|
249
|
+
// wording has to match that, or people go looking for the old one. On a passwordless
|
|
250
|
+
// deployment "reset" means the old SESSIONS are ended and this link is the way back in.
|
|
251
|
+
"credential-reset": (v) => {
|
|
252
|
+
const lines = v.passwordless ? [
|
|
253
|
+
`Your access to ${v.workspace} was reset, so your old sign-in no longer works.`,
|
|
254
|
+
"Open the link to sign back in — there is no password to set.",
|
|
255
|
+
expiryLine(v.expiresHours),
|
|
256
|
+
] : [
|
|
257
|
+
`Your password for ${v.workspace} was reset, so the old one no longer works.`,
|
|
258
|
+
"Choose a new one to get back in.",
|
|
259
|
+
expiryLine(v.expiresHours),
|
|
260
|
+
];
|
|
261
|
+
return {
|
|
262
|
+
subject: v.passwordless
|
|
263
|
+
? `Sign back in to ${str(v.workspace) || "your account"}`
|
|
264
|
+
: `Set a new password for ${str(v.workspace) || "your account"}`,
|
|
265
|
+
text: textBody({ lines, link: v.link, footer: "If you didn't expect this, tell whoever runs the site. Someone with admin access did it." }),
|
|
266
|
+
html: htmlShell({
|
|
267
|
+
heading: v.passwordless ? "Sign back in" : "Set a new password",
|
|
268
|
+
lines, link: v.link, action: v.passwordless ? "Sign back in" : "Choose a new password",
|
|
269
|
+
footer: "If you didn't expect this, tell whoever runs the site. Someone with admin access did it.",
|
|
270
|
+
}),
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
export function renderMail(template, vars = {}) {
|
|
276
|
+
const fn = TEMPLATES[template];
|
|
277
|
+
if (!fn) return null;
|
|
278
|
+
const v = { workspace: "your workspace", link: "", inviter: "", expiresHours: 0, passwordless: false, ...vars };
|
|
279
|
+
return fn(v);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---- The abuse guards ----------------------------------------------------------------
|
|
283
|
+
//
|
|
284
|
+
// FOUR RATE LAYERS AND TWO THINGS THAT ARE NOT RATE LIMITS. They stop different attacks,
|
|
285
|
+
// and any one of them alone stops none of the others.
|
|
286
|
+
//
|
|
287
|
+
// THE ATTACK THE ORIGINAL GUARD COULD NOT SEE. Every limit here used to be keyed on the
|
|
288
|
+
// RECIPIENT. So one actor triggers three resets each at ten thousand DIFFERENT addresses,
|
|
289
|
+
// stays inside every cap, and sends thirty thousand messages. That is the shape that
|
|
290
|
+
// destroys a sending domain's reputation, and it is also the cost shape: the sending plan
|
|
291
|
+
// is 300 messages a MONTH, so a modest burst exhausts the quota and signup then fails
|
|
292
|
+
// silently for everyone. The per-actor ceiling is the one that was missing; without it the
|
|
293
|
+
// other layers are decoration.
|
|
294
|
+
//
|
|
295
|
+
// 1. CEILING, per recipient per window. Stops one person's inbox being bombed.
|
|
296
|
+
// 2. FLOOR, a minimum gap between two sends to one address. NOT the same guard: 3/hour
|
|
297
|
+
// permits three instantly, so a double-clicked resend button sends three. The floor
|
|
298
|
+
// is what makes a resend button honest, and it is the cheapest fix here.
|
|
299
|
+
// 3. CEILING, per ACTOR — the admin for an invite, the client IP for anything a stranger
|
|
300
|
+
// can reach. This is the one that sees the attack above.
|
|
301
|
+
// 4. CEILING, per INSTANCE. Hard-stops and logs loudly rather than queueing, because the
|
|
302
|
+
// failure it prevents is silent quota exhaustion that breaks signup for everybody.
|
|
303
|
+
//
|
|
304
|
+
// `roster-invite` IS capped now, generously. The old reasoning — an authenticated admin
|
|
305
|
+
// naming an address they are also putting on their own roster — holds right up until the
|
|
306
|
+
// admin credential is the thing that was stolen, and an uncapped authenticated path is
|
|
307
|
+
// still a mail cannon, just one that needs a login first.
|
|
308
|
+
//
|
|
309
|
+
// EVERY LIMIT DEGRADES THE WAY THE TRANSPORT ALREADY DOES: refusing to send still returns
|
|
310
|
+
// a copy-pasteable link and a visible reason, never a silent swallow. Nothing a person was
|
|
311
|
+
// trying to DO is refused; only the email is.
|
|
312
|
+
//
|
|
313
|
+
// The numbers are named constants a self-hoster can raise. They protect a 300-a-month
|
|
314
|
+
// plan, not a large one.
|
|
315
|
+
//
|
|
316
|
+
// KV has no atomic increment, so these are soft counters — the same shape and the same
|
|
317
|
+
// honest limits as the login throttle. With no KV at all none of them apply, which is the
|
|
318
|
+
// local-development case.
|
|
319
|
+
|
|
320
|
+
// ── ENUMERATION SAFETY IS THE CALLER'S, AND IT IS NOT OPTIONAL ────────────────────────
|
|
321
|
+
//
|
|
322
|
+
// Nothing in this module can provide it: sendMail is told an address and a template, and
|
|
323
|
+
// has no idea whether an account exists behind it. But it is the other half of the same
|
|
324
|
+
// item, so the contract is written where whoever builds the stranger-facing reset and
|
|
325
|
+
// signup flows will be reading.
|
|
326
|
+
//
|
|
327
|
+
// A reset requested for an address that does NOT exist must be indistinguishable from one
|
|
328
|
+
// that does, in three ways, or the endpoint is a free account-existence oracle:
|
|
329
|
+
//
|
|
330
|
+
// STATUS the same code.
|
|
331
|
+
// BODY the same bytes. "If that address has an account, we have sent a link" for
|
|
332
|
+
// both — never "unknown user", which is the shape the engine's ADMIN reset
|
|
333
|
+
// uses and is correct there, because an admin is entitled to know who is on
|
|
334
|
+
// their own roster.
|
|
335
|
+
// TIMING the same elapsed time. This is the one that gets forgotten: the real path
|
|
336
|
+
// does a KV read, a hash and an HTTP send, and the fake path returns
|
|
337
|
+
// immediately. A few hundred milliseconds is a reliable oracle over enough
|
|
338
|
+
// samples, so the miss path has to do the same work or wait out the same
|
|
339
|
+
// budget.
|
|
340
|
+
//
|
|
341
|
+
// And it must SEND NOTHING for an address with no account, which is why the timing has to
|
|
342
|
+
// be equalised rather than achieved by sending anyway.
|
|
343
|
+
//
|
|
344
|
+
// The engine has no such endpoint today — its only sendMail caller is behind an admin
|
|
345
|
+
// check — and the control plane routes no signup or reset yet. This lands with
|
|
346
|
+
// `B-signup-flow`.
|
|
347
|
+
|
|
348
|
+
export const MAIL_RATE = Object.freeze({
|
|
349
|
+
"credential-reset": { max: 3, windowMs: 60 * 60 * 1000, minGapMs: 60 * 1000 },
|
|
350
|
+
"signup-verify": { max: 5, windowMs: 60 * 60 * 1000, minGapMs: 60 * 1000 },
|
|
351
|
+
// Generous: re-sending a lost invite is a real workflow and must not be blocked. It is
|
|
352
|
+
// capped at all because "only an admin can reach it" stops being a guarantee the moment
|
|
353
|
+
// an admin credential leaks.
|
|
354
|
+
"roster-invite": { max: 30, windowMs: 60 * 60 * 1000, minGapMs: 20 * 1000 },
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Per ACTOR: who triggered the send, across every recipient and every template.
|
|
358
|
+
export const MAIL_ACTOR_RATE = Object.freeze({ max: 20, windowMs: 60 * 60 * 1000 });
|
|
359
|
+
|
|
360
|
+
// Per INSTANCE. 50 a day against a 300-a-month plan: an instance sending its real volume
|
|
361
|
+
// never comes near it, and a runaway is stopped having spent a sixth of the month rather
|
|
362
|
+
// than all of it. Raise it if you send more; it exists to bound an accident, not to ration.
|
|
363
|
+
export const MAIL_GLOBAL_RATE = Object.freeze({ max: 50, windowMs: 24 * 60 * 60 * 1000 });
|
|
364
|
+
|
|
365
|
+
export const MAIL_RL_PREFIX = "rl:mail:";
|
|
366
|
+
export const MAIL_SUPPRESS_KEY = "mail:suppressed";
|
|
367
|
+
|
|
368
|
+
const lc = (e) => String(e || "").trim().toLowerCase();
|
|
369
|
+
export const mailRateKey = (template, to) => `${MAIL_RL_PREFIX}${template}:${lc(to)}`;
|
|
370
|
+
export const mailActorKey = (actor) => `${MAIL_RL_PREFIX}actor:${lc(actor)}`;
|
|
371
|
+
export const MAIL_GLOBAL_KEY = `${MAIL_RL_PREFIX}instance`;
|
|
372
|
+
|
|
373
|
+
// ---- Suppression: never send to an address that has hard-bounced ----------------------
|
|
374
|
+
//
|
|
375
|
+
// LEARNED THE EXPENSIVE WAY. Bounces to two addresses on the sending domain got both
|
|
376
|
+
// blocklisted at the provider for a MONTH, and the provider caps blocklist deletions at
|
|
377
|
+
// five per rolling 24 hours — so the cleanup is structurally slower than the damage. A
|
|
378
|
+
// local refusal costs nothing and is the only part of this that is faster than the harm.
|
|
379
|
+
//
|
|
380
|
+
// It is a local list and it is deliberately not clever: an address goes on when something
|
|
381
|
+
// tells us it hard-bounced, and it comes off when a human takes it off. Guessing that a
|
|
382
|
+
// bounce was temporary is how an address gets bounced a second time.
|
|
383
|
+
export async function mailSuppressed(kv, to) {
|
|
384
|
+
if (!kv) return false;
|
|
385
|
+
try {
|
|
386
|
+
const list = JSON.parse((await kv.get(MAIL_SUPPRESS_KEY)) || "null");
|
|
387
|
+
return !!(list && typeof list === "object" && !Array.isArray(list) && list[lc(to)]);
|
|
388
|
+
} catch (e) { return false; }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export async function mailSuppress(kv, to, reason = "hard-bounce", now = Date.now()) {
|
|
392
|
+
if (!kv) return false;
|
|
393
|
+
try {
|
|
394
|
+
const raw = JSON.parse((await kv.get(MAIL_SUPPRESS_KEY)) || "null");
|
|
395
|
+
const list = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
396
|
+
list[lc(to)] = { reason: String(reason).slice(0, 120), at: new Date(now).toISOString() };
|
|
397
|
+
await kv.put(MAIL_SUPPRESS_KEY, JSON.stringify(list));
|
|
398
|
+
return true;
|
|
399
|
+
} catch (e) { return false; }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ---- The counters --------------------------------------------------------------------
|
|
403
|
+
// Read-only, so a caller can report the wait without spending a write.
|
|
404
|
+
|
|
405
|
+
async function readCounter(kv, key, now) {
|
|
406
|
+
try {
|
|
407
|
+
const rec = JSON.parse((await kv.get(key)) || "null");
|
|
408
|
+
return rec && rec.until > now ? rec : null;
|
|
409
|
+
} catch (e) { return null; }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function noteCounter(kv, key, rule, now) {
|
|
413
|
+
try {
|
|
414
|
+
const rec = await readCounter(kv, key, now);
|
|
415
|
+
const n = (rec ? rec.n : 0) + 1;
|
|
416
|
+
const until = rec ? rec.until : now + rule.windowMs;
|
|
417
|
+
await kv.put(key, JSON.stringify({ n, until, last: now }), {
|
|
418
|
+
expirationTtl: Math.ceil(rule.windowMs / 1000) + 60,
|
|
419
|
+
});
|
|
420
|
+
} catch (e) {}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* {allowed, retryAfterMs, layer} — `layer` names WHICH guard refused, because "try again
|
|
425
|
+
* later" without saying which limit was hit is the message that makes an operator think
|
|
426
|
+
* the mail is broken.
|
|
427
|
+
*/
|
|
428
|
+
export async function mailRateCheck(kv, template, to, now = Date.now(), opts = {}) {
|
|
429
|
+
const rule = MAIL_RATE[template];
|
|
430
|
+
if (!kv) return { allowed: true, retryAfterMs: 0 };
|
|
431
|
+
|
|
432
|
+
if (rule) {
|
|
433
|
+
const rec = await readCounter(kv, mailRateKey(template, to), now);
|
|
434
|
+
if (rec) {
|
|
435
|
+
// FLOOR first: it is the one a person actually trips, and naming the ceiling when
|
|
436
|
+
// they double-clicked would be a wrong answer.
|
|
437
|
+
if (rule.minGapMs && rec.last && now - rec.last < rule.minGapMs) {
|
|
438
|
+
return { allowed: false, retryAfterMs: rule.minGapMs - (now - rec.last), layer: "floor" };
|
|
439
|
+
}
|
|
440
|
+
if (rec.n >= rule.max) {
|
|
441
|
+
return { allowed: false, retryAfterMs: rec.until - now, layer: "recipient" };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (opts.actor) {
|
|
447
|
+
const rec = await readCounter(kv, mailActorKey(opts.actor), now);
|
|
448
|
+
if (rec && rec.n >= MAIL_ACTOR_RATE.max) {
|
|
449
|
+
return { allowed: false, retryAfterMs: rec.until - now, layer: "actor" };
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const g = await readCounter(kv, MAIL_GLOBAL_KEY, now);
|
|
454
|
+
if (g && g.n >= MAIL_GLOBAL_RATE.max) {
|
|
455
|
+
return { allowed: false, retryAfterMs: g.until - now, layer: "instance" };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return { allowed: true, retryAfterMs: 0 };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Count the ATTEMPT, not the success: a provider that times out on every call still costs
|
|
462
|
+
// the recipient nothing, but it must not become a way around the cap.
|
|
463
|
+
export async function mailRateNote(kv, template, to, now = Date.now(), opts = {}) {
|
|
464
|
+
if (!kv) return;
|
|
465
|
+
const rule = MAIL_RATE[template];
|
|
466
|
+
if (rule) await noteCounter(kv, mailRateKey(template, to), rule, now);
|
|
467
|
+
if (opts.actor) await noteCounter(kv, mailActorKey(opts.actor), MAIL_ACTOR_RATE, now);
|
|
468
|
+
await noteCounter(kv, MAIL_GLOBAL_KEY, MAIL_GLOBAL_RATE, now);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ---- Send ---------------------------------------------------------------------------
|
|
472
|
+
// The one entry point. Returns a verdict, always; throws, never.
|
|
473
|
+
//
|
|
474
|
+
// { ok: true, reason: "sent", provider, id }
|
|
475
|
+
// { ok: false, reason: "unconfigured" } no provider — the default
|
|
476
|
+
// { ok: false, reason: "misconfigured", detail } provider named, settings missing
|
|
477
|
+
// { ok: false, reason: "unknown-template", detail } a caller bug
|
|
478
|
+
// { ok: false, reason: "bad-recipient", detail } not an address
|
|
479
|
+
// { ok: false, reason: "rate-limited", retryAfterMs, layer } capped — layer names which
|
|
480
|
+
// guard: floor | recipient |
|
|
481
|
+
// actor | instance
|
|
482
|
+
// { ok: false, reason: "suppressed", detail } this address hard-bounced before
|
|
483
|
+
// { ok: false, reason: "failed", detail } the provider said no
|
|
484
|
+
//
|
|
485
|
+
// `fetchImpl` and `kv` are injectable so the suite can drive every one of those without
|
|
486
|
+
// a network and without a live account. Nothing in here reaches for a global except
|
|
487
|
+
// through those two seams.
|
|
488
|
+
const EMAILISH = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
489
|
+
const SEND_TIMEOUT_MS = 10000;
|
|
490
|
+
const DETAIL_MAX = 240;
|
|
491
|
+
|
|
492
|
+
export async function sendMail(env, message = {}, opts = {}) {
|
|
493
|
+
const { to, template, vars = {} } = message;
|
|
494
|
+
const fetchImpl = opts.fetchImpl || (typeof fetch === "function" ? fetch : null);
|
|
495
|
+
const kv = opts.kv !== undefined ? opts.kv : (env && env.COMMENTS) || null;
|
|
496
|
+
const now = opts.now || Date.now();
|
|
497
|
+
|
|
498
|
+
const cfg = mailConfig(env);
|
|
499
|
+
if (!cfg) return { ok: false, reason: "unconfigured" };
|
|
500
|
+
|
|
501
|
+
const driver = DRIVERS[cfg.provider];
|
|
502
|
+
if (!driver) {
|
|
503
|
+
return { ok: false, reason: "misconfigured",
|
|
504
|
+
detail: `unknown MAIL_PROVIDER "${cfg.provider}". Known drivers: ${Object.keys(DRIVERS).join(", ")}` };
|
|
505
|
+
}
|
|
506
|
+
const missing = driver.missing(cfg).concat(cfg.from.email ? [] : ["MAIL_FROM"]);
|
|
507
|
+
if (missing.length) {
|
|
508
|
+
return { ok: false, reason: "misconfigured", detail: `not set: ${missing.join(", ")}` };
|
|
509
|
+
}
|
|
510
|
+
const endpoint = driver.endpoint(cfg);
|
|
511
|
+
if (!endpoint) return { ok: false, reason: "misconfigured", detail: "not set: MAIL_API_URL" };
|
|
512
|
+
|
|
513
|
+
if (!EMAILISH.test(String(to || ""))) {
|
|
514
|
+
return { ok: false, reason: "bad-recipient", detail: "not an email address" };
|
|
515
|
+
}
|
|
516
|
+
const rendered = renderMail(template, vars);
|
|
517
|
+
if (!rendered) return { ok: false, reason: "unknown-template", detail: String(template) };
|
|
518
|
+
|
|
519
|
+
// Suppression is checked BEFORE any counter, so an address we already know hard-bounced
|
|
520
|
+
// costs nobody their budget — and so a suppressed address cannot be used to burn an
|
|
521
|
+
// actor's allowance.
|
|
522
|
+
if (await mailSuppressed(kv, to)) {
|
|
523
|
+
return { ok: false, reason: "suppressed",
|
|
524
|
+
detail: "this address hard-bounced before; sending to it again risks the whole domain's reputation" };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const gate = await mailRateCheck(kv, template, to, now, { actor: opts.actor });
|
|
528
|
+
if (!gate.allowed) {
|
|
529
|
+
// The instance ceiling is the one worth waking someone for: everything still works,
|
|
530
|
+
// links are still handed out, and nobody would otherwise notice until signup mail
|
|
531
|
+
// stopped arriving for everyone.
|
|
532
|
+
if (gate.layer === "instance") {
|
|
533
|
+
try {
|
|
534
|
+
console.log(JSON.stringify({
|
|
535
|
+
level: "alarm",
|
|
536
|
+
event: "mail-instance-ceiling",
|
|
537
|
+
detail: `This instance has attempted ${MAIL_GLOBAL_RATE.max} sends in ${Math.round(MAIL_GLOBAL_RATE.windowMs / 3600000)}h and is now refusing. Links are still being handed out. Check for a loop or an abusive caller before raising MAIL_GLOBAL_RATE.`,
|
|
538
|
+
}));
|
|
539
|
+
} catch (e) { /* an alarm may never break the refusal it announces */ }
|
|
540
|
+
}
|
|
541
|
+
return { ok: false, reason: "rate-limited", retryAfterMs: gate.retryAfterMs, layer: gate.layer };
|
|
542
|
+
}
|
|
543
|
+
await mailRateNote(kv, template, to, now, { actor: opts.actor });
|
|
544
|
+
|
|
545
|
+
if (!fetchImpl) return { ok: false, reason: "failed", detail: "no fetch available" };
|
|
546
|
+
|
|
547
|
+
const req = driver.request(cfg, { to, template, ...rendered });
|
|
548
|
+
try {
|
|
549
|
+
const init = { method: "POST", headers: req.headers, body: req.body };
|
|
550
|
+
// A provider that hangs must not hold the admin's request open. AbortSignal.timeout
|
|
551
|
+
// exists in workerd and in Node 18+; where it does not, the send simply has no
|
|
552
|
+
// deadline rather than failing to be attempted.
|
|
553
|
+
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
|
|
554
|
+
init.signal = AbortSignal.timeout(SEND_TIMEOUT_MS);
|
|
555
|
+
}
|
|
556
|
+
const res = await fetchImpl(endpoint, init);
|
|
557
|
+
const body = await res.text().catch(() => "");
|
|
558
|
+
if (!res.ok) {
|
|
559
|
+
return { ok: false, reason: "failed", detail: `${res.status} ${body.slice(0, DETAIL_MAX)}`.trim() };
|
|
560
|
+
}
|
|
561
|
+
let json = null;
|
|
562
|
+
try { json = body ? JSON.parse(body) : null; } catch (e) {}
|
|
563
|
+
return { ok: true, reason: "sent", provider: cfg.provider, id: driver.id(json) || "" };
|
|
564
|
+
} catch (e) {
|
|
565
|
+
return { ok: false, reason: "failed", detail: String((e && e.message) || e).slice(0, DETAIL_MAX) };
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// What an operator should be told, in one line. The panel shows this next to the link,
|
|
570
|
+
// so it has to be useful to someone who has never read this file: name the state, and
|
|
571
|
+
// where a setting is missing, name the setting.
|
|
572
|
+
export function mailNotice(result, to) {
|
|
573
|
+
if (!result) return "";
|
|
574
|
+
if (result.ok) return `Emailed to ${to}.`;
|
|
575
|
+
switch (result.reason) {
|
|
576
|
+
case "unconfigured": return "";
|
|
577
|
+
case "suppressed":
|
|
578
|
+
return "Not emailed. That address bounced before, so we no longer send to it. Send the link yourself.";
|
|
579
|
+
case "rate-limited": {
|
|
580
|
+
const ms = result.retryAfterMs || 0;
|
|
581
|
+
const wait = ms < 60000 ? `${Math.max(1, Math.ceil(ms / 1000))} s` : `${Math.ceil(ms / 60000)} min`;
|
|
582
|
+
// Name WHICH limit. "Try again later" without saying which one is the message that
|
|
583
|
+
// makes an operator conclude the mail is broken and stop trusting the panel.
|
|
584
|
+
switch (result.layer) {
|
|
585
|
+
case "floor":
|
|
586
|
+
return `Not emailed yet. That was moments ago, so try again in ${wait}. Send the link yourself.`;
|
|
587
|
+
case "actor":
|
|
588
|
+
return `Not emailed. You have sent a lot of mail in the last hour, so try again in ${wait}. Send the link yourself.`;
|
|
589
|
+
case "instance":
|
|
590
|
+
return `Not emailed. This whole instance has hit its sending limit for the day. Nothing is lost. Send the link yourself, and check the logs for a loop.`;
|
|
591
|
+
default:
|
|
592
|
+
return `Not emailed. Too many messages to this address already, so try again in ${wait}. Send the link yourself.`;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
case "misconfigured": return `Email is switched on but not finished: ${result.detail}. Send the link yourself.`;
|
|
596
|
+
case "bad-recipient": return "Not emailed. That address isn't valid. Send the link yourself.";
|
|
597
|
+
default: return `Couldn't email them (${result.detail || result.reason}). Send the link yourself.`;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Who wrote a published version, and the one answer that is not a person.
|
|
2
|
+
//
|
|
3
|
+
// `F-seed-vs-real-provenance-marker`. A provisioned workspace is not empty: it arrives
|
|
4
|
+
// carrying seed prototypes, written by the platform on somebody's behalf before they have
|
|
5
|
+
// done anything. Every downstream reader of provenance — the onboarding floor-check that
|
|
6
|
+
// asks "has this workspace published anything REAL yet", and the "Edited by" line that
|
|
7
|
+
// will replace today's git-derived dates — has to be able to tell those versions from a
|
|
8
|
+
// person's, and has to be able to do it without guessing.
|
|
9
|
+
//
|
|
10
|
+
// THE SENTINEL MUST NOT BE FORGEABLE, and that is the part that cannot be retrofitted.
|
|
11
|
+
// `publish.mjs` stamps `source.actor` from `process.env.USER`, which is an environment
|
|
12
|
+
// variable: it is whatever the shell says it is. So a sentinel is only trustworthy if the
|
|
13
|
+
// ordinary publish path REFUSES to write it — otherwise "was this seeded?" is answered by
|
|
14
|
+
// a string anybody can set, and the floor-check it feeds can be walked straight past.
|
|
15
|
+
//
|
|
16
|
+
// Hence `sanitizeActor`: the reserved prefix is stripped at the one place a real publish
|
|
17
|
+
// stamps an actor. It is enforced at the WRITE, not checked at the read, because a read-
|
|
18
|
+
// side check has to be remembered by every future consumer and this one cannot be.
|
|
19
|
+
//
|
|
20
|
+
// The prefix is `augur:` and it contains a colon on purpose — no POSIX username may
|
|
21
|
+
// contain one, so an ordinary `$USER` cannot collide with the namespace by accident, and
|
|
22
|
+
// the only way to land in it is to try.
|
|
23
|
+
|
|
24
|
+
/** The actor recorded for anything the platform wrote on a workspace's behalf. */
|
|
25
|
+
export const SEED_ACTOR = "augur:seed";
|
|
26
|
+
|
|
27
|
+
/** The namespace no real actor may occupy. */
|
|
28
|
+
export const RESERVED_ACTOR_PREFIX = "augur:";
|
|
29
|
+
|
|
30
|
+
/** What a screen shows where it would show the publisher's name, for a platform write. */
|
|
31
|
+
export const SEED_DISPLAY_NAME = "Augur";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Is this publish-token label the platform's? The label-shaped twin of `isSeedSource`, for
|
|
35
|
+
* the one field that carries a label rather than a source object (`publishedBy`). Same
|
|
36
|
+
* rule, same namespace, so a second platform actor is still one edit in one file.
|
|
37
|
+
*/
|
|
38
|
+
export function isSeedActor(label) {
|
|
39
|
+
return typeof label === "string" && label.toLowerCase().startsWith(RESERVED_ACTOR_PREFIX);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Clean an actor string coming from the environment. Anything claiming the reserved
|
|
44
|
+
* namespace loses it, so a real publish can never present itself as a platform write.
|
|
45
|
+
*/
|
|
46
|
+
export function sanitizeActor(raw) {
|
|
47
|
+
const s = String(raw == null ? "" : raw).trim();
|
|
48
|
+
if (!s) return "";
|
|
49
|
+
if (s.toLowerCase().startsWith(RESERVED_ACTOR_PREFIX)) {
|
|
50
|
+
// Not an error: a person whose $USER happens to start with this should still be able
|
|
51
|
+
// to publish. They simply do not get to claim the namespace.
|
|
52
|
+
return s.slice(RESERVED_ACTOR_PREFIX.length).trim() || "";
|
|
53
|
+
}
|
|
54
|
+
return s;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Was this version written by the platform rather than by a person?
|
|
59
|
+
*
|
|
60
|
+
* THE ONE PREDICATE. Every consumer asks through here rather than comparing strings, so
|
|
61
|
+
* that adding a second platform actor later (a migration writer, a restore) is one edit
|
|
62
|
+
* in one file rather than a hunt through everything that ever looked at provenance.
|
|
63
|
+
*/
|
|
64
|
+
export function isSeedSource(source) {
|
|
65
|
+
const actor = source && typeof source === "object" ? source.actor : null;
|
|
66
|
+
if (source && source.seed === true) return true;
|
|
67
|
+
return typeof actor === "string" && actor.toLowerCase().startsWith(RESERVED_ACTOR_PREFIX);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The provenance stamp for a platform write. `seed: true` is belt and braces for a
|
|
71
|
+
* reader that only knows the flag, and costs one boolean. */
|
|
72
|
+
export function seedSource(extra = {}) {
|
|
73
|
+
return { ...extra, actor: SEED_ACTOR, seed: true };
|
|
74
|
+
}
|