@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,144 @@
1
+ // Which workspace a hostname names.
2
+ //
3
+ // `B-resolver-dynamic`. A deployment that serves several workspaces tells them apart by
4
+ // the first label of the Host header — `acme.example.com` is the workspace `acme`. This
5
+ // module is the parsing and the refusing; `resolveTenant()` in src/_worker.js is the one
6
+ // place that calls it, and turning the label into a Durable Object stub is that function's
7
+ // last line.
8
+ //
9
+ // It is a separate module for two reasons. The worker does not need two more module-scope
10
+ // tables in it, and the RESERVED list has a second reader coming: the control plane
11
+ // generates workspace names (`voracious-eel-294`, never user-chosen), and a generator that
12
+ // emits `admin` and a resolver that refuses it are the same list disagreeing. One list,
13
+ // imported twice, is the only arrangement where they cannot drift.
14
+ //
15
+ // EVERYTHING HERE IS A PURE FUNCTION OF A STRING. No env, no fetch, no clock — the
16
+ // resolver runs before any config is read, on every request, and it has to be impossible
17
+ // for it to be the thing that is slow or the thing that fails.
18
+
19
+ /**
20
+ * A hostname label a workspace may never be, whatever a generator emits and whatever
21
+ * already exists.
22
+ *
23
+ * These fall into three groups and each is here for its own reason:
24
+ *
25
+ * · INFRASTRUCTURE — `www`, `mail`, `ns1`, `mx`, `cdn`. A DNS record that has to be able
26
+ * to exist on this domain. A workspace holding one of these names is a workspace that
27
+ * cannot be routed around later without taking somebody's site away.
28
+ * · THE OPERATOR'S OWN SURFACE — `admin`, `status`, `billing`, `support`, `login`,
29
+ * `auth`. These read as the operator speaking. A stranger's workspace answering on
30
+ * `login.<domain>` is a phishing page with a real certificate, served by us.
31
+ * · MAILBOXES THAT MUST STAY ANSWERABLE — `postmaster`, `hostmaster`, `webmaster`,
32
+ * `abuse`, `security`. Certificate authorities and abuse reporters use these; they are
33
+ * addresses before they are hostnames, and losing control of one loses a channel.
34
+ *
35
+ * A frozen ARRAY rather than a Set, deliberately: `Object.freeze(new Set())` does not stop
36
+ * `.add()`, so a frozen Set is a table that looks immutable and is not. The list is short
37
+ * and the check runs once per request.
38
+ */
39
+ export const RESERVED_LABELS = Object.freeze([
40
+ // infrastructure
41
+ "www", "mail", "smtp", "imap", "pop", "mx", "ns", "ns1", "ns2", "ns3", "ftp", "sftp",
42
+ "cdn", "static", "assets", "img", "images", "media", "files", "edge", "origin", "proxy",
43
+ "vpn", "dns", "autodiscover", "autoconfig", "localhost", "local", "example", "invalid",
44
+ // the operator's own surface
45
+ "admin", "administrator", "root", "system", "internal", "control", "cp", "panel",
46
+ "dashboard", "account", "accounts", "billing", "invoice", "invoices", "pay", "payments",
47
+ "login", "signin", "signup", "register", "auth", "oauth", "sso", "id", "identity",
48
+ "api", "app", "apps", "console", "manage", "portal", "my", "me",
49
+ "status", "support", "help", "docs", "doc", "documentation", "blog", "news", "about",
50
+ "contact", "legal", "privacy", "terms", "press", "careers", "jobs", "shop", "store",
51
+ "download", "downloads", "community", "forum", "wiki",
52
+ "augur", "engine", "site", "web", "hosted", "cloud", "platform",
53
+ // environments, which are the names an operator reaches for in a hurry
54
+ "test", "testing", "staging", "stage", "dev", "develop", "development", "demo",
55
+ "preview", "sandbox", "beta", "alpha", "canary", "next", "old", "new", "tmp", "temp",
56
+ // mailboxes that have to stay answerable
57
+ "postmaster", "hostmaster", "webmaster", "abuse", "security", "noreply", "no-reply",
58
+ "info", "hi", "hello", "sales", "marketing",
59
+ ]);
60
+
61
+ /**
62
+ * The shape a workspace label may have: a DNS label, lowercase, no leading or trailing
63
+ * hyphen, at most 63 characters. Anchored at both ends, so a dot anywhere fails and a
64
+ * deeper hostname can never be read as a workspace name.
65
+ */
66
+ export const TENANT_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
67
+
68
+ /**
69
+ * A Host header, reduced to the hostname a comparison can be made against.
70
+ *
71
+ * Three normalizations, each of which is a real request somebody sends:
72
+ * · a PORT — `acme.example.com:8787`, which every local run produces;
73
+ * · CASE — the header is case-insensitive and proxies do not agree on which one to send;
74
+ * · a TRAILING DOT — `acme.example.com.` is the fully-qualified form and is legal.
75
+ *
76
+ * A bracketed IPv6 literal is left alone: it has no labels to take, and the suffix test
77
+ * below will not match it.
78
+ */
79
+ export function normalizeHost(hostHeader) {
80
+ let h = String(hostHeader == null ? "" : hostHeader).trim().toLowerCase();
81
+ if (!h) return "";
82
+ if (h.startsWith("[")) return h.split("]")[0] + "]"; // [::1]:8787 -> [::1]
83
+ h = h.split(":")[0];
84
+ while (h.endsWith(".")) h = h.slice(0, -1);
85
+ return h;
86
+ }
87
+
88
+ /**
89
+ * The workspace this hostname names, or null.
90
+ *
91
+ * `suffix` is LITERAL and is compared with endsWith, which is the whole trick. A dotted
92
+ * suffix (`.example.com`) puts workspaces on `<name>.example.com` and needs a wildcard
93
+ * certificate; a hyphenated one (`-team.example.com`) keeps every workspace on a
94
+ * first-level hostname, which a universal certificate already covers. Both are one string
95
+ * in this function, and neither is special-cased.
96
+ *
97
+ * Returns null — never a guess — for: no suffix configured, a host that does not carry it,
98
+ * the apex itself, a label of the wrong shape, a deeper hostname, and a reserved name.
99
+ * The caller refuses the request; it does not fall back to a default workspace, because a
100
+ * default on a multi-workspace deployment is somebody else's workspace.
101
+ */
102
+ export function tenantLabelFromHost(hostHeader, suffix, extra = NO_EXTRA) {
103
+ const s = String(suffix == null ? "" : suffix).trim().toLowerCase();
104
+ if (!s) return null;
105
+ const host = normalizeHost(hostHeader);
106
+ if (!host || host.length <= s.length || !host.endsWith(s)) return null;
107
+ const label = host.slice(0, -s.length);
108
+ if (!TENANT_LABEL_RE.test(label)) return null;
109
+ if (isReservedLabel(label, extra)) return null;
110
+ return label;
111
+ }
112
+
113
+ /**
114
+ * Whether a label is one the resolver must never resolve. Case-folded; nothing else.
115
+ * `extra` is the DEPLOYMENT's own reserved list (see parseReservedLabels) and reserves
116
+ * exactly like the engine's: the literal resolver answers nobody for it.
117
+ */
118
+ export function isReservedLabel(label, extra = NO_EXTRA) {
119
+ const l = String(label == null ? "" : label).trim().toLowerCase();
120
+ return l !== "" && (RESERVED_LABELS.includes(l) || (extra.length > 0 && extra.includes(l)));
121
+ }
122
+
123
+ const NO_EXTRA = Object.freeze([]);
124
+
125
+ /**
126
+ * The deployment's own reserved labels, from one env string (`RESERVED_LABELS_EXTRA`).
127
+ *
128
+ * The frozen list above names what is dangerous on ANY deployment. A deployment also has
129
+ * names of its own that must never become somebody's first label — the fallback origin it
130
+ * pointed a certificate product at, an address it intends to alias by operator grant — and
131
+ * those belong in its config, not in a public engine. Comma or whitespace separated,
132
+ * case-folded, and anything that is not a legal label is DROPPED rather than widened: a
133
+ * typo here must not reserve more than it says. Pure string work, like everything in this
134
+ * file; the caller reads the env, this never does.
135
+ */
136
+ export function parseReservedLabels(raw) {
137
+ const s = String(raw == null ? "" : raw).trim().toLowerCase();
138
+ if (!s) return NO_EXTRA;
139
+ const out = [];
140
+ for (const part of s.split(/[\s,]+/)) {
141
+ if (part && TENANT_LABEL_RE.test(part) && !out.includes(part)) out.push(part);
142
+ }
143
+ return Object.freeze(out);
144
+ }
@@ -0,0 +1,140 @@
1
+ // What one workspace is allowed, as one row it carries with it.
2
+ //
3
+ // `B-quota-schema`. Four enforcement points are coming — asset uploads, board writes,
4
+ // realtime rooms, signup invites — and the failure mode if they each carry their own
5
+ // number is not that a limit is wrong. It is that nobody can say what the limit IS: raising
6
+ // a plan means finding four constants in three files, and the one that gets missed refuses
7
+ // a customer who has paid.
8
+ //
9
+ // So the quotas are SEEDED INTO THE WORKSPACE at provisioning and read from there. A
10
+ // workspace's limits then travel with its data, survive an engine deploy, and can be raised
11
+ // for one customer without a code change — which is what a support request actually looks
12
+ // like.
13
+ //
14
+ // ⚠️ THE NUMBERS BELOW ARE A STARTING POSITION, NOT A PRICE. The plan item defines the
15
+ // SHAPE and says the values are the operator's call. Each one has its reasoning written
16
+ // beside it so changing it is an argument with a stated position rather than a guess at
17
+ // what the last person meant. TWO of them are not guesses. `editorSeatLimit`: the free tier
18
+ // is one editor because the paywall is the SECOND editor, which is the whole business model
19
+ // and not a tuning knob. And `rtMonthlyDoMinutes` was MEASURED — a real room, instrumented
20
+ // from the inside, driven by two real browser tabs; the note beside it says what the
21
+ // measurement was and what it cost.
22
+
23
+ /**
24
+ * R2's free tier is 10 GB for the WHOLE ACCOUNT, not per workspace. A per-workspace cap
25
+ * that ignores that is a cap that never fires until the account bill does.
26
+ */
27
+ const ACCOUNT_R2_FREE_BYTES = 10 * 1024 * 1024 * 1024;
28
+
29
+ /**
30
+ * How many free workspaces the account's free storage is meant to hold. THIS is the number
31
+ * to turn: it is the one with a meaning ("free workspaces we can carry before storage
32
+ * costs money"), and the byte cap falls out of it.
33
+ */
34
+ const FREE_WORKSPACES_PER_ACCOUNT = 40;
35
+
36
+ /**
37
+ * A working month in minutes — 22 days of 8 hours. The realtime cap below is written in
38
+ * board time rather than in a bare number because "how long boards may be live" is a
39
+ * sentence somebody can check against their own week, and 10560 is not.
40
+ */
41
+ const WORKING_MINUTES_PER_MONTH = 22 * 8 * 60;
42
+
43
+ /**
44
+ * The quota shape. Every field is a number, and every enforcement point reads its ceiling
45
+ * from here rather than declaring one.
46
+ *
47
+ * `null` is not used and must not be: an absent ceiling written as null reads as "no limit"
48
+ * at one call site and "not configured, refuse" at another. Unlimited is a very large
49
+ * number, so every comparison has the same meaning everywhere.
50
+ */
51
+ export const QUOTA_FIELDS = Object.freeze([
52
+ "editorSeatLimit",
53
+ "storageBytesCap",
54
+ "assetUploadDailyBytes",
55
+ "boardWritesPerMinute",
56
+ "rtConcurrentRooms",
57
+ "rtMonthlyDoMinutes",
58
+ ]);
59
+
60
+ export const PLANS = Object.freeze({
61
+ free: Object.freeze({
62
+ // ONE editor. Not a tuning knob — the paywall is the second editor, and everything
63
+ // else on this list is a cost ceiling rather than a product boundary. Viewers and
64
+ // commenters are not seats: a workspace nobody can show anything to is not a workspace.
65
+ editorSeatLimit: 1,
66
+ // The account's free storage divided by how many free workspaces it is meant to carry.
67
+ // A published design system with a few dozen prototypes is tens of megabytes, so this
68
+ // is generous for what it is for and firm about what it is not (an image host).
69
+ storageBytesCap: Math.floor(ACCOUNT_R2_FREE_BYTES / FREE_WORKSPACES_PER_ACCOUNT),
70
+ // Per DAY, and it is deliberately a fraction of the total cap: the thing this stops is
71
+ // a script filling a workspace overnight, which the total cap alone would allow right
72
+ // up to the moment it is full.
73
+ assetUploadDailyBytes: 50 * 1024 * 1024,
74
+ // A canvas board writes on a debounce, not per keystroke, so a person costs a handful
75
+ // a minute. This is loose enough that nobody drawing hits it and tight enough that a
76
+ // loop does.
77
+ boardWritesPerMinute: 300,
78
+ // Concurrent multiplayer rooms. This used to say a room "stays awake while somebody is
79
+ // in it, which is the realtime bill in one sentence", and the measurement below refutes
80
+ // that: a room with people in it and nobody touching it is hibernated and costs nothing.
81
+ // What this stops is a workspace holding hundreds of rooms open at once, which the
82
+ // monthly cap alone would allow right up to the moment it is spent.
83
+ rtConcurrentRooms: 5,
84
+ // Minutes those rooms may stay awake in a month. ONE BOARD LIVE EVERY WORKING HOUR OF
85
+ // THE MONTH — which one editor cannot reach, and that is the point: this is a runaway
86
+ // stop, not a ration.
87
+ //
88
+ // It is set from a measurement, not chosen. test/rt-cost/ instruments a real room from
89
+ // the inside and test/rt-cost/results/ is the recording; a room was driven both by a
90
+ // replay of the client's own cadence and by two browser tabs running the real client.
91
+ // An hour of editing keeps the room awake 32.7 minutes with one person in it and 54.7 with
92
+ // two — the same object either way, so a second person is nearly free and a cap on room
93
+ // minutes is a cap on how long boards are LIVE, never on head count. A heavy month for
94
+ // one editor is therefore around 1300 awake minutes, and the 1000 that stood here would
95
+ // have cut that person off.
96
+ //
97
+ // ⚠️ AWAKE IS NOT BILLED, AND THE GAP IS TWO ORDERS OF MAGNITUDE. Duration is charged
98
+ // while the object is running or idle-but-unable-to-hibernate, and a room whose sockets
99
+ // all went through the hibernation API is eligible in every gap between messages. The
100
+ // same recording puts the object's actual handler occupancy under one per cent of its
101
+ // awake time, so this cap corresponds to at most a couple of minutes of charged
102
+ // duration — a fairness limit long before it is a cost one. On the deployed runtime the
103
+ // object was never evicted at all across 150 seconds of silence, so residency there is
104
+ // nearer "a socket is open" than "somebody is working", and it still costs nothing.
105
+ //
106
+ // Two cases are deliberately NOT averaged into the number above. An idle tab costs
107
+ // NOTHING: five minutes, two tabs, 52 keepalives, zero events reached the object,
108
+ // because the runtime answers them from the auto-response pair without waking it. And
109
+ // the tail after the last person leaves is one flush of about 11ms, plus one already-
110
+ // armed alarm that fires later into an empty room and returns.
111
+ rtMonthlyDoMinutes: WORKING_MINUTES_PER_MONTH,
112
+ }),
113
+ paid: Object.freeze({
114
+ // The only difference that is a PRODUCT difference. The rest are raised because a
115
+ // paying workspace's costs are covered, not because the free tier was mean.
116
+ editorSeatLimit: 100,
117
+ storageBytesCap: 20 * 1024 * 1024 * 1024,
118
+ assetUploadDailyBytes: 2 * 1024 * 1024 * 1024,
119
+ boardWritesPerMinute: 3000,
120
+ rtConcurrentRooms: 100,
121
+ // Ten boards live every working hour of the month, at the measured cost of a room with
122
+ // more than one person on it. Ten because that is what a hundred seats spread over
123
+ // shared boards looks like when the whole team is working, and because the measurement
124
+ // says the ceiling costs about a thousand minutes of charged duration a month — inside
125
+ // the account's included allowance on its own.
126
+ rtMonthlyDoMinutes: 10 * WORKING_MINUTES_PER_MONTH,
127
+ }),
128
+ });
129
+
130
+ export const DEFAULT_PLAN = "free";
131
+
132
+ /**
133
+ * The quota values a plan implies, as a flat name→number map including `plan` itself.
134
+ * An unknown plan name resolves to the free tier rather than to no limits: a typo in a
135
+ * billing webhook must not be how somebody gets an unlimited workspace.
136
+ */
137
+ export function quotasForPlan(plan, plans = PLANS) {
138
+ const name = plans[plan] ? plan : DEFAULT_PLAN;
139
+ return Object.freeze({ plan: name, ...plans[name] });
140
+ }
@@ -0,0 +1,103 @@
1
+ // src/unit-core.mjs — the pure half of drafts that land.
2
+ //
3
+ // A UNIT is a prototype folder, spelled as its URL prefix with a leading and a trailing
4
+ // slash, exactly as `routing.publicPrefixes` spells it. A DRAFT is one session's live
5
+ // working copy of a unit, addressed at `<unit>@<id>/`. Everything here is a decision over
6
+ // plain objects: the Durable Object, the worker and the CLI all import it, and none of them
7
+ // re-derive what a unit, a draft or a stale save is. See docs/drafts-that-land.md.
8
+ //
9
+ // ⚠️ NO NODE IMPORTS. The worker and the Durable Object run this too.
10
+
11
+ export const ACTIVE_MS = 5 * 60_000;
12
+ export const DRAFT_ID_RE = /^[a-z0-9]{6}$/;
13
+ // A draft address: one or more path segments, then `@` + six chars, then the rest.
14
+ const DRAFT_PATH_RE = /^(\/(?:[^/@][^/]*\/)+)@([a-z0-9]{6})(\/.*)?$/;
15
+
16
+ /** `"checkout/flow"` → `"/checkout/flow/"`; null for anything that is not a unit path. */
17
+ export function normUnit(s) {
18
+ const raw = String(s == null ? "" : s).trim().replace(/\/{2,}/g, "/");
19
+ const segs = raw.split("/").filter(Boolean);
20
+ if (!segs.length) return null;
21
+ for (const seg of segs) if (seg === "." || seg === ".." || seg.startsWith("@")) return null;
22
+ return "/" + segs.join("/") + "/";
23
+ }
24
+
25
+ export function newDraftId(random = Math.random) {
26
+ const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
27
+ let out = "";
28
+ for (let i = 0; i < 6; i++) out += alphabet[Math.floor(random() * alphabet.length) % alphabet.length];
29
+ return out;
30
+ }
31
+
32
+ export const draftAddress = (unit, id) => `${unit}@${id}/`;
33
+
34
+ export function splitDraftPath(pathname) {
35
+ const m = DRAFT_PATH_RE.exec(String(pathname || ""));
36
+ if (!m) return null;
37
+ return { unit: m[1], id: m[2], rest: m[3] || "/" };
38
+ }
39
+
40
+ /** The manifest's entries under `unit`, with only the fields a table carries. */
41
+ export function unitTable(files, unit) {
42
+ const out = {};
43
+ for (const [p, f] of Object.entries(files || {})) {
44
+ if (!p.startsWith(unit) || !f) continue;
45
+ const row = { h: f.h, ct: f.ct, s: f.s };
46
+ if (f.by) row.by = f.by;
47
+ if (f.editedAt) row.editedAt = f.editedAt;
48
+ out[p] = row;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ export function sameTable(a, b) {
54
+ const ka = Object.keys(a || {}), kb = Object.keys(b || {});
55
+ if (ka.length !== kb.length) return false;
56
+ return ka.every((p) => b[p] && b[p].h === a[p].h);
57
+ }
58
+
59
+ /**
60
+ * Apply a save's changes with per-file compare-and-set. All or nothing: one stale base
61
+ * refuses the whole batch, and the caller gets the current hash of every stale file.
62
+ */
63
+ export function applyChanges(table, changes) {
64
+ const stale = [];
65
+ for (const c of changes || []) {
66
+ const cur = table[c.path];
67
+ const curHash = cur ? cur.h : null;
68
+ const base = c.baseHash == null ? null : c.baseHash;
69
+ if (base !== curHash) stale.push({ path: c.path, h: curHash });
70
+ }
71
+ if (stale.length) return { ok: false, table, stale };
72
+ const next = { ...table };
73
+ for (const c of changes || []) {
74
+ if (c.delete) delete next[c.path];
75
+ else next[c.path] = { h: c.h, ct: c.ct, s: c.s };
76
+ }
77
+ return { ok: true, table: next, stale: [] };
78
+ }
79
+
80
+ /** What `to` has that `from` does not: changed/added with `to`'s metadata, and removed paths. */
81
+ export function tableDelta(from, to) {
82
+ const changed = [], removed = [];
83
+ for (const [p, f] of Object.entries(to || {})) {
84
+ if (!from[p] || from[p].h !== f.h) changed.push({ path: p, ...f });
85
+ }
86
+ for (const p of Object.keys(from || {})) if (!to[p]) removed.push(p);
87
+ changed.sort((a, b) => a.path.localeCompare(b.path));
88
+ removed.sort();
89
+ return { changed, removed };
90
+ }
91
+
92
+ /** Open drafts, each with `active` derived from its last save (or its opening). */
93
+ export function presenceOf(drafts, nowMs) {
94
+ return (drafts || [])
95
+ .filter((d) => !d.closedAt)
96
+ .map((d) => {
97
+ const last = Date.parse(d.lastSaveAt || d.openedAt || "") || 0;
98
+ return {
99
+ id: d.id, owner: d.owner, session: d.session || "", openedAt: d.openedAt,
100
+ lastSaveAt: d.lastSaveAt || null, active: nowMs - last < ACTIVE_MS,
101
+ };
102
+ });
103
+ }