@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.
Files changed (190) hide show
  1. package/CANVAS.md +858 -0
  2. package/INSTALL.md +603 -0
  3. package/LICENSE +27 -0
  4. package/README.md +150 -0
  5. package/TRADEMARKS.md +72 -0
  6. package/agents/README.md +65 -0
  7. package/agents/canon-extract.md +117 -0
  8. package/agents/canon.md +151 -0
  9. package/agents/canvas.md +88 -0
  10. package/agents/currency.md +74 -0
  11. package/agents/drafts.md +92 -0
  12. package/agents/identity.md +148 -0
  13. package/agents/prototype-contract.md +69 -0
  14. package/agents/publishing.md +324 -0
  15. package/agents/review-feedback.md +76 -0
  16. package/agents/space-json.md +132 -0
  17. package/agents/ui-skill.md +101 -0
  18. package/agents/working-marks.md +86 -0
  19. package/brand/augur-eye.svg +3 -0
  20. package/brand/augur-icon.svg +41 -0
  21. package/brand/augur-mark.svg +5 -0
  22. package/brand/lentianova-bulky.otf +0 -0
  23. package/brand/lentianova-median.otf +0 -0
  24. package/brand/lentianova.woff2 +0 -0
  25. package/build.js +8409 -0
  26. package/changelog.md +133 -0
  27. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  28. package/package.json +57 -0
  29. package/pitis/PETS.md +121 -0
  30. package/pitis/README.md +117 -0
  31. package/pitis/index.html +266 -0
  32. package/pitis/piti.build.js +81 -0
  33. package/pitis/piti.js +929 -0
  34. package/pitis/roast-agent.md +185 -0
  35. package/realtime/src/index.js +119 -0
  36. package/realtime/wrangler.example.toml +44 -0
  37. package/scripts/adopt.mjs +106 -0
  38. package/scripts/board-snapshot.mjs +197 -0
  39. package/scripts/build-seed-pack.mjs +33 -0
  40. package/scripts/bundle-rekey.mjs +112 -0
  41. package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
  42. package/scripts/canon-extract.mjs +578 -0
  43. package/scripts/canon.mjs +599 -0
  44. package/scripts/canvas-screen.mjs +333 -0
  45. package/scripts/check-local.mjs +136 -0
  46. package/scripts/clawd-art.mjs +75 -0
  47. package/scripts/clawd-canvas.mjs +741 -0
  48. package/scripts/cli.mjs +100 -0
  49. package/scripts/clone.mjs +422 -0
  50. package/scripts/close.mjs +30 -0
  51. package/scripts/connect.mjs +103 -0
  52. package/scripts/deploy.mjs +157 -0
  53. package/scripts/dev.mjs +54 -0
  54. package/scripts/doc-lint.mjs +143 -0
  55. package/scripts/export.mjs +300 -0
  56. package/scripts/fork.mjs +64 -0
  57. package/scripts/freeze.mjs +74 -0
  58. package/scripts/frontdoor-parity.mjs +180 -0
  59. package/scripts/hook.mjs +67 -0
  60. package/scripts/identity-rekey.mjs +112 -0
  61. package/scripts/init.mjs +122 -0
  62. package/scripts/land.mjs +40 -0
  63. package/scripts/lib/adapters.mjs +156 -0
  64. package/scripts/lib/board-snapshot.mjs +511 -0
  65. package/scripts/lib/draft.mjs +385 -0
  66. package/scripts/lib/graduate.mjs +276 -0
  67. package/scripts/lib/instance.mjs +74 -0
  68. package/scripts/lib/marks.mjs +107 -0
  69. package/scripts/lib/materialize.mjs +138 -0
  70. package/scripts/lib/merge3.mjs +124 -0
  71. package/scripts/lib/offline-posture.mjs +120 -0
  72. package/scripts/lib/offline-respawn.mjs +18 -0
  73. package/scripts/lib/offline-wrangler.mjs +78 -0
  74. package/scripts/lib/publish-compose.mjs +12 -0
  75. package/scripts/lib/publish-conflict.mjs +78 -0
  76. package/scripts/lib/publish-evidence.mjs +144 -0
  77. package/scripts/lib/refine-compare.mjs +140 -0
  78. package/scripts/lib/refine-ledger.mjs +150 -0
  79. package/scripts/lib/refine-png.mjs +149 -0
  80. package/scripts/lib/seed-pack-build.mjs +141 -0
  81. package/scripts/lib/state-compare.mjs +68 -0
  82. package/scripts/lib/store.mjs +122 -0
  83. package/scripts/login.mjs +84 -0
  84. package/scripts/mark.mjs +112 -0
  85. package/scripts/migrate-board-keys.mjs +164 -0
  86. package/scripts/migrate.mjs +265 -0
  87. package/scripts/no-foreign-vocabulary.mjs +216 -0
  88. package/scripts/no-product-names.mjs +157 -0
  89. package/scripts/no-tenant-globals.mjs +1134 -0
  90. package/scripts/offline.mjs +247 -0
  91. package/scripts/og.mjs +170 -0
  92. package/scripts/one-tenant-resolver.mjs +146 -0
  93. package/scripts/open.mjs +63 -0
  94. package/scripts/publish.mjs +1041 -0
  95. package/scripts/read.mjs +31 -0
  96. package/scripts/refine.mjs +455 -0
  97. package/scripts/release-drift.mjs +138 -0
  98. package/scripts/restore.mjs +322 -0
  99. package/scripts/review.mjs +125 -0
  100. package/scripts/save.mjs +21 -0
  101. package/scripts/shell-lint.mjs +189 -0
  102. package/scripts/ship.mjs +460 -0
  103. package/scripts/shoot.mjs +264 -0
  104. package/scripts/smoke-invite.mjs +113 -0
  105. package/scripts/state-inventory.mjs +223 -0
  106. package/scripts/status.mjs +177 -0
  107. package/scripts/sync.mjs +27 -0
  108. package/scripts/tenant-do-rehearsal.mjs +1314 -0
  109. package/scripts/ui-copy-lint.mjs +202 -0
  110. package/scripts/watch.mjs +32 -0
  111. package/scripts/wrangler-preflight.mjs +246 -0
  112. package/seed/CANON.md +43 -0
  113. package/seed/README.md +119 -0
  114. package/seed/prototype-status.json +9 -0
  115. package/seed/registry.json +88 -0
  116. package/seed/skills/starter-ui/SKILL.md +52 -0
  117. package/seed/skills/starter-ui/skill.json +11 -0
  118. package/seed/skills/starter-ui/starter-tokens.css +73 -0
  119. package/seed/skills/starter-ui/starter-ui.css +318 -0
  120. package/seed/skills/starter-ui/starter-ui.js +50 -0
  121. package/seed/space.json +5 -0
  122. package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
  123. package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
  124. package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
  125. package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
  126. package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
  127. package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
  128. package/seed/threads.json +52 -0
  129. package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
  130. package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
  131. package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
  132. package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
  133. package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
  134. package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
  135. package/src/_worker.js +12668 -0
  136. package/src/board-key.mjs +57 -0
  137. package/src/board-room.mjs +881 -0
  138. package/src/bundle-keys.mjs +138 -0
  139. package/src/canon/collect-in-browser.js +215 -0
  140. package/src/canon/emit.mjs +232 -0
  141. package/src/canon/observe.mjs +260 -0
  142. package/src/canon/schema.mjs +451 -0
  143. package/src/canvas/DSEG-LICENSE.txt +95 -0
  144. package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
  145. package/src/canvas/canvas.css +885 -0
  146. package/src/canvas/canvas.js +5780 -0
  147. package/src/canvas/capture.js +485 -0
  148. package/src/chrome/appchrome.mjs +568 -0
  149. package/src/currency.mjs +203 -0
  150. package/src/delete-confirmation.mjs +254 -0
  151. package/src/drafts/drafts.js +326 -0
  152. package/src/entry.js +58 -0
  153. package/src/galleries.mjs +400 -0
  154. package/src/health-cron.mjs +173 -0
  155. package/src/identity.json +1 -0
  156. package/src/kv-codec.mjs +135 -0
  157. package/src/kv-identity.mjs +259 -0
  158. package/src/mail.mjs +599 -0
  159. package/src/provenance.mjs +74 -0
  160. package/src/publish-compose.mjs +363 -0
  161. package/src/publish-fork.mjs +221 -0
  162. package/src/publish-units.mjs +44 -0
  163. package/src/purge.mjs +81 -0
  164. package/src/review/COMMENTING-UX.md +306 -0
  165. package/src/review/cat.png +0 -0
  166. package/src/review/comment-cursor.svg +7 -0
  167. package/src/review/comments.js +1738 -0
  168. package/src/room-ticket.mjs +96 -0
  169. package/src/roster-chip.mjs +32 -0
  170. package/src/seed-pack.mjs +261 -0
  171. package/src/state-inventory.mjs +249 -0
  172. package/src/sw-logic.mjs +48 -0
  173. package/src/tenant-cache.mjs +141 -0
  174. package/src/tenant-context.mjs +386 -0
  175. package/src/tenant-do.js +2940 -0
  176. package/src/tenant-host.mjs +144 -0
  177. package/src/tenant-quotas.mjs +140 -0
  178. package/src/unit-core.mjs +103 -0
  179. package/src/unit-object.mjs +338 -0
  180. package/templates/README.md +67 -0
  181. package/templates/shell/deploy.yml +145 -0
  182. package/templates/shell/engine-bump.yml +142 -0
  183. package/templates/shell/health.yml +233 -0
  184. package/templates/shell/kv-backup.yml +201 -0
  185. package/templates/shell/roster-update.yml +110 -0
  186. package/templates/shell/space-preflight.yml +35 -0
  187. package/templates/shell/space-rebake.yml +79 -0
  188. package/templates/shell/store-backup.yml +152 -0
  189. package/templates/shell/wrangler.example.toml +266 -0
  190. 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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
+ }