@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
@@ -0,0 +1,203 @@
1
+ // What is current here, and what has been left behind.
2
+ //
3
+ // `F-currency-default`. Divergence is cheap in a workspace — a second version of a screen
4
+ // costs a folder — so the shelf fills with abandoned paths fast, and a repository where the
5
+ // live thing and the dead thing look identical has stopped being a repository. The failure
6
+ // is quiet: nothing breaks, nothing 404s, the gallery just slowly stops meaning anything.
7
+ //
8
+ // The two facts that answer it were already recorded, in two different places, and neither
9
+ // was on a card:
10
+ //
11
+ // · STATUS — what a person said about this unit — is a row in the workspace overlay,
12
+ // written by the chip on the card and read by `/__status`. It was visible only as a
13
+ // 20px glyph on the preview image, and only to somebody who already knew to hover it.
14
+ // · EDITED-AT — when this unit's bytes last actually changed — is stamped per file at
15
+ // COMMIT by the publish handler and carried forward untouched for every file a publish
16
+ // did not change. It was rendered nowhere at all.
17
+ //
18
+ // ⚠️ STALENESS IS DERIVED, AND MUST STAY DERIVED. The obvious shape — an "archived" flag
19
+ // somebody sets — is the bug wearing the fix's clothes: the person who abandons a
20
+ // prototype is by definition not coming back to tick a box, so the flag is accurate only
21
+ // for the units that were never the problem. Everything here is computed from `editedAt`
22
+ // and a clock. There is NO new stored field, and adding one would put the junk drawer back.
23
+ //
24
+ // ⚠️ ABSENT IS AN ANSWER, AND IT IS NOT "FRESH". A file published before the stamp existed
25
+ // carries no `editedAt`, so its unit's `stale` is `null` — unknown — and it gets no
26
+ // treatment at all. Defaulting the unknown to either end would be inventing the exact fact
27
+ // the stamp exists to stop being invented: false-stale accuses somebody's live work, and
28
+ // false-fresh is the junk drawer with a clean bill of health. The treatment therefore turns
29
+ // on unit by unit as content is republished, which is slower than a flag day and true.
30
+ //
31
+ // The module is PURE and lives in `src/` because three callers need to agree: the worker
32
+ // (which answers `/__currency` and `/__publish/<space>/currency`), build.js (which bakes
33
+ // the first paint), and the suite. Same reason `src/publish-units.mjs` lives here.
34
+
35
+ import { spanWords, relTime } from "./chrome/appchrome.mjs";
36
+ import { authoredUnits } from "./publish-units.mjs";
37
+
38
+ /**
39
+ * How long a unit may go untouched before the gallery says so, in days.
40
+ *
41
+ * NINETY, and the number is a judgement, so here is the judgement. It is a full planning
42
+ * quarter: a prototype somebody returns to once a cycle — the norm for anything still
43
+ * being argued about — never gets marked, and only something skipped for an entire cycle
44
+ * does. The failure directions are not symmetrical, which is what sets the floor rather
45
+ * than the ceiling. A false stale mark is an accusation against live work and costs the
46
+ * signal its credibility the first time somebody sees it on the thing they shipped
47
+ * yesterday; a late one costs a few weeks of a card looking ordinary. So the threshold is
48
+ * set where it cannot fire on ordinary working rhythm — a holiday, a re-org, a quarter
49
+ * spent on something else — and it is one number in one place, echoed in every answer
50
+ * (`staleAfterDays`) so no client and no agent ever hardcodes a second copy of it.
51
+ */
52
+ export const STALE_AFTER_DAYS = 90;
53
+
54
+ const DAY_MS = 86400000;
55
+
56
+ /**
57
+ * The status vocabulary's WORDS. One table — the worker validates against its keys, build.js
58
+ * takes its labels, and the currency line prints them — because the whole point of this
59
+ * item is that a status is legible without hovering anything, and a status spelled two ways
60
+ * on two surfaces is not legible.
61
+ */
62
+ export const STATUS_LABELS = Object.freeze({
63
+ "in-progress": "In progress",
64
+ "dev-ready": "Dev ready",
65
+ ignore: "Ignore",
66
+ reviewed: "Reviewed",
67
+ });
68
+
69
+ // A poster is a BUILD OUTPUT committed back into the folder, so a reshoot is not an edit —
70
+ // and one reshoot touches every folder on the site at once. Counting it would move every
71
+ // card to "edited now" in a single commit, which is not a hypothetical: it happened, to 76
72
+ // folders, and it is why the git-derived pass carries the same exclusion. build.js imports
73
+ // THIS test rather than keeping its own, so the stamp-derived answer and the git-derived
74
+ // answer cannot come to disagree about what a person wrote.
75
+ //
76
+ // A regex LITERAL, not a `new RegExp` built from a list of basenames: the module-scope lint
77
+ // (scripts/no-tenant-globals.mjs) can prove a literal holds no state and cannot prove it of
78
+ // a constructor call, so the prettier version costs two allowlist entries and buys nothing.
79
+ const GENERATED_ASSET = /(^|\/)(preview\.webp|og\.jpg)$/i;
80
+
81
+ /** Is this path a build output rather than something a person wrote? */
82
+ export const isGeneratedAsset = (p) => GENERATED_ASSET.test(String(p == null ? "" : p));
83
+
84
+ const decode = (s) => { try { return decodeURIComponent(String(s)); } catch (e) { return String(s); } };
85
+
86
+ /**
87
+ * The overlay status key for a unit path.
88
+ *
89
+ * `/checkout/flow/` is the unit; `checkout/flow` is the key the status chip has always
90
+ * written under (`data-status-key`). Decoded and unslashed, and nothing more — the two
91
+ * spellings must keep meeting, so this is the one place that converts between them.
92
+ */
93
+ export const unitKey = (unitPath) => decode(unitPath).replace(/^\/+|\/+$/g, "");
94
+
95
+ /**
96
+ * When this unit's bytes last changed, and who by, from the manifest's per-file stamps.
97
+ *
98
+ * The NEWEST stamp in the folder wins: a unit is a folder and editing any file in it is
99
+ * editing the unit. Files with no stamp are skipped rather than counted as old, and a unit
100
+ * where every file is unstamped answers `null` — see the header on why that is not "fresh".
101
+ */
102
+ export function unitProvenance(manifest, unitPath) {
103
+ const prefix = decode(unitPath);
104
+ let at = 0, editedAt = null, by = null;
105
+ for (const [p, f] of Object.entries((manifest || {}).files || {})) {
106
+ if (!decode(p).startsWith(prefix)) continue;
107
+ if (isGeneratedAsset(p)) continue;
108
+ const t = f && f.editedAt ? Date.parse(f.editedAt) : NaN;
109
+ if (!Number.isFinite(t) || t <= at) continue;
110
+ at = t; editedAt = f.editedAt; by = f.by || null;
111
+ }
112
+ return { editedAt, by };
113
+ }
114
+
115
+ /**
116
+ * The whole of the staleness decision, in one function with no I/O.
117
+ *
118
+ * `stale` is a THREE-valued answer — true, false, or null for "no record" — because the
119
+ * two-valued version has to guess about unstamped content, and there is no honest guess.
120
+ */
121
+ export function freshness(editedAt, now = Date.now()) {
122
+ const t = editedAt ? Date.parse(editedAt) : NaN;
123
+ if (!Number.isFinite(t)) return { ageDays: null, stale: null };
124
+ const ageDays = Math.max(0, Math.floor((now - t) / DAY_MS));
125
+ return { ageDays, stale: ageDays >= STALE_AFTER_DAYS };
126
+ }
127
+
128
+ /**
129
+ * The sentence a card shows for a unit's freshness.
130
+ *
131
+ * A stale unit does not get the same sentence in a different colour: "Edited 7 months ago"
132
+ * asks the reader to do the arithmetic and know the threshold, and most of them will do
133
+ * neither. "Untouched for 7 months" is the finding, stated. Both come off the same counter.
134
+ */
135
+ export function whenWords(editedAt, now = Date.now()) {
136
+ const { stale } = freshness(editedAt, now);
137
+ if (stale === null) return null;
138
+ const t = Date.parse(editedAt);
139
+ return stale ? `Untouched for ${spanWords(t, now)}` : relTime(t, now);
140
+ }
141
+
142
+ /**
143
+ * How far back a `since` window reaches, in ms — `14d`, `2w`, `36h`, or a bare number of
144
+ * days. Returns 0 for anything it does not understand, so a caller refuses rather than
145
+ * silently answering a different question than the one asked.
146
+ */
147
+ export function parseSince(raw) {
148
+ const m = /^\s*(\d+)\s*([hdw])?\s*$/i.exec(String(raw == null ? "" : raw));
149
+ if (!m) return 0;
150
+ const n = parseInt(m[1], 10);
151
+ if (!n) return 0;
152
+ const unit = (m[2] || "d").toLowerCase();
153
+ return n * (unit === "h" ? 3600000 : unit === "w" ? 7 * DAY_MS : DAY_MS);
154
+ }
155
+
156
+ /**
157
+ * Every authored unit in a workspace, with what is known about it. The ONE read — the
158
+ * gallery paints from it and an agent answers "what changed here" from it, so there is no
159
+ * second definition of current to drift.
160
+ *
161
+ * `spaces` is the live manifest map (`loadManifests`'s shape). `_engine` is skipped: it is
162
+ * shared chrome, it has no authored units, and a deploy would make the whole site look
163
+ * freshly edited.
164
+ */
165
+ export function currencyRows(spaces, statuses, { now = Date.now(), sinceMs = 0 } = {}) {
166
+ const rows = [];
167
+ const floor = sinceMs ? now - sinceMs : 0;
168
+ for (const [space, manifest] of Object.entries(spaces || {})) {
169
+ if (space === "_engine") continue;
170
+ for (const path of authoredUnits(manifest)) {
171
+ const key = unitKey(path);
172
+ const { editedAt, by } = unitProvenance(manifest, path);
173
+ const { ageDays, stale } = freshness(editedAt, now);
174
+ // A `since` window asks what CHANGED. A unit with no record did not answer, and
175
+ // listing it would put "we don't know" in a list of things that happened.
176
+ if (floor && (!editedAt || Date.parse(editedAt) < floor)) continue;
177
+ const status = (statuses && Object.prototype.hasOwnProperty.call(statuses, key) && statuses[key]) || null;
178
+ rows.push({
179
+ key,
180
+ path,
181
+ space,
182
+ status,
183
+ statusLabel: (status && STATUS_LABELS[status]) || null,
184
+ editedAt: editedAt || null,
185
+ // The recorded author id, exactly as the manifest holds it: `personId(email)`, a
186
+ // one-way hash, never an address. Resolving it to a name and a face belongs at the
187
+ // render, against the roster — it is not this read's job to hand out who.
188
+ by,
189
+ ageDays,
190
+ stale,
191
+ when: whenWords(editedAt, now),
192
+ });
193
+ }
194
+ }
195
+ // Newest first, unstamped last, then by path — so the first screenful of an agent's
196
+ // answer is the answer.
197
+ rows.sort((a, b) => {
198
+ const ta = a.editedAt ? Date.parse(a.editedAt) : -Infinity;
199
+ const tb = b.editedAt ? Date.parse(b.editedAt) : -Infinity;
200
+ return tb - ta || a.path.localeCompare(b.path);
201
+ });
202
+ return rows;
203
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * What a person is shown BEFORE a workspace is deleted.
3
+ *
4
+ * `F-tenant-delete-ux`. A delete is the one act on a workspace that no rollback reaches, so
5
+ * the screen in front of it has two jobs and this module is both of them: offer the export
6
+ * first, and state the retention window truthfully.
7
+ *
8
+ * ⚠️ EVERY NUMBER HERE IS DERIVED, AND THAT IS THE WHOLE POINT OF THE MODULE. The window is
9
+ * computed from the grace the tombstone actually uses (`DELETE_GRACE_MS` in `tenant-do.js`)
10
+ * and from the backup rotation the deployment declares — never typed. A hand-typed "30 days"
11
+ * in a confirmation screen is a promise that keeps being true only for as long as nobody
12
+ * changes the constant, and the day somebody does, the screen is the last thing anyone
13
+ * thinks to check. Derived copy cannot drift: change the constant and the sentence changes
14
+ * with it, or the test that pins the sentence fails.
15
+ *
16
+ * ⚠️ IT NEVER INVENTS THE BACKUP NUMBER. The engine knows exactly how long a tombstone
17
+ * survives, because it writes that date itself. It does NOT know how long a deployment's
18
+ * off-site backup copies live — that is a rotation somebody configures outside this code —
19
+ * so `backupRetentionMs` is an input with no default, and with none supplied the copy says
20
+ * that a backup copy outlives the erasure WITHOUT naming a period. Two wrong answers were
21
+ * available and both are worse: inventing a number promises a schedule nobody runs, and
22
+ * saying nothing at all tells a customer their data is gone everywhere when a backup still
23
+ * holds it. Absent is a fact, and the copy states it as one.
24
+ *
25
+ * ⚠️ IT RENDERS NOTHING. The confirmation is CONTENT — strings and numbers — because the two
26
+ * surfaces that show it live in different repos and cannot import each other: a workspace's
27
+ * own settings here, and an operator console in the control plane. A shape that crosses the
28
+ * wire is the only kind both can read, which is why `GET /__control/delete` serves this and
29
+ * nothing hand-copies the words.
30
+ *
31
+ * No product name, no deployment name, no address: a self-hosted instance and a hosted one
32
+ * show the same words, and "whoever runs this service" is the honest way to name an operator
33
+ * this code cannot identify.
34
+ */
35
+
36
+ const DAY_MS = 24 * 60 * 60 * 1000;
37
+
38
+ /** The command that takes a copy of everything, including what `--full` adds. */
39
+ export const EXPORT_COMMAND = "augur export --full";
40
+
41
+ /** The route that command walks, for a surface that would rather link than instruct. */
42
+ export const EXPORT_PATH = "/__publish/_state/export";
43
+
44
+ /**
45
+ * Whole days, rounded DOWN, and the rounding direction is deliberate.
46
+ *
47
+ * A window stated as longer than it is, is a promise the erasure breaks. Flooring can only
48
+ * understate — somebody is told 30 days and gets 30 days and a few hours — and understating
49
+ * a deadline is the safe side of this particular wrong answer.
50
+ */
51
+ function wholeDays(ms) {
52
+ return Math.floor(ms / DAY_MS);
53
+ }
54
+
55
+ /** "1 person" / "4 people" — a count that reads as English rather than as a field. */
56
+ function plural(n, one, many) {
57
+ return `${n} ${n === 1 ? one : many}`;
58
+ }
59
+
60
+ /**
61
+ * The retention window, as numbers.
62
+ *
63
+ * `serviceDays` is what the tombstone gives you: the data is all still there, and the
64
+ * erasure refuses until the date passes. `backupDays` is the day the LAST copy anywhere
65
+ * expires — the grace plus the rotation, not the rotation on its own, because a backup taken
66
+ * the day before the erasure still holds the workspace and its own clock starts then.
67
+ * `null` means the deployment has not declared a rotation.
68
+ */
69
+ export function retentionWindow({ graceMs, backupRetentionMs = null } = {}) {
70
+ if (!Number.isFinite(graceMs) || graceMs < 0) {
71
+ throw new Error("delete-confirmation: graceMs is required and must be a finite duration");
72
+ }
73
+ const configured = Number.isFinite(backupRetentionMs) && backupRetentionMs >= 0;
74
+ return {
75
+ serviceDays: wholeDays(graceMs),
76
+ backupDays: configured ? wholeDays(graceMs + backupRetentionMs) : null,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * The sentence the published lifecycle policy owns, rebuilt from the live numbers.
82
+ *
83
+ * ⚠️ BYTE-IDENTICAL, ON PURPOSE, to the promise a customer already read before they got
84
+ * here: "gone from the service in 30 days, gone from the backups within 70". A confirmation
85
+ * screen that paraphrases the policy is a second policy, and the second one is the one
86
+ * nobody updates. Lower case and no full stop, because it is a CLAUSE — the policy page
87
+ * lands it mid-sentence and a confirmation screen wants a capital, so both grow their own
88
+ * punctuation from the same words rather than each keeping a copy of the sentence.
89
+ *
90
+ * With no rotation declared the clause stops at the half this code can prove.
91
+ */
92
+ export function retentionClause(window) {
93
+ const service = `gone from the service in ${plural(window.serviceDays, "day", "days")}`;
94
+ if (window.backupDays == null) return service;
95
+ return `${service}, gone from the backups within ${window.backupDays}`;
96
+ }
97
+
98
+ function sentence(clause) {
99
+ return `${clause.charAt(0).toUpperCase()}${clause.slice(1)}.`;
100
+ }
101
+
102
+ /**
103
+ * What this workspace holds, as counts.
104
+ *
105
+ * Straight off `status()`, so a confirmation cannot disagree with the workspace's own
106
+ * account of itself, and counts ONLY — the same rule `status()` keeps. A confirmation screen
107
+ * is read by an operator who administers somebody else's workspace, and a comment body has
108
+ * no business being anywhere near one.
109
+ *
110
+ * Zeroes are dropped rather than listed: "0 boards" is noise on a screen whose job is to
111
+ * make somebody hesitate over what is actually there.
112
+ */
113
+ function holdings(status) {
114
+ if (!status) return { items: [], line: null };
115
+ const items = [
116
+ ["members", plural(status.members || 0, "person", "people")],
117
+ ["threads", plural(status.threads || 0, "comment thread", "comment threads")],
118
+ ["boards", plural(status.boards || 0, "board", "boards")],
119
+ ["images", plural(status.images || 0, "image", "images")],
120
+ ]
121
+ .filter(([k]) => Number(status[k] || 0) > 0)
122
+ .map(([, text]) => text);
123
+ if (!items.length) return { items: [], line: null };
124
+ const line = items.length === 1
125
+ ? items[0]
126
+ : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
127
+ return { items, line };
128
+ }
129
+
130
+ /**
131
+ * The whole confirmation, for one workspace, at one instant.
132
+ *
133
+ * `at` is passed in rather than read from the clock so the erasure date on the screen and
134
+ * the erasure date the delete writes are the same arithmetic on the same instant. A screen
135
+ * that computed its own "now" would be off by the time somebody spent reading it, which is
136
+ * harmless until the day it straddles midnight and the date shown is not the date written.
137
+ *
138
+ * `alreadyDeleted` is not an error case: somebody reaching this screen for a workspace that
139
+ * is already tombstoned needs the date it is erased on, not a second chance to delete it.
140
+ */
141
+ export function deleteConfirmation({
142
+ workspaceId,
143
+ graceMs,
144
+ at = Date.now(),
145
+ backupRetentionMs = null,
146
+ status = null,
147
+ } = {}) {
148
+ const id = String(workspaceId || "");
149
+ const window = retentionWindow({ graceMs, backupRetentionMs });
150
+ const clause = retentionClause(window);
151
+ const startedAt = status && status.deletedAt ? Date.parse(status.deletedAt) : NaN;
152
+ const already = !!(status && status.deleted);
153
+ // An existing tombstone keeps its own date. Re-deleting does not restart the clock
154
+ // (`deleteWorkspace` refuses to), so a screen offering a fresh window would be lying about
155
+ // arithmetic that has already happened.
156
+ const erasedAt = already && status.purgeAfter
157
+ ? Date.parse(status.purgeAfter)
158
+ : (Number.isFinite(startedAt) ? startedAt : Number(at)) + graceMs;
159
+ const erasedOn = new Date(erasedAt).toISOString();
160
+ const holds = holdings(status);
161
+
162
+ const timeline = [
163
+ {
164
+ when: "Immediately",
165
+ what: "The workspace stops being served. Its address goes dark, signing in to it "
166
+ + "stops, and nothing it published is reachable by anyone.",
167
+ },
168
+ {
169
+ when: `For ${plural(window.serviceDays, "day", "days")}`,
170
+ what: "The data still exists, tombstoned. Nothing has been erased, and a delete "
171
+ + "somebody regrets can still be undone by whoever runs this service.",
172
+ },
173
+ {
174
+ when: `On ${erasedOn.slice(0, 10)}`,
175
+ what: "Everything is erased from the live service: published content, uploads, "
176
+ + "comments, boards, roster, history.",
177
+ },
178
+ window.backupDays == null
179
+ ? {
180
+ when: "After that",
181
+ what: "A backup copy can outlive the erasure. Backups are kept on their own "
182
+ + "rotation and are not thinned to remove one workspace — the last copy is "
183
+ + "waited out rather than rewritten.",
184
+ }
185
+ : {
186
+ when: `Within ${plural(window.backupDays - window.serviceDays, "further day", "further days")}`,
187
+ what: "The last backup copy containing it expires. After that no copy exists "
188
+ + "anywhere, and there is nothing left for anyone to restore.",
189
+ },
190
+ ];
191
+
192
+ return {
193
+ workspace: id,
194
+ title: already ? `${id} is already deleted` : `Delete ${id}?`,
195
+ alreadyDeleted: already,
196
+ holds: holds.items,
197
+ holdsLine: holds.line,
198
+ // Two steps, in this order, and the order IS the feature. The export is offered BEFORE
199
+ // the confirm rather than beside it, because the only copy that survives a delete is the
200
+ // one somebody took first, and a nudge that shares a row with the button that deletes is
201
+ // a nudge nobody reads.
202
+ steps: [
203
+ {
204
+ id: "export",
205
+ title: already ? "You can still take a copy" : "Take a copy first",
206
+ body: already
207
+ ? `A full export still runs on a deleted workspace until it is erased. `
208
+ + `Run ‘${EXPORT_COMMAND}’ while the data is still here.`
209
+ : `Run ‘${EXPORT_COMMAND}’ before you confirm. It is one command, it `
210
+ + `takes minutes, and it is the only copy that is definitely yours.`,
211
+ command: EXPORT_COMMAND,
212
+ path: EXPORT_PATH,
213
+ },
214
+ {
215
+ id: "confirm",
216
+ title: already ? "Nothing further to confirm" : "Then confirm",
217
+ body: already
218
+ ? `This workspace is already tombstoned. Deleting it again changes nothing and `
219
+ + `does not move the date below.`
220
+ : `Type the workspace's name to confirm. There is no undo once the date below `
221
+ + `passes.`,
222
+ confirmWith: already ? null : id,
223
+ },
224
+ ],
225
+ timeline,
226
+ retention: {
227
+ serviceDays: window.serviceDays,
228
+ backupDays: window.backupDays,
229
+ graceMs,
230
+ erasedOn,
231
+ // The clause the published policy owns, and the sentence a screen shows. Both from
232
+ // the same words — see retentionClause.
233
+ clause,
234
+ summary: sentence(clause),
235
+ },
236
+ };
237
+ }
238
+
239
+ /**
240
+ * A deployment's declared backup rotation, in milliseconds, or `null`.
241
+ *
242
+ * ⚠️ IT FAILS TO `null`, NEVER TO A NUMBER. Unset, blank, negative, non-numeric — every one
243
+ * of them means "this deployment has not told me", and the copy then states the backup tail
244
+ * without a period rather than guessing at one. A default here would be the invented number
245
+ * this module exists to refuse: an operator who never configured a rotation would get a
246
+ * confirmation screen promising a schedule that nothing runs.
247
+ */
248
+ export function backupRetentionFromEnv(env) {
249
+ const raw = env && env.BACKUP_RETENTION_DAYS;
250
+ if (raw == null || raw === "") return null;
251
+ const days = Number(raw);
252
+ if (!Number.isFinite(days) || days < 0) return null;
253
+ return days * DAY_MS;
254
+ }