@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,110 @@
1
+ name: Roster update (from Augur Admin)
2
+
3
+ # Receiver for Admin-panel roster changes. Inviting or removing a person in the
4
+ # Augur Admin UI fires a `roster-update` repository_dispatch here (same channel
5
+ # as "Delete forever"), and this job commits the change to identity.json — so
6
+ # the identity file stays the ONE durable record and the KV roster overlay only
7
+ # bridges the minute this takes. The deploy this push triggers sends the fresh
8
+ # config back to the worker, which then drains the superseded overlay entry
9
+ # itself. Both directions are idempotent: an email already in (or already gone
10
+ # from) the file exits green with no commit.
11
+ #
12
+ # The push uses AUGUR_PIN_TOKEN so the commit retriggers deploy.yml — the
13
+ # default GITHUB_TOKEN deliberately would not.
14
+
15
+ on:
16
+ repository_dispatch:
17
+ types: [roster-update]
18
+
19
+ concurrency:
20
+ group: roster-update
21
+ cancel-in-progress: false
22
+
23
+ permissions:
24
+ contents: read # the push below authenticates with AUGUR_PIN_TOKEN
25
+
26
+ jobs:
27
+ update:
28
+ runs-on: ubuntu-latest
29
+ steps:
30
+ - uses: actions/checkout@v4
31
+ with:
32
+ token: ${{ secrets.AUGUR_PIN_TOKEN }}
33
+ submodules: false
34
+
35
+ - name: Apply the change to identity.json and push (retry on races)
36
+ env:
37
+ ACTION: ${{ github.event.client_payload.action }}
38
+ EMAIL: ${{ github.event.client_payload.user.email || github.event.client_payload.email }}
39
+ USER_NAME: ${{ github.event.client_payload.user.name }}
40
+ USER_INITIALS: ${{ github.event.client_payload.user.initials }}
41
+ USER_COLOR: ${{ github.event.client_payload.user.color }}
42
+ USER_ROLE: ${{ github.event.client_payload.user.role }}
43
+ BY: ${{ github.event.client_payload.by }}
44
+ run: |
45
+ set -euo pipefail
46
+ case "$ACTION" in add|remove|role) ;; *) echo "::error::bad action '$ACTION'"; exit 1;; esac
47
+ if ! printf '%s' "$EMAIL" | grep -Eq '^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$'; then
48
+ echo "::error::client_payload does not carry a valid email"; exit 1
49
+ fi
50
+
51
+ # The edit, as an idempotent script we can re-run after a lost push race.
52
+ cat > "$RUNNER_TEMP/edit.cjs" <<'EOF'
53
+ const fs = require("fs");
54
+ const { ACTION, EMAIL, USER_NAME, USER_INITIALS, USER_COLOR, USER_ROLE } = process.env;
55
+ const lc = (s) => String(s || "").toLowerCase();
56
+ const users = JSON.parse(fs.readFileSync("identity.json", "utf8"));
57
+ const known = (u) => lc(u.email) === lc(EMAIL) || (u.emails || []).some((e) => lc(e) === lc(EMAIL));
58
+ let out = users, changed = false;
59
+ // `user` is the legacy spelling of `editor` and both mean the same thing, so
60
+ // neither is written out: an absent role IS an editor, and storing one would
61
+ // only give the file two ways to say the same thing.
62
+ const ROLES = ["admin", "editor", "viewer"];
63
+ const wanted = ROLES.includes(USER_ROLE) ? USER_ROLE : "editor";
64
+ if (ACTION === "add" && !users.some(known)) {
65
+ const entry = {
66
+ email: EMAIL,
67
+ name: USER_NAME || EMAIL.split("@")[0],
68
+ initials: USER_INITIALS || "",
69
+ emails: [],
70
+ color: USER_COLOR || "#4f46e5",
71
+ };
72
+ if (wanted !== "editor") entry.role = wanted;
73
+ out = [...users, entry];
74
+ changed = true;
75
+ }
76
+ if (ACTION === "remove") {
77
+ out = users.filter((u) => !known(u));
78
+ changed = out.length !== users.length;
79
+ }
80
+ // A role change from the Admin panel. The KV overlay already made it true;
81
+ // this is what makes it durable, and what lets the overlay entry drain.
82
+ if (ACTION === "role") {
83
+ out = users.map((u) => {
84
+ if (!known(u)) return u;
85
+ const current = u.role === "admin" || u.role === "viewer" ? u.role : "editor";
86
+ if (current === wanted) return u;
87
+ changed = true;
88
+ const { role, ...rest } = u;
89
+ return wanted === "editor" ? rest : { ...rest, role: wanted };
90
+ });
91
+ }
92
+ if (changed) fs.writeFileSync("identity.json", JSON.stringify(out, null, 2) + "\n");
93
+ console.log(changed ? "identity.json updated" : "already in the desired state");
94
+ EOF
95
+
96
+ git config user.name "augur-deploy"
97
+ git config user.email "augur-deploy@users.noreply.github.com"
98
+ for attempt in 1 2 3 4 5; do
99
+ node "$RUNNER_TEMP/edit.cjs"
100
+ if git diff --quiet identity.json; then
101
+ echo "nothing to commit — the file already agrees"; exit 0
102
+ fi
103
+ git commit -q -m "[roster] $ACTION $EMAIL${USER_ROLE:+ → $USER_ROLE} (via Augur Admin${BY:+, by $BY})" identity.json
104
+ if git push origin HEAD; then exit 0; fi
105
+ echo "push rejected (concurrent commit) — refetching and reapplying"
106
+ git fetch origin main
107
+ git reset --hard --quiet origin/main
108
+ sleep "$attempt"
109
+ done
110
+ echo "::error::push still rejected after 5 attempts — re-run this dispatch"; exit 1
@@ -0,0 +1,35 @@
1
+ name: Preflight — can CI read a space repo?
2
+
3
+ # Part of the add-a-space ritual: BEFORE mounting a new space repo as a submodule,
4
+ # verify SUBMODULE_PAT can read it — mounting first would break every deploy at the
5
+ # checkout step until the grant is fixed. Run from the Actions tab (or
6
+ # `gh workflow run space-preflight.yml -f repo=<owner/name>`); green = safe to mount.
7
+
8
+ on:
9
+ workflow_dispatch:
10
+ inputs:
11
+ repo:
12
+ description: "Space repo (owner/name)"
13
+ required: true
14
+
15
+ permissions: {}
16
+
17
+ jobs:
18
+ probe:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - name: Probe SUBMODULE_PAT read access
22
+ env:
23
+ PAT: ${{ secrets.SUBMODULE_PAT }}
24
+ REPO: ${{ inputs.repo }}
25
+ run: |
26
+ set -euo pipefail
27
+ if ! printf '%s' "$REPO" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9._-]+$'; then
28
+ echo "::error::bad repo '$REPO' (expected owner/name)"; exit 1
29
+ fi
30
+ # Output fully discarded so the token-bearing URL can never reach the log.
31
+ if git ls-remote "https://x-access-token:${PAT}@github.com/${REPO}.git" HEAD >/dev/null 2>&1; then
32
+ echo "OK — SUBMODULE_PAT can read ${REPO}; safe to mount at spaces/<id>."
33
+ else
34
+ echo "::error::SUBMODULE_PAT cannot read ${REPO} — add it to the token's repository access (Settings → Developer settings → Fine-grained tokens), then re-run."; exit 1
35
+ fi
@@ -0,0 +1,79 @@
1
+ # Re-bake any roster space whose LIVE baked chrome (/_build.json builtWithEngine)
2
+ # is behind this shell's pinned engine. Two triggers, one job:
3
+ # - workflow_call: deploy.yml runs it after every deploy (drift converges on
4
+ # every pin bump / config push).
5
+ # - repository_dispatch [space-rebake]: the WORKER fires this the moment a
6
+ # publish arrives baked with a non-current engine — the stale window is
7
+ # minutes, not until-the-next-deploy. The publisher is never involved.
8
+ # Runtime chrome does NOT make this obsolete: serve-time composition fixes
9
+ # marker-wrapped chrome, but pages baked before the markers existed — and all
10
+ # baked generated markup (galleries, indexes) — only converge by republishing.
11
+ # Drift-driven and idempotent: a converged space is a curl + skip, so over-firing
12
+ # is harmless. Publishes from a dirty working tree are someone's live session and
13
+ # are left alone; health.yml alarms if drift outlives its grace.
14
+ name: Space re-bake
15
+ on:
16
+ workflow_call: {}
17
+ workflow_dispatch:
18
+ repository_dispatch:
19
+ types: [space-rebake]
20
+ concurrency:
21
+ group: space-rebake
22
+ cancel-in-progress: false
23
+ jobs:
24
+ rebake:
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ with:
29
+ submodules: true # the engine, at the pinned sha — bakes exactly it
30
+ token: ${{ secrets.SUBMODULE_PAT }}
31
+ fetch-depth: 0
32
+ - uses: actions/setup-node@v4
33
+ with:
34
+ node-version: 20
35
+ # For each stale roster space: clone it (full history — publish.mjs dates
36
+ # cards from git), check out the LIVE published sha when the clone has it
37
+ # (a re-bake must republish what IS live, never quietly ship someone's
38
+ # pushed-but-unpublished work), and publish with the star token, the pinned
39
+ # engine, and an explicit AUGUR_ORIGIN. When live's sha isn't in the clone
40
+ # (a direct publish that never pushed), publish main — the client-side
41
+ # reconcile adopts the live bytes, so nothing is lost either way.
42
+ - name: Re-bake roster spaces whose baked chrome is behind the engine
43
+ env:
44
+ GH_TOKEN: ${{ secrets.SUBMODULE_PAT }} # clone the space repos
45
+ AUGUR_TOKEN: ${{ secrets.AUGUR_TOKEN }} # *-scoped: publishes any space's content
46
+ run: |
47
+ set -uo pipefail
48
+ if [ -z "${AUGUR_TOKEN:-}" ]; then echo "AUGUR_TOKEN unset (assets-mode) — nothing to re-bake"; exit 0; fi
49
+ NEW="$(git rev-parse 'HEAD:engine' 2>/dev/null || true)"
50
+ [ -n "$NEW" ] || { echo "no engine pin on disk — skipping"; exit 0; }
51
+ ORIGIN="$(jq -r '.siteOrigin // empty' deploy.config.json)"
52
+ [ -n "$ORIGIN" ] || { echo "no siteOrigin in deploy.config.json — skipping"; exit 0; }
53
+ STAMP="$(curl -fsS --max-time 30 "$ORIGIN/_build.json" 2>/dev/null || echo '{}')"
54
+ FAILED=""
55
+ while read -r ID REPO; do
56
+ [ -n "$ID" ] && [ -n "$REPO" ] || continue
57
+ BW="$(printf '%s' "$STAMP" | jq -r --arg id "$ID" '.spaces[$id].builtWithEngine // empty')"
58
+ DIRTY="$(printf '%s' "$STAMP" | jq -r --arg id "$ID" '.spaces[$id].dirty // false')"
59
+ LIVE_SHA="$(printf '%s' "$STAMP" | jq -r --arg id "$ID" '.spaces[$id].sha // empty')"
60
+ if [ "$BW" = "$NEW" ]; then echo "$ID already on ${NEW:0:12} — skip"; continue; fi
61
+ if [ "$DIRTY" = "true" ]; then echo "$ID is a working-tree publish — leaving it"; continue; fi
62
+ DIR="$RUNNER_TEMP/space-$ID"; rm -rf "$DIR"
63
+ if ! git clone --quiet "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "$DIR"; then
64
+ echo "::warning::clone of $REPO failed — skipping $ID"; FAILED="$FAILED $ID"; continue
65
+ fi
66
+ if [ -n "$LIVE_SHA" ] && git -C "$DIR" cat-file -e "$LIVE_SHA^{commit}" 2>/dev/null; then
67
+ git -C "$DIR" checkout --quiet "$LIVE_SHA"
68
+ echo "re-baking $ID at live sha ${LIVE_SHA:0:12} (${BW:-<none>}→${NEW:0:12})"
69
+ else
70
+ echo "re-baking $ID from main — live sha ${LIVE_SHA:-<none>} not in clone; reconcile adopts live bytes (${BW:-<none>}→${NEW:0:12})"
71
+ fi
72
+ if ( cd "$DIR" && AUGUR_ORIGIN="$ORIGIN" GV_SPACES_ROOT="$DIR" node "$GITHUB_WORKSPACE/engine/scripts/publish.mjs" ); then
73
+ echo "re-baked $ID"
74
+ else
75
+ echo "::warning::re-bake of $ID failed"; FAILED="$FAILED $ID"
76
+ fi
77
+ done < <(jq -r '.spaces[]? | "\(.id) \(.repo)"' deploy.config.json)
78
+ if [ -n "$FAILED" ]; then echo "::error::re-bake failed for:$FAILED — see warnings above"; exit 1; fi
79
+ echo "re-bake pass complete."
@@ -0,0 +1,152 @@
1
+ name: Bundle-store backup
2
+
3
+ # Off-Cloudflare copies of everything the live site serves.
4
+ #
5
+ # The store is the only copy of published content, and R2 has no point-in-time
6
+ # restore. Most of it is reproducible — clone a space at the sha /_build.json
7
+ # reports and publish again — but a publish made from an uncommitted working tree
8
+ # serves bytes held in NO repository, and those are gone with the bucket.
9
+ # `augur ship` makes that state rare by committing first; this makes it
10
+ # survivable.
11
+ #
12
+ # The copy is a plain directory: manifests/<id>.json + blobs/<sha256> +
13
+ # export.json. Restore is `augur restore <dir>` from any machine holding a
14
+ # publish token — see the engine's docs/store-recovery.md.
15
+ #
16
+ # TWO SLOTS, ON PURPOSE — one recent copy and one old one:
17
+ #
18
+ # weekly Sunday, kept 10 days → there is always a copy under a week old
19
+ # monthly the 1st, kept 40 days → there is always a copy roughly a month old
20
+ #
21
+ # The old slot is the one that matters against a quiet compromise. Anything that
22
+ # corrupts or empties the store on a Tuesday is in every copy taken after it, so
23
+ # a rolling week of dailies protects you from accidents and not from an attack
24
+ # you notice late. A month-old copy does.
25
+ #
26
+ # Cost: each run uploads a FULL copy (the export's incremental skip saves nothing
27
+ # across runs, because a fresh runner starts empty), and Actions artifact storage
28
+ # is shared across every repo in the account — not per-repo. Two slots is roughly
29
+ # two full copies steady state. Frequency and retention are the only real knobs.
30
+ #
31
+ # Residual risk worth naming: anyone with write access to THIS repo can delete
32
+ # these artifacts as easily as read them. Genuine immutability needs an offsite
33
+ # with object-lock, which is a different job from this one.
34
+ #
35
+ # SET BEFORE USE: AUGUR_ORIGIN below. Secrets: AUGUR_TOKEN, star-scoped — a
36
+ # space-scoped token silently skips the spaces it cannot read, and this job fails
37
+ # if anything was skipped.
38
+
39
+ on:
40
+ schedule:
41
+ - cron: "40 2 * * 0" # weekly — Sunday 02:40 UTC (offset: GitHub drops top-of-hour crons)
42
+ - cron: "40 3 1 * *" # monthly — the 1st, 03:40 UTC
43
+ workflow_dispatch: # take one on demand before anything risky
44
+ inputs:
45
+ slot:
46
+ description: "Which slot this manual copy fills"
47
+ type: choice
48
+ default: weekly
49
+ options: [weekly, monthly]
50
+
51
+ permissions:
52
+ contents: read
53
+
54
+ concurrency:
55
+ group: store-backup
56
+ cancel-in-progress: false
57
+
58
+ # github.event.schedule carries the cron line that fired, so one workflow serves
59
+ # both slots without duplicating the job.
60
+ env:
61
+ SLOT: ${{ github.event.schedule == '40 3 1 * *' && 'monthly' || inputs.slot || 'weekly' }}
62
+
63
+ jobs:
64
+ backup:
65
+ runs-on: ubuntu-latest
66
+ steps:
67
+ # ⛔ REFUSE TO RUN ON A PUBLIC REPOSITORY. First step, on purpose.
68
+ #
69
+ # A COMMENT IS NOT A GUARD, AND THIS HAS ALREADY HAPPENED. A public fork of the
70
+ # engine ran a workflow of this shape and published a full production KV export of
71
+ # a live instance — 58 keys, 26 internal comment threads, five real names and
72
+ # personal addresses — reachable with no authentication. GitHub shares git objects
73
+ # across a fork network, so the commit resolved through the PARENT repository's own
74
+ # raw URL: deleting the branch, deleting the fork and rewriting the parent's history
75
+ # all leave it fetchable, and only a GitHub Support object purge closes it. The
76
+ # header above this job said "the shell repo must be private" the whole time.
77
+ #
78
+ # IT FAILS, IT DOES NOT SKIP. A skip is worse than having no backup workflow at all,
79
+ # because the operator goes on believing a backup exists.
80
+ #
81
+ # AND IT FAILS WHEN IT CANNOT TELL. `github.event.repository.private` is absent on
82
+ # some event types, and an absent value is not a private repository — it is an
83
+ # unanswered question, and this is the one question that must not have a default.
84
+ - name: Refuse to run on a public repository
85
+ env:
86
+ GH_TOKEN: ${{ github.token }}
87
+ CTX_PRIVATE: ${{ github.event.repository.private }}
88
+ run: |
89
+ set -uo pipefail
90
+ PRIVATE="$(gh api "repos/${GITHUB_REPOSITORY}" --jq .private 2>/dev/null || true)"
91
+ if [ -z "$PRIVATE" ]; then PRIVATE="${CTX_PRIVATE:-}"; fi
92
+ case "$PRIVATE" in
93
+ true)
94
+ echo "OK: ${GITHUB_REPOSITORY} is private"
95
+ ;;
96
+ false)
97
+ echo "::error::REFUSING TO RUN: ${GITHUB_REPOSITORY} is a PUBLIC repository. This job exports the instance's whole state and writes it into this repo. On a public repo that publishes it to anyone, permanently — a fork network keeps the objects fetchable through the parent's URL after the branch, the fork and the history are all gone. Make the repository private, or delete this workflow."
98
+ exit 1
99
+ ;;
100
+ *)
101
+ echo "::error::REFUSING TO RUN: could not determine whether ${GITHUB_REPOSITORY} is private (got '${PRIVATE}'). An unanswered question is not a private repository."
102
+ exit 1
103
+ ;;
104
+ esac
105
+
106
+ - uses: actions/checkout@v4
107
+ with:
108
+ submodules: true # the engine carries the export script
109
+ token: ${{ secrets.SUBMODULE_PAT }}
110
+
111
+ - uses: actions/setup-node@v4
112
+ with:
113
+ node-version: 20
114
+
115
+ - name: Export the store
116
+ env:
117
+ AUGUR_TOKEN: ${{ secrets.AUGUR_TOKEN }}
118
+ AUGUR_ORIGIN: https://your-site-origin.example # ← your deploy.config.json siteOrigin
119
+ run: |
120
+ set -euo pipefail
121
+ if [ -z "$AUGUR_TOKEN" ]; then
122
+ echo "::error::AUGUR_TOKEN is not configured — nothing can be backed up."; exit 1
123
+ fi
124
+ node engine/scripts/export.mjs --out backup
125
+
126
+ # A backup that quietly omits a space is the one you discover is short on
127
+ # the day you need it. export.json records anything it could not read;
128
+ # refuse to publish an artifact that claims to be a copy and isn't.
129
+ - name: Refuse an incomplete copy
130
+ run: |
131
+ set -euo pipefail
132
+ SKIPPED="$(jq -r '.skipped | length' backup/export.json)"
133
+ if [ "$SKIPPED" != "0" ]; then
134
+ echo "::error::the export skipped $SKIPPED target(s) — check AUGUR_TOKEN is star-scoped:"
135
+ jq -r '.skipped[] | " - \(.id): \(.reason)"' backup/export.json
136
+ exit 1
137
+ fi
138
+ echo "Spaces copied:"; jq -r '.spaces[] | " \(.id) v\(.version)\(if .source.dirty then " [DIRTY — these bytes exist in no repo]" else "" end)"' backup/export.json
139
+ echo "Blobs: $(jq -r .blobs backup/export.json) · $(du -sh backup | cut -f1)"
140
+
141
+ - name: Stamp the copy
142
+ id: stamp
143
+ run: echo "on=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
144
+
145
+ - uses: actions/upload-artifact@v4
146
+ with:
147
+ name: store-${{ env.SLOT }}-${{ steps.stamp.outputs.on }}
148
+ path: backup
149
+ # Each slot outlives its own interval, so a copy always exists even in
150
+ # the moments around a scheduled run.
151
+ retention-days: ${{ env.SLOT == 'monthly' && 40 || 10 }}
152
+ compression-level: 0 # the payload is already-compressed images and woff2
@@ -0,0 +1,266 @@
1
+ # TEMPLATE — copy this into YOUR DEPLOY SHELL as wrangler.toml and fill it in.
2
+ #
3
+ # It is deliberately not a live wrangler.toml: a worker's name, its account, its KV
4
+ # namespace and its R2 bucket belong to ONE instance, so the shared engine must not carry
5
+ # them. `wrangler` ignores this filename, which also means a stray `npx wrangler` run
6
+ # inside an engine clone cannot pick it up. Same pattern as realtime/wrangler.example.toml.
7
+ #
8
+ # It is hand-authored per shell rather than generated from deploy.config.json ON PURPOSE:
9
+ # five of the six mandatory values below (worker name, account id, compatibility date, KV
10
+ # namespace id, R2 bucket) have no source in any deploy.config.json today, so generating
11
+ # it would mean inventing a config block, bumping the shell contract, and committing
12
+ # account and namespace ids into shell repos — for a file each shell writes once.
13
+ #
14
+ # npx wrangler deploy --dry-run -c wrangler.toml # resolves every binding, ships nothing
15
+ # node engine/scripts/wrangler-preflight.mjs -c wrangler.toml
16
+ #
17
+ # ─────────────────────────────────────────────────────────────────────────────────────
18
+ # ⛔ READ THIS BEFORE CHANGING [assets]. IT IS THE LOGIN GATE.
19
+ #
20
+ # Pages and Workers invert request precedence, and the inversion is silent.
21
+ #
22
+ # Pages runs _worker.js FIRST for every request; the worker decides what is public
23
+ # and falls through to the static asset only when it chooses to.
24
+ # Workers serves a MATCHING STATIC ASSET FIRST and only invokes the worker on a miss —
25
+ # unless `run_worker_first = true`.
26
+ #
27
+ # `engine/dist` is the asset directory, and it contains `__config/instance.json`, which
28
+ # carries the instance's user roster INCLUDING SEED PASSWORDS (build.js writes `users:
29
+ # IDENTITY`, unstripped, and says so in its own comment). It also contains the admin page
30
+ # and, in assets mode, every built page.
31
+ #
32
+ # So without `run_worker_first = true`, a deploy that passes every test in the engine and
33
+ # answers a health check with "healthy" serves the roster, the admin panel and every gated
34
+ # page to anyone who asks. That is not a hardening flag. It IS the gate.
35
+ #
36
+ # `scripts/wrangler-preflight.mjs` refuses to pass a config whose asset directory holds
37
+ # __config/instance.json without it, and runs in the workflow that gates the deploy.
38
+ # ─────────────────────────────────────────────────────────────────────────────────────
39
+
40
+ name = "<your-instance>" # the Worker's name, e.g. augur-<instance>
41
+ account_id = "<your-cloudflare-account-id>"
42
+ main = "engine/src/entry.js" # relative to THIS file, i.e. your shell's root
43
+ compatibility_date = "2026-07-01"
44
+
45
+ [assets]
46
+ directory = "engine/dist" # what `node engine/build.js` emits
47
+ binding = "ASSETS" # the worker reads its own config through this
48
+ # The line the gate depends on. See the block above before touching it.
49
+ run_worker_first = true
50
+ # Stated explicitly rather than left to the default. "none" is what keeps the worker's own
51
+ # `asset.status === 404` branches firing; "single-page-application" would answer every
52
+ # unknown path with the index page at status 200, which is the bug engine/dist/404.html
53
+ # exists to prevent.
54
+ not_found_handling = "none"
55
+ # /foo, /foo/ and /foo/index.html all resolve, which is what published prototype folders
56
+ # rely on. Stated explicitly for the same reason as above.
57
+ html_handling = "auto-trailing-slash"
58
+
59
+ # Comments, pins, statuses, rosters, canvas boards, publish tokens. The SAME namespace the
60
+ # Pages project binds as COMMENTS and the realtime worker binds as BOARD_KV — during a
61
+ # migration both front doors read one store, which is what makes the cutover reversible.
62
+ [[kv_namespaces]]
63
+ binding = "COMMENTS"
64
+ id = "<your-kv-namespace-id>"
65
+
66
+ # The bundle store: content-addressed blobs plus per-space manifests. A live instance
67
+ # serves published content from here, never from the asset directory, so an instance with
68
+ # no BUNDLES binding is a site with nothing published in it.
69
+ [[r2_buckets]]
70
+ binding = "BUNDLES"
71
+ bucket_name = "<your-r2-bucket>"
72
+
73
+ # ── Canvas rooms, once this instance serves them from its own worker ────────────────────
74
+ #
75
+ # Uncomment BOTH blocks together, and only when you are ready to retire this instance's
76
+ # standalone `augur-realtime-<instance>` worker. The class already ships in the engine's
77
+ # deploy entry, so wrangler resolves it; what these add is the binding and the migration
78
+ # that creates the Durable Object namespace.
79
+ #
80
+ # The room's KV mirror uses the COMMENTS binding above — the same namespace the standalone
81
+ # worker binds as BOARD_KV, which is why folding realtime in here removes a binding rather
82
+ # than adding one. The room accepts either name for as long as both deployments exist.
83
+ #
84
+ # ⚠️ The rooms in THIS namespace are new and empty: a Durable Object's storage belongs to
85
+ # the script that created it, so boards do not travel from the standalone worker. They
86
+ # migrate on first touch from the KV mirror, which lags by up to 45 seconds — so cut over
87
+ # when boards are quiet, not mid-session.
88
+ #
89
+ # ⚠️ THE BINDING ALSO MOVES THE BOARD KEY, and that is one cutover, not two. With rooms
90
+ # bound, `/__rt` names each room `<workspace>:<path>` and the board document is written to
91
+ # `board:<workspace>:<path>`; without it, both keep the unscoped spelling they have always
92
+ # had. The two ride together because the rail and the room's mirror are one script only
93
+ # here — scoping one of them while the other is in a separate worker would leave the rail
94
+ # reading a key nothing writes. A board written before the cutover is still found: both
95
+ # readers fall back to the unscoped key, serve it, and write it back scoped.
96
+ #
97
+ # So the cutover, in order:
98
+ # 1. deploy with the blocks below uncommented (the worker now serves its own rooms)
99
+ # 2. CLOUDFLARE_API_TOKEN=… CLOUDFLARE_ACCOUNT_ID=… AUGUR_KV_NS=… \
100
+ # node engine/scripts/migrate-board-keys.mjs --workspace <id>
101
+ # — a DRY RUN, and it has to report `differing 0` before anything else happens
102
+ # 3. the same command with --apply
103
+ # 4. stop the standalone worker only after the boards look right
104
+ # `<id>` is the `tenantId` the build stamped into __config/instance.json, which is the
105
+ # deploy config's `tenantId` if it sets one and otherwise EMPTY — an empty one resolves to
106
+ # the literal `default`, and that is what to pass. Guessing it wrong is silent: the site
107
+ # keeps working on the read-through while a second, stale copy of every board accumulates.
108
+ #
109
+ # [[durable_objects.bindings]]
110
+ # name = "ROOMS"
111
+ # class_name = "BoardRoom"
112
+ #
113
+ # [[migrations]]
114
+ # tag = "v1"
115
+ # new_sqlite_classes = ["BoardRoom"]
116
+
117
+ # ── The workspace store, once membership moves off KV ───────────────────────────────────
118
+ #
119
+ # One Durable Object per workspace, holding the roster, roles, invites, publish tokens and
120
+ # profile photos this instance's KV holds under `users:*` and `publish:tokens` today.
121
+ # Schema and reasoning: engine/src/tenant-do.js.
122
+ #
123
+ # The point of it is that the isolation stops being a rule about key naming: a DO's storage
124
+ # belongs to that object, so there is no key you can construct in one workspace that reads
125
+ # another. Which is why it only pays for itself once a deployment serves more than one —
126
+ # a single-workspace instance gains nothing here and should leave this commented.
127
+ #
128
+ # ⚠️ NOT A CUTOVER ON ITS OWN. Uncommenting creates the namespace and applies the schema;
129
+ # it moves no data and no read. The families move over in their own steps, and until then
130
+ # every read still comes from KV. Do not delete anything from KV on the strength of this
131
+ # block existing.
132
+ #
133
+ # ⚠️ THE MIGRATION TAG IS SHARED. If ROOMS above is already live on `v1`, this needs its
134
+ # own `tag = "v2"` with only the new class in it — tags are applied in order and are never
135
+ # re-run, so amending a tag that has already shipped does nothing at all, silently.
136
+ #
137
+ # [[durable_objects.bindings]]
138
+ # name = "TENANTS"
139
+ # class_name = "TenantStore"
140
+ #
141
+ # [[migrations]]
142
+ # tag = "v1"
143
+ # new_sqlite_classes = ["TenantStore"]
144
+
145
+ # ── The unit store: drafts that land ────────────────────────────────────────────────────
146
+ #
147
+ # One Durable Object per prototype, holding its main revision, open drafts, landing history
148
+ # and the landing lease. Without it `augur open` answers 501 and nothing else changes.
149
+ # Schema and reasoning: engine/src/unit-object.mjs, design: engine/docs/drafts-that-land.md.
150
+ #
151
+ # [[durable_objects.bindings]]
152
+ # name = "UNITS"
153
+ # class_name = "UnitObject"
154
+ #
155
+ # [[migrations]]
156
+ # tag = "v2"
157
+ # new_sqlite_classes = ["UnitObject"]
158
+
159
+ [vars]
160
+ # Bundle mode. Without this the worker falls back to assets mode and every published page
161
+ # answers 404 while the site itself looks healthy.
162
+ GV_ASSET_SOURCE = "r2"
163
+
164
+ # NO tenantId HERE, on purpose. Which workspace a deployment serves already flows
165
+ # deploy.config.json -> build.js -> dist/__config/instance.json -> resolveTenant(), pinned
166
+ # to a single call site by scripts/one-tenant-resolver.mjs. A var would be a second source
167
+ # of truth for the one value the whole isolation model is keyed on.
168
+ #
169
+ # TENANT_HOST_SUFFIX is the exception, and it is not that value — it is the switch between
170
+ # ONE workspace and MANY. Leave it unset and this deployment serves the single workspace
171
+ # its build named, which is what a self-hosted instance is. Set it and the workspace comes
172
+ # from the Host header instead: `TENANT_HOST_SUFFIX = ".example.com"` makes
173
+ # `acme.example.com` the workspace `acme`, and a hostname that names no workspace gets a
174
+ # 404 rather than somebody else's site.
175
+ #
176
+ # RESERVED_LABELS_EXTRA adds this deployment's OWN reserved first labels to the engine's
177
+ # list (src/tenant-host.mjs): names that must never become somebody's workspace address —
178
+ # the fallback origin a certificate product points at, an address you intend to alias to a
179
+ # workspace by operator grant. Comma or space separated; anything that is not a legal label
180
+ # is dropped, never widened. A reserved label resolves nobody literally, which is exactly
181
+ # what lets the alias table answer for it.
182
+ #
183
+ # RESERVED_LABELS_EXTRA = "service"
184
+ #
185
+ # The suffix is LITERAL, which is worth a moment. A dotted suffix puts workspaces one level
186
+ # down and needs a wildcard certificate; a hyphenated one (`-team.example.com`) keeps every
187
+ # workspace on a first-level hostname that a universal certificate already covers. Pick
188
+ # before the first workspace exists — changing it later changes everybody's URL.
189
+ #
190
+ # Set it ONLY together with the TENANTS binding above. Without it there is nowhere for a
191
+ # workspace's own state to live, and the deployment would resolve names it cannot serve.
192
+ #
193
+ # ── TENANT_JURISDICTION — WHERE those workspace objects are, and it is part of their
194
+ # address ──────────────────────────────────────────────────────────────────────────────
195
+ #
196
+ # Leave it unset and workspace objects carry no jurisdictional restriction, which is what
197
+ # every deployment does today. Set it — `TENANT_JURISDICTION = "eu"` — and the platform
198
+ # keeps each workspace's storage inside that jurisdiction and runs it there.
199
+ #
200
+ # ⚠️ IT IS NOT A SETTING ON THE BINDING ABOVE, AND THAT IS THE TRAP. There is no wrangler
201
+ # key for it: the restriction is applied when an object is ADDRESSED, so
202
+ # `idFromName("acme")` and `jurisdiction("eu").idFromName("acme")` are two different ids —
203
+ # two different objects, in two different places, that know nothing about each other. A
204
+ # Durable Object's storage belongs to its id, so this cannot be changed after a workspace
205
+ # exists: it is not a setting you flip, it is an export and a replay into a new object.
206
+ # DECIDE BEFORE THE FIRST WORKSPACE IS CREATED.
207
+ #
208
+ # ⚠️ AND EVERYTHING ELSE THAT ADDRESSES THESE WORKSPACES HAS TO BE TOLD THE SAME VALUE.
209
+ # Anything holding this namespace binding — a provisioning worker, an operator tool —
210
+ # resolves a DIFFERENT object if it applies a different jurisdiction, or none. The symptom
211
+ # is not an error: one side creates a workspace, the other resolves an empty one.
212
+ #
213
+ # The value is handed to the platform as written. It is case-sensitive, `"EU"` is not
214
+ # `"eu"`, and a value the platform does not accept fails EVERY request rather than quietly
215
+ # addressing without a restriction — engine/scripts/wrangler-preflight.mjs refuses the
216
+ # unknown value and the missing binding before the deploy, so the typo costs a re-run
217
+ # rather than an outage.
218
+ #
219
+ # NO SECRETS HERE EITHER — [vars] is plaintext in the repo. These go in with
220
+ # `npx wrangler secret put <NAME> -c wrangler.toml`:
221
+ #
222
+ # SESSION_SECRET REQUIRED. Without it sessions degrade to an unkeyed digest,
223
+ # silently, and anyone who knows an address can forge a cookie.
224
+ # RT_SHARED_SECRET canvas multiplayer; the realtime worker fails closed without it
225
+ # MAIL_API_KEY only if this instance sends mail (with MAIL_PROVIDER/MAIL_FROM)
226
+ # DELETE_DISPATCH_TOKEN only if prototype-delete / roster-update dispatch to a repo
227
+ #
228
+ # And never PUBLISH_BOOTSTRAP_TOKEN on a live instance: it is compared as a bare string
229
+ # and grants star-scope publish. The preflight refuses a config that names it.
230
+
231
+ # ── The health cron, once you want to stop paying GitHub to run your canary ─────────────
232
+ #
233
+ # Uncomment and this deployment checks ITSELF on a schedule: whether any workspace is
234
+ # serving a working-tree publish that has outlived its window, whether any is serving page
235
+ # chrome baked by an older engine than the one deployed, and whether the engine you are
236
+ # running is behind the newest RELEASE. The last one is the point — if your own CI is
237
+ # broken, this is the only thing left that will tell you your engine is months old.
238
+ #
239
+ # The report lands at `/__admin/health`, admin-only. That endpoint READS; it never runs the
240
+ # checks, deliberately, so that "the cron is dead" and "nobody opened this page" cannot
241
+ # produce the same green answer. An absent report says so in words.
242
+ #
243
+ # ⚠️ IT DOES NOT ASK WHETHER YOUR SITE IS GATED, and that omission is deliberate rather
244
+ # than missing. A worker probing its own front door is a detector inside the thing it
245
+ # detects: the request re-enters this deployment, so the misconfiguration that would make
246
+ # the platform serve assets before the worker is exactly the condition under which the
247
+ # worker's own answer stops being worth anything. That question stays with
248
+ # `scripts/frontdoor-parity.mjs`, run from OUTSIDE — health.yml step (g), or by hand.
249
+ #
250
+ # ⚠️ ONE WORKSPACE ONLY. A cron has no Host header, so on a deployment that tells
251
+ # workspaces apart by hostname (TENANT_HOST_SUFFIX above) there is no single workspace to
252
+ # check, and it declines rather than picking one. Nothing is lost — that deployment wants a
253
+ # job that holds the workspace list, not a cron that guesses.
254
+ #
255
+ # [triggers]
256
+ # crons = ["23 */6 * * *"] # every six hours, offset off the hour
257
+
258
+ # The front door. A route attaches to a hostname that already has a proxied DNS record —
259
+ # which a Pages custom domain is — and takes precedence over Pages on the same hostname,
260
+ # so adding this is the cutover and deleting it is the rollback, in seconds, with no DNS
261
+ # propagation and no certificate reissue. Keep the Pages project and its last deployment
262
+ # until the new front door has been watched for a while.
263
+ #
264
+ # [[routes]]
265
+ # pattern = "<your-site-host>/*"
266
+ # zone_name = "<your-zone>"