@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/currency.mjs
ADDED
|
@@ -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
|
+
}
|