@augurworks/augur 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CANVAS.md +858 -0
- package/INSTALL.md +603 -0
- package/LICENSE +27 -0
- package/README.md +150 -0
- package/TRADEMARKS.md +72 -0
- package/agents/README.md +65 -0
- package/agents/canon-extract.md +117 -0
- package/agents/canon.md +151 -0
- package/agents/canvas.md +88 -0
- package/agents/currency.md +74 -0
- package/agents/drafts.md +92 -0
- package/agents/identity.md +148 -0
- package/agents/prototype-contract.md +69 -0
- package/agents/publishing.md +324 -0
- package/agents/review-feedback.md +76 -0
- package/agents/space-json.md +132 -0
- package/agents/ui-skill.md +101 -0
- package/agents/working-marks.md +86 -0
- package/brand/augur-eye.svg +3 -0
- package/brand/augur-icon.svg +41 -0
- package/brand/augur-mark.svg +5 -0
- package/brand/lentianova-bulky.otf +0 -0
- package/brand/lentianova-median.otf +0 -0
- package/brand/lentianova.woff2 +0 -0
- package/build.js +8409 -0
- package/changelog.md +133 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/package.json +57 -0
- package/pitis/PETS.md +121 -0
- package/pitis/README.md +117 -0
- package/pitis/index.html +266 -0
- package/pitis/piti.build.js +81 -0
- package/pitis/piti.js +929 -0
- package/pitis/roast-agent.md +185 -0
- package/realtime/src/index.js +119 -0
- package/realtime/wrangler.example.toml +44 -0
- package/scripts/adopt.mjs +106 -0
- package/scripts/board-snapshot.mjs +197 -0
- package/scripts/build-seed-pack.mjs +33 -0
- package/scripts/bundle-rekey.mjs +112 -0
- package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
- package/scripts/canon-extract.mjs +578 -0
- package/scripts/canon.mjs +599 -0
- package/scripts/canvas-screen.mjs +333 -0
- package/scripts/check-local.mjs +136 -0
- package/scripts/clawd-art.mjs +75 -0
- package/scripts/clawd-canvas.mjs +741 -0
- package/scripts/cli.mjs +100 -0
- package/scripts/clone.mjs +422 -0
- package/scripts/close.mjs +30 -0
- package/scripts/connect.mjs +103 -0
- package/scripts/deploy.mjs +157 -0
- package/scripts/dev.mjs +54 -0
- package/scripts/doc-lint.mjs +143 -0
- package/scripts/export.mjs +300 -0
- package/scripts/fork.mjs +64 -0
- package/scripts/freeze.mjs +74 -0
- package/scripts/frontdoor-parity.mjs +180 -0
- package/scripts/hook.mjs +67 -0
- package/scripts/identity-rekey.mjs +112 -0
- package/scripts/init.mjs +122 -0
- package/scripts/land.mjs +40 -0
- package/scripts/lib/adapters.mjs +156 -0
- package/scripts/lib/board-snapshot.mjs +511 -0
- package/scripts/lib/draft.mjs +385 -0
- package/scripts/lib/graduate.mjs +276 -0
- package/scripts/lib/instance.mjs +74 -0
- package/scripts/lib/marks.mjs +107 -0
- package/scripts/lib/materialize.mjs +138 -0
- package/scripts/lib/merge3.mjs +124 -0
- package/scripts/lib/offline-posture.mjs +120 -0
- package/scripts/lib/offline-respawn.mjs +18 -0
- package/scripts/lib/offline-wrangler.mjs +78 -0
- package/scripts/lib/publish-compose.mjs +12 -0
- package/scripts/lib/publish-conflict.mjs +78 -0
- package/scripts/lib/publish-evidence.mjs +144 -0
- package/scripts/lib/refine-compare.mjs +140 -0
- package/scripts/lib/refine-ledger.mjs +150 -0
- package/scripts/lib/refine-png.mjs +149 -0
- package/scripts/lib/seed-pack-build.mjs +141 -0
- package/scripts/lib/state-compare.mjs +68 -0
- package/scripts/lib/store.mjs +122 -0
- package/scripts/login.mjs +84 -0
- package/scripts/mark.mjs +112 -0
- package/scripts/migrate-board-keys.mjs +164 -0
- package/scripts/migrate.mjs +265 -0
- package/scripts/no-foreign-vocabulary.mjs +216 -0
- package/scripts/no-product-names.mjs +157 -0
- package/scripts/no-tenant-globals.mjs +1134 -0
- package/scripts/offline.mjs +247 -0
- package/scripts/og.mjs +170 -0
- package/scripts/one-tenant-resolver.mjs +146 -0
- package/scripts/open.mjs +63 -0
- package/scripts/publish.mjs +1041 -0
- package/scripts/read.mjs +31 -0
- package/scripts/refine.mjs +455 -0
- package/scripts/release-drift.mjs +138 -0
- package/scripts/restore.mjs +322 -0
- package/scripts/review.mjs +125 -0
- package/scripts/save.mjs +21 -0
- package/scripts/shell-lint.mjs +189 -0
- package/scripts/ship.mjs +460 -0
- package/scripts/shoot.mjs +264 -0
- package/scripts/smoke-invite.mjs +113 -0
- package/scripts/state-inventory.mjs +223 -0
- package/scripts/status.mjs +177 -0
- package/scripts/sync.mjs +27 -0
- package/scripts/tenant-do-rehearsal.mjs +1314 -0
- package/scripts/ui-copy-lint.mjs +202 -0
- package/scripts/watch.mjs +32 -0
- package/scripts/wrangler-preflight.mjs +246 -0
- package/seed/CANON.md +43 -0
- package/seed/README.md +119 -0
- package/seed/prototype-status.json +9 -0
- package/seed/registry.json +88 -0
- package/seed/skills/starter-ui/SKILL.md +52 -0
- package/seed/skills/starter-ui/skill.json +11 -0
- package/seed/skills/starter-ui/starter-tokens.css +73 -0
- package/seed/skills/starter-ui/starter-ui.css +318 -0
- package/seed/skills/starter-ui/starter-ui.js +50 -0
- package/seed/space.json +5 -0
- package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
- package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
- package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
- package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
- package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
- package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
- package/seed/threads.json +52 -0
- package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
- package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
- package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
- package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
- package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
- package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
- package/src/_worker.js +12668 -0
- package/src/board-key.mjs +57 -0
- package/src/board-room.mjs +881 -0
- package/src/bundle-keys.mjs +138 -0
- package/src/canon/collect-in-browser.js +215 -0
- package/src/canon/emit.mjs +232 -0
- package/src/canon/observe.mjs +260 -0
- package/src/canon/schema.mjs +451 -0
- package/src/canvas/DSEG-LICENSE.txt +95 -0
- package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
- package/src/canvas/canvas.css +885 -0
- package/src/canvas/canvas.js +5780 -0
- package/src/canvas/capture.js +485 -0
- package/src/chrome/appchrome.mjs +568 -0
- package/src/currency.mjs +203 -0
- package/src/delete-confirmation.mjs +254 -0
- package/src/drafts/drafts.js +326 -0
- package/src/entry.js +58 -0
- package/src/galleries.mjs +400 -0
- package/src/health-cron.mjs +173 -0
- package/src/identity.json +1 -0
- package/src/kv-codec.mjs +135 -0
- package/src/kv-identity.mjs +259 -0
- package/src/mail.mjs +599 -0
- package/src/provenance.mjs +74 -0
- package/src/publish-compose.mjs +363 -0
- package/src/publish-fork.mjs +221 -0
- package/src/publish-units.mjs +44 -0
- package/src/purge.mjs +81 -0
- package/src/review/COMMENTING-UX.md +306 -0
- package/src/review/cat.png +0 -0
- package/src/review/comment-cursor.svg +7 -0
- package/src/review/comments.js +1738 -0
- package/src/room-ticket.mjs +96 -0
- package/src/roster-chip.mjs +32 -0
- package/src/seed-pack.mjs +261 -0
- package/src/state-inventory.mjs +249 -0
- package/src/sw-logic.mjs +48 -0
- package/src/tenant-cache.mjs +141 -0
- package/src/tenant-context.mjs +386 -0
- package/src/tenant-do.js +2940 -0
- package/src/tenant-host.mjs +144 -0
- package/src/tenant-quotas.mjs +140 -0
- package/src/unit-core.mjs +103 -0
- package/src/unit-object.mjs +338 -0
- package/templates/README.md +67 -0
- package/templates/shell/deploy.yml +145 -0
- package/templates/shell/engine-bump.yml +142 -0
- package/templates/shell/health.yml +233 -0
- package/templates/shell/kv-backup.yml +201 -0
- package/templates/shell/roster-update.yml +110 -0
- package/templates/shell/space-preflight.yml +35 -0
- package/templates/shell/space-rebake.yml +79 -0
- package/templates/shell/store-backup.yml +152 -0
- package/templates/shell/wrangler.example.toml +266 -0
- package/templates/space/publish.yml +91 -0
|
@@ -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
|
+
}
|