@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,338 @@
1
+ // src/unit-object.mjs — one Durable Object per unit: the authority on main, drafts,
2
+ // landings and the landing lease. See docs/drafts-that-land.md §6.
3
+ //
4
+ // File BODIES never live here. A table row names a content hash; the bytes are in the
5
+ // bundle store under `blobs/<hash>`, shared and content-addressed, exactly as a publish
6
+ // leaves them. A table is stored as one JSON document per revision, well under the row
7
+ // limit for any prototype this engine has met.
8
+ //
9
+ // THE OBJECT NEVER MERGES. `save` applies a batch of changes with per-file compare-and-set
10
+ // or refuses the whole batch with the fresh hashes; `land` refuses when main moved since
11
+ // the draft's base. What to do about a refusal is the client's, on its own disk.
12
+ //
13
+ // THE LANDING LEASE. Landing writes the space manifest, and that write happens in the
14
+ // worker, outside this object's single thread. So `land` hands out a ten-second lease and
15
+ // refuses every other landing while it is held; `landed` commits under that lease. A
16
+ // worker that dies between the two leaves a lease that simply expires — nothing depends on
17
+ // it being released, and `abandon-land` is a courtesy.
18
+ import {
19
+ newDraftId, unitTable, sameTable, applyChanges, tableDelta, presenceOf, DRAFT_ID_RE,
20
+ } from "./unit-core.mjs";
21
+
22
+ export const LAND_LEASE_MS = 10_000;
23
+ export const UNIT_SCHEMA_VERSION = 1;
24
+ // A literal, not a constructed RegExp — its group mirrors DRAFT_ID_RE in unit-core.mjs.
25
+ const DRAFT_ROUTE_RE = /^\/draft\/([a-z0-9]{6})$/;
26
+
27
+ export const UNIT_SCHEMA = Object.freeze([
28
+ `CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL)`,
29
+ `CREATE TABLE IF NOT EXISTS landings (
30
+ revision INTEGER PRIMARY KEY,
31
+ tbl TEXT NOT NULL,
32
+ by TEXT,
33
+ session TEXT,
34
+ at TEXT NOT NULL,
35
+ note TEXT,
36
+ draft_id TEXT,
37
+ restored_from INTEGER
38
+ )`,
39
+ `CREATE TABLE IF NOT EXISTS drafts (
40
+ id TEXT PRIMARY KEY,
41
+ owner TEXT NOT NULL,
42
+ session TEXT,
43
+ opened_at TEXT NOT NULL,
44
+ last_save_at TEXT,
45
+ base_revision INTEGER NOT NULL,
46
+ revision INTEGER NOT NULL,
47
+ tbl TEXT NOT NULL,
48
+ closed_at TEXT,
49
+ discarded INTEGER NOT NULL DEFAULT 0
50
+ )`,
51
+ `CREATE TABLE IF NOT EXISTS draft_saves (
52
+ draft_id TEXT NOT NULL,
53
+ revision INTEGER NOT NULL,
54
+ tbl TEXT NOT NULL,
55
+ at TEXT NOT NULL,
56
+ PRIMARY KEY (draft_id, revision)
57
+ )`,
58
+ ]);
59
+
60
+ export function applyUnitSchema(sql, workspace, unit) {
61
+ for (const stmt of UNIT_SCHEMA) sql.exec(stmt);
62
+ sql.exec(`INSERT INTO meta (k, v) VALUES ('schema_version', ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v`, String(UNIT_SCHEMA_VERSION));
63
+ if (workspace) sql.exec(`INSERT INTO meta (k, v) VALUES ('workspace', ?) ON CONFLICT(k) DO NOTHING`, String(workspace));
64
+ if (unit) sql.exec(`INSERT INTO meta (k, v) VALUES ('unit', ?) ON CONFLICT(k) DO NOTHING`, String(unit));
65
+ }
66
+
67
+ const json = (obj, status = 200) => new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
68
+ const rowDraft = (r) => r && ({
69
+ id: r.id, owner: r.owner, session: r.session || "", openedAt: r.opened_at, lastSaveAt: r.last_save_at || null,
70
+ baseRevision: Number(r.base_revision), revision: Number(r.revision), table: JSON.parse(r.tbl),
71
+ closedAt: r.closed_at || null, discarded: !!r.discarded,
72
+ });
73
+
74
+ export class UnitObject {
75
+ constructor(ctx, env) {
76
+ this.ctx = ctx;
77
+ this.env = env;
78
+ this.ready = false;
79
+ }
80
+
81
+ get sql() { return this.ctx.storage.sql; }
82
+
83
+ async init(workspace, unit) {
84
+ if (this.ready) return;
85
+ const run = () => { applyUnitSchema(this.sql, workspace, unit); this.ready = true; };
86
+ if (this.ctx.blockConcurrencyWhile) await this.ctx.blockConcurrencyWhile(async () => run());
87
+ else run();
88
+ }
89
+
90
+ // ── meta ──────────────────────────────────────────────────────────────────
91
+ metaGet(k) {
92
+ const rows = [...this.sql.exec(`SELECT v FROM meta WHERE k = ?`, k)];
93
+ return rows.length ? rows[0].v : null;
94
+ }
95
+ metaSet(k, v) {
96
+ if (v === null) this.sql.exec(`DELETE FROM meta WHERE k = ?`, k);
97
+ else this.sql.exec(`INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v`, k, String(v));
98
+ }
99
+ mainRevision() { return Number(this.metaGet("main_revision") || 0); }
100
+ landing(revision) {
101
+ const rows = [...this.sql.exec(`SELECT * FROM landings WHERE revision = ?`, revision)];
102
+ return rows.length ? rows[0] : null;
103
+ }
104
+ mainTable() {
105
+ const l = this.landing(this.mainRevision());
106
+ return l ? JSON.parse(l.tbl) : {};
107
+ }
108
+ draft(id) {
109
+ const rows = [...this.sql.exec(`SELECT * FROM drafts WHERE id = ?`, id)];
110
+ return rows.length ? rowDraft(rows[0]) : null;
111
+ }
112
+ openDrafts() {
113
+ return [...this.sql.exec(`SELECT * FROM drafts WHERE closed_at IS NULL ORDER BY opened_at`)].map(rowDraft);
114
+ }
115
+ lease(nowMs) {
116
+ const raw = this.metaGet("lease");
117
+ if (!raw) return null;
118
+ const l = JSON.parse(raw);
119
+ if (Number(l.until) <= nowMs) { this.metaSet("lease", null); return null; }
120
+ return l;
121
+ }
122
+ writeLanding({ table, by, session, at, note, draftId, restoredFrom }) {
123
+ const revision = this.mainRevision() + 1;
124
+ this.sql.exec(
125
+ `INSERT INTO landings (revision, tbl, by, session, at, note, draft_id, restored_from) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
126
+ revision, JSON.stringify(table), by || null, session || null, at, note || null, draftId || null, restoredFrom == null ? null : restoredFrom,
127
+ );
128
+ this.metaSet("main_revision", revision);
129
+ return revision;
130
+ }
131
+
132
+ // ── verbs ─────────────────────────────────────────────────────────────────
133
+ syncMain({ table, at }) {
134
+ const cur = this.mainTable();
135
+ if (this.mainRevision() > 0 && sameTable(cur, table)) return { revision: this.mainRevision() };
136
+ return { revision: this.writeLanding({ table, by: "live", at, note: "adopted from live" }) };
137
+ }
138
+
139
+ open({ owner, session, at }) {
140
+ let id = newDraftId();
141
+ while (this.draft(id)) id = newDraftId();
142
+ const base = this.mainRevision();
143
+ const table = this.mainTable();
144
+ this.sql.exec(
145
+ `INSERT INTO drafts (id, owner, session, opened_at, base_revision, revision, tbl) VALUES (?, ?, ?, ?, ?, 0, ?)`,
146
+ id, String(owner || ""), String(session || ""), at, base, JSON.stringify(table),
147
+ );
148
+ return { draftId: id, baseRevision: base, table, presence: presenceOf(this.openDrafts(), Date.parse(at)) };
149
+ }
150
+
151
+ save({ draftId, draftRevision, changes, baseRevision, at }) {
152
+ const d = this.draft(draftId);
153
+ if (!d || d.closedAt) return [404, { error: "unknown-draft" }];
154
+ const held = this.lease(Date.parse(at));
155
+ if (held && held.draftId === draftId) return [409, { error: "landing-in-progress" }];
156
+ if (Number(draftRevision) !== d.revision) return [409, { error: "stale-draft-revision", draftRevision: d.revision }];
157
+ if (baseRevision !== undefined) {
158
+ const b = Number(baseRevision);
159
+ if (!Number.isInteger(b) || b < d.baseRevision || b > this.mainRevision()) return [400, { error: "bad-base", mainRevision: this.mainRevision() }];
160
+ }
161
+ const r = applyChanges(d.table, Array.isArray(changes) ? changes : []);
162
+ if (!r.ok) return [409, { error: "stale-draft", stale: r.stale }];
163
+ const revision = d.revision + 1;
164
+ this.sql.exec(
165
+ `UPDATE drafts SET revision = ?, tbl = ?, last_save_at = ?, base_revision = ? WHERE id = ?`,
166
+ revision, JSON.stringify(r.table), at, baseRevision === undefined ? d.baseRevision : Number(baseRevision), draftId,
167
+ );
168
+ this.sql.exec(`INSERT INTO draft_saves (draft_id, revision, tbl, at) VALUES (?, ?, ?, ?)`, draftId, revision, JSON.stringify(r.table), at);
169
+ return [200, { draftRevision: revision, table: r.table }];
170
+ }
171
+
172
+ takeLease({ draftId, table, at, restoredFrom }) {
173
+ const nowMs = Date.parse(at);
174
+ const held = this.lease(nowMs);
175
+ if (held && held.draftId !== (draftId || null)) return [409, { error: "landing-in-progress" }];
176
+ const lease = newDraftId() + newDraftId();
177
+ const revision = this.mainRevision() + 1;
178
+ this.metaSet("lease", JSON.stringify({
179
+ token: lease, draftId: draftId || null, revision, until: nowMs + LAND_LEASE_MS,
180
+ table, restoredFrom: restoredFrom == null ? null : restoredFrom,
181
+ }));
182
+ const delta = tableDelta(this.mainTable(), table);
183
+ return [200, { lease, revision, table, ...delta }];
184
+ }
185
+
186
+ land({ draftId, baseRevision, at }) {
187
+ const d = this.draft(draftId);
188
+ if (!d || d.closedAt) return [404, { error: "unknown-draft" }];
189
+ const main = this.mainRevision();
190
+ if (d.baseRevision !== main || Number(baseRevision) !== main) {
191
+ const base = this.landing(d.baseRevision);
192
+ const delta = tableDelta(base ? JSON.parse(base.tbl) : {}, this.mainTable());
193
+ return [409, { error: "main-moved", mainRevision: main, ...delta }];
194
+ }
195
+ // AN EMPTIED DRAFT IS NOT AN UNPUBLISH REQUEST. A draft whose every file is gone lands
196
+ // a unit with nothing behind it: the URL goes dark for everyone, the prefix is left
197
+ // declared, and the landing reads as a success. The one thing that reliably produces
198
+ // it is a folder that failed to materialise — a killed open, a disk that filled — so
199
+ // the answer is a refusal rather than a confirmation the object has no way to ask for.
200
+ // Deleting a prototype is its own verb, with its own confirmation.
201
+ if (!Object.keys(d.table).length && Object.keys(this.mainTable()).length) {
202
+ return [409, { error: "would-unpublish" }];
203
+ }
204
+ return this.takeLease({ draftId, table: d.table, at });
205
+ }
206
+
207
+ restore({ revision, at }) {
208
+ const l = this.landing(Number(revision));
209
+ if (!l) return [404, { error: "unknown-revision" }];
210
+ return this.takeLease({ draftId: null, table: JSON.parse(l.tbl), at, restoredFrom: Number(revision) });
211
+ }
212
+
213
+ landed({ lease, draftId, note, by, session, at }) {
214
+ const held = this.lease(Date.parse(at));
215
+ if (!held || held.token !== lease) return [409, { error: "bad-lease" }];
216
+ const table = held.table;
217
+ const revision = this.writeLanding({
218
+ table, by, session, at, note, draftId: held.draftId || draftId || null, restoredFrom: held.restoredFrom,
219
+ });
220
+ if (held.draftId) this.sql.exec(`UPDATE drafts SET closed_at = ? WHERE id = ?`, at, held.draftId);
221
+ this.metaSet("lease", null);
222
+ return [200, { revision }];
223
+ }
224
+
225
+ abandonLand({ lease }) {
226
+ const raw = this.metaGet("lease");
227
+ if (raw && JSON.parse(raw).token === lease) this.metaSet("lease", null);
228
+ return [200, { ok: true }];
229
+ }
230
+
231
+ sync({ draftId }) {
232
+ const d = this.draft(draftId);
233
+ if (!d || d.closedAt) return [404, { error: "unknown-draft" }];
234
+ const base = this.landing(d.baseRevision);
235
+ const delta = tableDelta(base ? JSON.parse(base.tbl) : {}, this.mainTable());
236
+ return [200, { mainRevision: this.mainRevision(), baseRevision: d.baseRevision, ...delta }];
237
+ }
238
+
239
+ discard({ draftId, at }) {
240
+ const d = this.draft(draftId);
241
+ if (!d || d.closedAt) return [404, { error: "unknown-draft" }];
242
+ this.sql.exec(`UPDATE drafts SET closed_at = ?, discarded = 1 WHERE id = ?`, at, draftId);
243
+ return [200, { closed: true }];
244
+ }
245
+
246
+ history() {
247
+ const rows = [...this.sql.exec(`SELECT * FROM landings ORDER BY revision DESC`)];
248
+ return {
249
+ revision: this.mainRevision(),
250
+ landings: rows.map((r) => ({
251
+ revision: Number(r.revision), by: r.by || null, session: r.session || "", at: r.at, note: r.note || "",
252
+ draftId: r.draft_id || null, restoredFrom: r.restored_from == null ? null : Number(r.restored_from),
253
+ files: Object.keys(JSON.parse(r.tbl)).length,
254
+ })),
255
+ };
256
+ }
257
+
258
+ // ── live tabs ─────────────────────────────────────────────────────────────
259
+ // One socket per open tab, accepted with the Hibernation API so an idle unit with tabs
260
+ // on it costs nothing. A SOCKET CARRIES NO AUTHORITY: the worker authenticated the
261
+ // person before forwarding the Upgrade, and every message sent here is a fact any member
262
+ // may read anyway — a draft saved, a landing happened. Nothing about a socket is stored
263
+ // beyond its attachment, nothing waits for one, and a verb's answer never depends on a
264
+ // send having worked: `emit` is fire-and-forget and closes what it cannot reach.
265
+ socket(request, url) {
266
+ if ((request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") return json({ error: "expected-websocket" }, 426);
267
+ const draft = url.searchParams.get("draft") || null;
268
+ if (draft && !DRAFT_ID_RE.test(draft)) return json({ error: "bad-draft" }, 400);
269
+ if (typeof WebSocketPair !== "function" || !this.ctx.acceptWebSocket) return json({ error: "sockets-unavailable" }, 501);
270
+ const pair = new WebSocketPair();
271
+ const [client, server] = Object.values(pair);
272
+ this.ctx.acceptWebSocket(server);
273
+ server.serializeAttachment({ draft, since: Date.now() });
274
+ return new Response(null, { status: 101, webSocket: client });
275
+ }
276
+ emit(msg) {
277
+ if (!this.ctx.getWebSockets) return;
278
+ const raw = JSON.stringify(msg);
279
+ for (const ws of this.ctx.getWebSockets()) {
280
+ try { ws.send(raw); } catch (e) { try { ws.close(1011, "send failed"); } catch (e2) { /* already gone */ } }
281
+ }
282
+ }
283
+ webSocketMessage(ws, raw) {
284
+ if (raw === "ping") { try { ws.send("pong"); } catch (e) { /* closing */ } }
285
+ }
286
+ webSocketClose() { /* the runtime owns the socket list; nothing to clean up */ }
287
+ webSocketError() { /* same */ }
288
+
289
+ // ── router ────────────────────────────────────────────────────────────────
290
+ async fetch(request) {
291
+ const url = new URL(request.url);
292
+ const route = url.pathname;
293
+ let body = {};
294
+ if (request.method === "POST") {
295
+ try { body = await request.json(); } catch (e) { return json({ error: "bad-json" }, 400); }
296
+ }
297
+ // Every verb but sync-main assumes the schema is there; sync-main is what the worker
298
+ // calls first on every request, carrying the names the schema is stamped with.
299
+ if (route === "/sync-main") {
300
+ await this.init(body.workspace, body.unit);
301
+ return json(this.syncMain({ table: unitTable(body.table || {}, body.unit || ""), at: body.at || new Date().toISOString() }));
302
+ }
303
+ if (route === "/socket") return this.socket(request, url);
304
+ await this.init(null, null);
305
+ if (request.method === "GET") {
306
+ if (route === "/presence") return json({ drafts: presenceOf(this.openDrafts(), Date.parse(url.searchParams.get("at") || "") || Date.now()) });
307
+ if (route === "/history") return json(this.history());
308
+ if (route === "/main") return json({ revision: this.mainRevision(), table: this.mainTable() });
309
+ const m = DRAFT_ROUTE_RE.exec(route);
310
+ if (m) {
311
+ const d = this.draft(m[1]);
312
+ if (!d || d.discarded) return json({ error: "unknown-draft" }, 404);
313
+ return json({ draftId: d.id, table: d.table, owner: d.owner, session: d.session, openedAt: d.openedAt, lastSaveAt: d.lastSaveAt, baseRevision: d.baseRevision, revision: d.revision, closedAt: d.closedAt });
314
+ }
315
+ return json({ error: "unknown-route" }, 404);
316
+ }
317
+ const at = body.at || new Date().toISOString();
318
+ const verbs = {
319
+ "/open": () => [200, this.open({ ...body, at })],
320
+ "/save": () => this.save({ ...body, at }),
321
+ "/land": () => this.land({ ...body, at }),
322
+ "/restore": () => this.restore({ ...body, at }),
323
+ "/landed": () => this.landed({ ...body, at }),
324
+ "/abandon-land": () => this.abandonLand(body),
325
+ "/sync": () => this.sync(body),
326
+ "/discard": () => this.discard({ ...body, at }),
327
+ };
328
+ if (!verbs[route]) return json({ error: "unknown-route" }, 404);
329
+ const [status, out] = verbs[route]();
330
+ if (status === 200) {
331
+ if (route === "/open") this.emit({ t: "open", draftId: out.draftId, at });
332
+ else if (route === "/save") this.emit({ t: "save", draftId: body.draftId, revision: out.draftRevision, at });
333
+ else if (route === "/landed") this.emit({ t: "land", revision: out.revision, draftId: body.draftId || null, at });
334
+ else if (route === "/discard") this.emit({ t: "discard", draftId: body.draftId, at });
335
+ }
336
+ return json(out, status);
337
+ }
338
+ }
@@ -0,0 +1,67 @@
1
+ # Deploy templates
2
+
3
+ Copy-paste workflows for a **deploy shell** — the private repo that pins this engine,
4
+ holds the user list and the secrets, and ships the site. The engine deploys nothing
5
+ itself.
6
+
7
+ **The recipe that uses these files is [INSTALL.md](../INSTALL.md)** — read that first;
8
+ this page is only the file index.
9
+
10
+ ```
11
+ templates/shell/ → <shell-repo>/.github/workflows/
12
+ ├── deploy.yml # REQUIRED. Build engine chrome → Pages → publish chrome to the store.
13
+ ├── engine-bump.yml # REQUIRED in practice. Take engine updates on your own schedule.
14
+ ├── health.yml # Canary: pushed-but-never-published drift, stale dirty publishes.
15
+ ├── store-backup.yml # Weekly + monthly off-Cloudflare copies of the bundle store.
16
+ ├── kv-backup.yml # Nightly copy of KV — the half store-backup does not cover.
17
+ ├── space-preflight.yml # Probe that CI's PAT can read a space repo before you add it.
18
+ └── roster-update.yml # Commit Admin-panel invites/removals back to identity.json.
19
+
20
+ templates/space/ → <space-repo>/.github/workflows/ (OPTIONAL — see below)
21
+ └── publish.yml # Auto-publish on push + keep baked chrome current with the engine.
22
+ ```
23
+
24
+ Drift between a shell and these files is caught by `shell-lint` — run it from a shell
25
+ (`node engine/scripts/shell-lint.mjs`), where the engine submodule IS that shell's
26
+ pinned engine. `health.yml` runs it on every canary pass. Filled-in placeholders and
27
+ reworded comments stay quiet; changed behaviour fails.
28
+
29
+ Each file carries its own header explaining what it does and what it needs. Three of them
30
+ have an instance value to fill in before first use — the Pages project name and site
31
+ origin in `deploy.yml`, the site origin in `health.yml` and `store-backup.yml`.
32
+
33
+ **The two backups do not overlap, and a shell needs both.** `store-backup.yml` copies
34
+ published *content* out of the bundle store (R2). `kv-backup.yml` copies the mutable
35
+ *state* the worker keeps alongside it — comment threads, statuses, pins, renames,
36
+ canvases, and the identity records below. Neither store has point-in-time restore, and
37
+ neither backup covers the other, so running only one is being half-backed-up while
38
+ reading as backed-up.
39
+
40
+ ⚠️ `kv-backup.yml` commits the **whole** namespace to a branch on the shell repo, and
41
+ that includes `users:secrets` (password hashes, and the tombstones that hold reset
42
+ passwords out of service) and `publish:tokens` (live bearer tokens that can overwrite
43
+ published content). **Do not enable it on a public shell.** Everything else here is
44
+ safe on one; this is not.
45
+
46
+ **Keeping baked chrome current is the SHELL's job, not the space's.** Page-level chrome
47
+ (rail, overlays, layout) is baked into each page at publish time (`/_build.json`
48
+ `builtWithEngine`), so an engine bump refreshes the *serving* engine but leaves
49
+ already-published pages on older chrome until the space republishes. `deploy.yml` closes
50
+ that with a `rebake` job: when the engine pin moves it clones each roster space and
51
+ re-publishes it with the shell's own `*`-scoped `AUGUR_TOKEN` (the one it already uses for
52
+ `--engine`). The star token therefore lives ONLY in the private shell — never in a
53
+ (possibly public) space repo — and **no space needs a token or CI just to stay on the
54
+ current chrome.** `health.yml` check (f) alarms if a space is ever left on chrome older
55
+ than the deployed engine.
56
+
57
+ **`templates/space/publish.yml` is optional and only for a PUBLIC auto-publish space** —
58
+ a demo whose last pusher is often not the person who'd remember to publish. It joins
59
+ push→publish so content goes live on push, running the same `augur publish` client with a
60
+ token scoped to that space (not `*`, since the repo may be public). A working space needs
61
+ none of this: it publishes deliberately from a terminal, and its chrome is re-baked by the
62
+ shell above. A bare space repo is just a `space.json` and some `prototypes/` folders — no
63
+ CI, no secret, no submodule mount.
64
+
65
+ Engine missing something your instance needs? Don't patch your copy — **open a PR
66
+ upstream** ([CONTRIBUTING.md](../CONTRIBUTING.md)) and take it back via pin bump. That
67
+ keeps your instance on the update train and ships your fix to everyone.
@@ -0,0 +1,145 @@
1
+ name: Deploy to Cloudflare Pages
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ workflow_dispatch: # allow manual runs from the Actions tab
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ concurrency:
12
+ group: pages-deploy
13
+ cancel-in-progress: true
14
+
15
+ jobs:
16
+ deploy:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ # A deploy shell composes ONLY the Augur ENGINE (submodule at engine/). No
20
+ # space repo is mounted here and none is checked out: space content reaches
21
+ # production exclusively through `augur publish` from a space clone, into the
22
+ # bundle store the worker serves from.
23
+ #
24
+ # If you are tempted to mount spaces here and build the whole site: that was
25
+ # the previous design, and it gave every page two sources of content with
26
+ # nothing to tell them apart. The store shadowed the built assets at serve
27
+ # time, so a submodule pin could sit weeks behind its space repo while the
28
+ # live URL served something else — and bumping the pin changed nothing,
29
+ # because pins were never the serving path. Keep the roster of space repos in
30
+ # deploy.config.json (`spaces`) for repo-side automation instead.
31
+ #
32
+ # The engine is public; SUBMODULE_PAT is only needed because actions/checkout
33
+ # uses one token for the host repo and its submodules, and this shell repo is
34
+ # private. (.gitmodules URLs must stay HTTPS — checkout cannot authenticate
35
+ # SSH URLs.)
36
+ - uses: actions/checkout@v4
37
+ with:
38
+ fetch-depth: 0 # the realtime step below diffs across the engine pin move
39
+ submodules: true # the engine only
40
+ token: ${{ secrets.SUBMODULE_PAT }}
41
+
42
+ - uses: actions/setup-node@v4
43
+ with:
44
+ node-version: 20
45
+
46
+ # GV_ENGINE_ONLY=1 builds the shared chrome and the worker with NO space on
47
+ # disk. That is the point: a build that never sees a space cannot emit one, so
48
+ # this job is structurally incapable of overwriting a direct publish however
49
+ # stale its checkout. build.js asserts the result really is chrome-only and
50
+ # fails the deploy if anything space-derived slipped in.
51
+ - name: Build engine chrome
52
+ run: node engine/build.js
53
+ env:
54
+ GV_ENGINE_ONLY: "1"
55
+ GV_IDENTITY_PATH: ${{ github.workspace }}/identity.json
56
+ GV_DEPLOY_CONFIG_PATH: ${{ github.workspace }}/deploy.config.json
57
+
58
+ # ── ONE FRONT DOOR OR THE OTHER, chosen by whether this shell has a wrangler.toml ──
59
+ #
60
+ # An instance is on Pages or on a plain Worker, and this template answers both so a
61
+ # shell can move on its own schedule. The `hashFiles` conditional is the pattern this
62
+ # very file already uses for the realtime worker below.
63
+ #
64
+ # Keeping BOTH here is deliberate. `scripts/shell-lint.mjs` compares a shell's
65
+ # workflows to these templates LINE BY LINE, so a template that knew only about
66
+ # Workers would red-line every shell still on Pages — and with them the health check
67
+ # that reads shell-lint — until the last one migrated. One template, both worlds,
68
+ # and cutover is adding one file to a shell while rollback is deleting it.
69
+
70
+ # PAGES. Carries the WORKER (_worker.js) plus the chrome as an assets-mode fallback.
71
+ # Content is not here.
72
+ - name: Deploy to Cloudflare Pages
73
+ if: ${{ hashFiles('wrangler.toml') == '' }}
74
+ uses: cloudflare/wrangler-action@v3
75
+ with:
76
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
77
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
78
+ command: pages deploy engine/dist --project-name=your-pages-project --branch=main
79
+
80
+ # PLAIN WORKER. The preflight runs FIRST and the deploy is gated on it, because the
81
+ # config it checks is the difference between a gated site and a public one: Workers
82
+ # serves a matching static asset BEFORE invoking the worker unless
83
+ # `run_worker_first = true`, and the asset directory holds __config/instance.json —
84
+ # the roster, with seed passwords. A deploy that skips this check and gets it wrong
85
+ # passes every test, answers the health canary "healthy", and serves the roster.
86
+ - name: Preflight the worker config
87
+ if: ${{ hashFiles('wrangler.toml') != '' }}
88
+ run: node engine/scripts/wrangler-preflight.mjs -c wrangler.toml
89
+
90
+ - name: Deploy the Worker
91
+ if: ${{ hashFiles('wrangler.toml') != '' }}
92
+ uses: cloudflare/wrangler-action@v3
93
+ with:
94
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
95
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
96
+ command: deploy --config wrangler.toml
97
+
98
+ # The store's copy of the chrome, plus the instance config (identity + knobs)
99
+ # the worker reads at runtime. --engine sets GV_ENGINE_ONLY itself, so it
100
+ # cannot touch space content. Skips gracefully while AUGUR_TOKEN isn't
101
+ # configured (assets-mode setups, where Pages is the only source).
102
+ - name: Publish engine chrome to the bundle store
103
+ env:
104
+ AUGUR_TOKEN: ${{ secrets.AUGUR_TOKEN }}
105
+ AUGUR_ORIGIN: https://your-site-origin.example
106
+ GV_IDENTITY_PATH: ${{ github.workspace }}/identity.json
107
+ GV_DEPLOY_CONFIG_PATH: ${{ github.workspace }}/deploy.config.json
108
+ run: |
109
+ if [ -z "$AUGUR_TOKEN" ]; then echo "AUGUR_TOKEN not configured — skipping store publish"; exit 0; fi
110
+ cd engine && node scripts/publish.mjs --engine
111
+
112
+ # The realtime (canvas multiplayer) worker deploys separately from Pages; its
113
+ # instance config lives in THIS repo (realtime.wrangler.toml). Third parties
114
+ # forget manual redeploys, so: when the engine pin moved AND the move touched
115
+ # engine realtime code, redeploy it here. Skips gracefully when the shell has
116
+ # no realtime config, the pin didn't move, or realtime/ is untouched.
117
+ - name: Redeploy realtime if the engine bump changed it
118
+ if: ${{ hashFiles('realtime.wrangler.toml') != '' }}
119
+ env:
120
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
121
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
122
+ run: |
123
+ set -euo pipefail
124
+ OLD="$(git rev-parse '${{ github.event.before }}:engine' 2>/dev/null \
125
+ || git rev-parse 'HEAD^:engine' 2>/dev/null || true)"
126
+ NEW="$(git rev-parse 'HEAD:engine')"
127
+ if [ -z "$OLD" ] || [ "$OLD" = "$NEW" ]; then echo "engine pin unmoved — skipping realtime."; exit 0; fi
128
+ # BOTH paths, and src/board-room.mjs is the one that is easy to forget: the
129
+ # BoardRoom class MOVED out of realtime/ into the engine's own module graph, so
130
+ # this worker's behaviour now lives mostly in a directory this diff used to
131
+ # ignore. Watching only realtime/ would ship a room fix to the engine worker and
132
+ # silently leave the standalone realtime workers on the old code.
133
+ if git -C engine diff --quiet "$OLD" "$NEW" -- realtime/ src/board-room.mjs 2>/dev/null; then
134
+ echo "engine bump didn't touch realtime/ or src/board-room.mjs — skipping."; exit 0
135
+ fi
136
+ echo "realtime code changed in ${OLD:0:12}..${NEW:0:12} — redeploying"
137
+ npx wrangler deploy -c realtime.wrangler.toml
138
+
139
+ # Drift-driven re-bake after every deploy; the same workflow also answers the
140
+ # worker's space-rebake dispatch the moment a stale-baked publish arrives.
141
+ # Full rationale + the script live in space-rebake.yml.
142
+ rebake:
143
+ needs: deploy
144
+ uses: ./.github/workflows/space-rebake.yml
145
+ secrets: inherit