@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,135 @@
1
+ // kv-codec — how a KV value survives being written into a JSON backup, and how a
2
+ // restore reads one back.
3
+ //
4
+ // THE BUG THIS EXISTS TO CLOSE. Every export path used to read a value as TEXT —
5
+ // `kv.get(name, "text")` in the worker, `res.text()` over the REST API, `jq --rawfile`
6
+ // in a workflow. KV values are BYTES, and the canvas stores pasted board images raw
7
+ // under `basset:<sha256-prefix>`. A JPEG is not valid UTF-8, so every invalid sequence
8
+ // became U+FFFD on the way in and no re-encoding ever brought it back: a 75,963-byte
9
+ // image came out of the copy as 137,439 bytes of different data, a third of its
10
+ // characters replacement characters, no longer matching the content-addressed key it
11
+ // was stored under. That is worse than a short backup, twice over. The copy is
12
+ // confidently WRONG rather than visibly missing, so nothing looks broken until someone
13
+ // restores. And a restore writes the garbage back under the content-addressed key, after
14
+ // which the canvas client SKIPS re-uploading the real image because the key exists — so
15
+ // the image is lost a second time, by the repair.
16
+ //
17
+ // THE FORMAT (`format: 2`). A value in `data` is EITHER:
18
+ //
19
+ // a JSON string — the value's bytes, which are valid UTF-8 text. Unchanged from
20
+ // format 1, so every value a format-1 copy holds still reads.
21
+ // {"b64": "…"} — the value's bytes, base64. Written whenever the bytes are not
22
+ // valid UTF-8, i.e. whenever a string could not carry them.
23
+ //
24
+ // The marker is an OBJECT rather than a prefixed string on purpose: `data` values have
25
+ // always been strings, so an object cannot collide with a real value, and a reader that
26
+ // predates the marker gets something it cannot write rather than something plausible.
27
+ // Detection is per value, so a reader needs no version negotiation — a copy taken before
28
+ // this existed and one taken after are read by the same code.
29
+ //
30
+ // WHAT DECIDES. `TextDecoder` with `fatal: true` — the exact question "do these bytes
31
+ // round-trip as text", asked of the bytes rather than guessed from the key name or a
32
+ // content type. `ignoreBOM: true` matters as much as `fatal`: without it the decoder
33
+ // SWALLOWS a leading U+FEFF, which is valid UTF-8 that would come back one BOM shorter.
34
+ //
35
+ // A NOTE ON `bytes`. Byte counts here are byte counts. The old envelope reported
36
+ // `v.length` on a decoded string, which undercounts every non-ASCII value and was
37
+ // nonsense for a binary one.
38
+
39
+ export const KV_BACKUP_FORMAT = 2;
40
+
41
+ // The one field name in the marker. Kept as a constant so the encoder, the decoder and
42
+ // the detector cannot drift apart, and so a grep for it finds all three.
43
+ const B64_FIELD = "b64";
44
+
45
+ // Content-addressed key schemes. Today there is exactly one: the canvas board assets the
46
+ // worker stores under the first 40 hex characters of the SHA-256 of the image bytes
47
+ // (`ASSET_PREFIX` + hash in src/_worker.js). The key IS the checksum, which is what lets
48
+ // a restore prove a value is intact without having anything to compare it against.
49
+ const CONTENT_ADDRESSED = /^basset:([0-9a-f]{40})$/;
50
+
51
+ const toBytes = (v) => (v instanceof Uint8Array ? v : new Uint8Array(v));
52
+
53
+ /**
54
+ * The bytes as JSON: a string when they are text, the base64 marker when they are not.
55
+ * @param {Uint8Array|ArrayBuffer} value
56
+ * @returns {string|{b64: string}}
57
+ */
58
+ export function encodeKvValue(value) {
59
+ const bytes = toBytes(value);
60
+ try {
61
+ // fatal → throws rather than substituting U+FFFD. ignoreBOM → a leading U+FEFF is
62
+ // returned rather than eaten, so a decode/encode round trip is byte-exact.
63
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
64
+ } catch (e) {
65
+ return { [B64_FIELD]: bytesToBase64(bytes) };
66
+ }
67
+ }
68
+
69
+ /** True when this JSON value is the base64 marker rather than a plain text value. */
70
+ export function isBinaryKvValue(v) {
71
+ return !!v && typeof v === "object" && !Array.isArray(v) && typeof v[B64_FIELD] === "string";
72
+ }
73
+
74
+ /**
75
+ * The bytes a backup value stands for. Throws on anything that is neither a string nor
76
+ * the marker — a restore must stop on a value it does not understand rather than write
77
+ * its best guess.
78
+ * @param {string|{b64: string}} v
79
+ * @returns {Uint8Array}
80
+ */
81
+ export function decodeKvValue(v) {
82
+ if (typeof v === "string") return new TextEncoder().encode(v);
83
+ if (isBinaryKvValue(v)) return base64ToBytes(v[B64_FIELD]);
84
+ throw new Error(`unreadable backup value: expected a string or {"${B64_FIELD}": "…"}, got ${Array.isArray(v) ? "an array" : typeof v}`);
85
+ }
86
+
87
+ /** The hash a content-addressed key promises, or null if the key promises nothing. */
88
+ export function contentAddressOf(key) {
89
+ const m = CONTENT_ADDRESSED.exec(String(key || ""));
90
+ return m ? m[1] : null;
91
+ }
92
+
93
+ /**
94
+ * Does this value match the checksum its own key name carries?
95
+ * true — it does
96
+ * false — it does NOT: the value under this key is not the value that was stored
97
+ * null — the key is not content-addressed, so there is nothing to check
98
+ * @param {string} key
99
+ * @param {Uint8Array|ArrayBuffer} value
100
+ */
101
+ export async function contentAddressMatches(key, value) {
102
+ const want = contentAddressOf(key);
103
+ if (!want) return null;
104
+ return (await sha256Hex(toBytes(value))).slice(0, want.length) === want;
105
+ }
106
+
107
+ export async function sha256Hex(value) {
108
+ const bytes = toBytes(value);
109
+ // A Uint8Array view may sit on a larger buffer; hand digest() exactly these bytes.
110
+ const digest = await crypto.subtle.digest("SHA-256", bytes.slice().buffer);
111
+ let out = "";
112
+ for (const b of new Uint8Array(digest)) out += b.toString(16).padStart(2, "0");
113
+ return out;
114
+ }
115
+
116
+ // btoa/atob are the only base64 both a Worker and Node have without an import, and both
117
+ // speak binary strings, so the bytes go through String.fromCharCode. In CHUNKS: spreading
118
+ // a multi-megabyte array into apply() overflows the stack, which would turn a large image
119
+ // into an export failure instead of a base64 string.
120
+ export function bytesToBase64(value) {
121
+ const bytes = toBytes(value);
122
+ const CHUNK = 0x8000;
123
+ let binary = "";
124
+ for (let i = 0; i < bytes.length; i += CHUNK) {
125
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
126
+ }
127
+ return btoa(binary);
128
+ }
129
+
130
+ export function base64ToBytes(b64) {
131
+ const binary = atob(b64);
132
+ const out = new Uint8Array(binary.length);
133
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
134
+ return out;
135
+ }
@@ -0,0 +1,259 @@
1
+ // KV's identity documents, translated into the rows the workspace object stores.
2
+ //
3
+ // `B-kv-to-do-migration-tool`. The object gained the write path (`importAll` in
4
+ // src/tenant-do.js); this is the half that decides what to hand it. It is a pure function
5
+ // on purpose: the mapping is where a copy silently loses somebody, so it is tested against
6
+ // fixtures rather than exercised through a live instance and eyeballed.
7
+ //
8
+ // ⚠️ THE ROSTER HAS TWO LAYERS AND ONLY ONE OF THEM IS IN KV. `users:roster` is the
9
+ // invite/remove OVERLAY; the durable record is `identity.json`, injected at build time and
10
+ // living in the deploy shell, not in KV. A copy that read KV alone would produce a members
11
+ // table missing every person the config file names — which is most of them on a real
12
+ // instance, and every one of them on an instance nobody has invited anyone to. So this
13
+ // takes the config roster as well, and merges the two exactly the way the serving path
14
+ // does (`mergeRoster` in src/_worker.js): the config list first minus anyone removed, then
15
+ // overlay `add` entries for addresses the config does not already name.
16
+ //
17
+ // ⚠️ A REMOVAL IS A TOMBSTONE. `users:roster.remove` names people who must not come back
18
+ // by fallback, so they become rows carrying `removed_at` rather than absent rows. Leaving
19
+ // them out would let a re-invite inherit the last holder's role.
20
+
21
+ /** Lowercased address, the same normalisation the serving path uses as an identity. */
22
+ const lc = (s) => String(s || "").trim().toLowerCase();
23
+
24
+ /**
25
+ * A KV timestamp as the ISO string the object's columns hold.
26
+ *
27
+ * ⚠️ THE TWO STORES SPELL A MOMENT DIFFERENTLY AND THE COPY HAS TO TRANSLATE IT. KV records
28
+ * an invite's expiry as epoch MILLISECONDS (`mintInvite` writes `nowMs + INVITE_TTL_MS`);
29
+ * every timestamp column in the object's schema is an ISO-8601 string. Handing the number
30
+ * straight over put `"1788484474092"` in a text column, which `Date.parse` answers `NaN`
31
+ * for — so an invite carried across by a copy read as having no usable expiry at all, on a
32
+ * path where an unreadable expiry is the difference between a link working and a link
33
+ * quietly not. It cost nothing at the time because nothing read the table; `B-kv-read-cutover`
34
+ * is what reads it.
35
+ *
36
+ * Anything already unreadable is not made worse: `stampMs` in src/tenant-do.js still accepts
37
+ * the number, so rows an earlier copy wrote stay redeemable. This is what stops new ones.
38
+ */
39
+ const isoStamp = (v, fallback) => {
40
+ if (typeof v === "number" && Number.isFinite(v)) return new Date(v).toISOString();
41
+ const s = typeof v === "string" ? v.trim() : "";
42
+ if (!s) return fallback;
43
+ if (/^\d+$/.test(s)) return new Date(Number(s)).toISOString();
44
+ return Number.isFinite(Date.parse(s)) ? s : fallback;
45
+ };
46
+
47
+ /** The roles `members.role` will accept. A value outside this set is the object's to refuse. */
48
+ const MEMBER_ROLE_SET = Object.freeze(["admin", "editor", "viewer"]);
49
+ const FALLBACK_ROLE = "viewer";
50
+
51
+ /**
52
+ * Families the inventory sends to the workspace object that this translation does NOT carry.
53
+ * Named rather than dropped: a copy that quietly skips a family is indistinguishable from a
54
+ * complete one, which is the failure this whole item exists to avoid.
55
+ */
56
+ export const UNMAPPED_WORKSPACE_FAMILIES = Object.freeze({
57
+ "spaces:icons": "the workspace icon pointer belongs in the object's `settings` table, which `importAll` does not write yet",
58
+ "mail:suppressed": "the object has NO TABLE for a suppression list, and the inventory entry says dropping it breaks a promise not to mail somebody again — see B-do-schema-core",
59
+ "users:firstrun": "the object has no table for the first-run record yet — it lives in the workspace's segmented KV, like users:sessionkeys, and the two should take a table in the same schema decision. Losing this family in a copy re-shows one placeholder page to each person once, which is the safer of the two ways to be wrong.",
60
+ "users:sessionkeys:": "the object has no table for a PER-PERSON session key. It has `signing_keys`, which is the workspace's own one, and the two belong in the same schema decision rather than in two — B-cross-workspace-signin is the item that makes it, because minting a session ON a workspace host is the thing that decides what a session binds to. Declared here rather than given a table now so that decision is made once, deliberately. Losing this family in a copy signs that workspace's people out once, which is recoverable, so it is the safer of the two ways to be wrong.",
61
+ "users:sessionkeys": "the retired shared document the per-person records above replaced, read only for a person who has no record yet. Same table decision as the records, same cost when lost: one sign-out for whoever had not been rotated since the records landed.",
62
+ });
63
+
64
+ /**
65
+ * The KV documents this translation reads. Named so `scripts/state-inventory.mjs` can ask
66
+ * the question that would have caught `mail:suppressed`: does every family the inventory
67
+ * sends to the workspace object actually have somewhere to land?
68
+ */
69
+ export const IDENTITY_KV_FAMILIES = Object.freeze([
70
+ "users:roster", "users:roles", "users:names", "users:avatars",
71
+ "users:invites", "users:lastseen:", "publish:tokens", "avatar:", "spaceicon:",
72
+ ]);
73
+
74
+ /**
75
+ * @param {object} families the export document's `families` map, keyed by inventory id
76
+ * @param {object} opts
77
+ * @param {Array} opts.configUsers the roster from instance config — the durable half
78
+ * @param {(token: string) => Promise<string>} opts.hashInvite how a raw invite token is keyed
79
+ * @param {string} opts.now ISO stamp for rows KV has no date for
80
+ * @returns {Promise<{identity: object, consumed: string[], skipped: Array<{id: string, why: string}>}>}
81
+ */
82
+ export async function identityFromKv(families = {}, opts = {}) {
83
+ const { configUsers = [], hashInvite, now = new Date().toISOString() } = opts;
84
+ const has = (id) => Object.prototype.hasOwnProperty.call(families, id);
85
+ const doc = (id) => (has(id) && families[id] && typeof families[id] === "object" ? families[id] : {});
86
+
87
+ const consumed = [];
88
+ const skipped = [];
89
+ const take = (id) => { if (has(id)) consumed.push(id); };
90
+
91
+ const roster = doc("users:roster");
92
+ const roles = doc("users:roles");
93
+ const names = doc("users:names");
94
+ const avatars = doc("users:avatars");
95
+ take("users:roster"); take("users:roles"); take("users:names"); take("users:avatars");
96
+
97
+ const removed = new Set((Array.isArray(roster.remove) ? roster.remove : []).map(lc));
98
+
99
+ // mergeRoster's order, and its precedence: the config list wins over an `add` of the same
100
+ // address, because the file is the record the overlay is a layer on.
101
+ const merged = [];
102
+ const seen = new Set();
103
+ for (const u of configUsers || []) {
104
+ const e = lc(u && u.email);
105
+ if (!e || removed.has(e) || seen.has(e)) continue;
106
+ seen.add(e);
107
+ merged.push({ ...u, email: e });
108
+ }
109
+ for (const rec of Object.values(roster.add || {})) {
110
+ const e = lc(rec && rec.email);
111
+ if (!e || removed.has(e) || seen.has(e)) continue;
112
+ seen.add(e);
113
+ merged.push({ ...rec, email: e });
114
+ }
115
+
116
+ // Which addresses the config file NAMES, as against which the overlay added. That is the
117
+ // provenance `members.source` carries, and it is a fact about the source rather than
118
+ // something the table can be asked to work out afterwards.
119
+ const fromConfig = new Set((configUsers || []).map((u) => lc(u && u.email)).filter(Boolean));
120
+
121
+ const rowFor = (u, removedAt) => {
122
+ const e = lc(u.email);
123
+ const av = avatars[e] && typeof avatars[e] === "object" ? avatars[e] : null;
124
+ // ⚠️ THE OVERLAY AND THE FILE GO INTO DIFFERENT COLUMNS AND ARE NOT MERGED HERE.
125
+ // Merging is what the SERVING path does, per request, and it is not a fold: `applyNames`
126
+ // DROPS a config-set `initials` when a name override exists and keeps it when one does
127
+ // not, so a table that had already merged them could not answer both. `users:names` also
128
+ // has two live shapes — `{name, at}` today, a bare string on older instances — and only
129
+ // the first is honoured by `applyNames`, so the value travels VERBATIM rather than
130
+ // normalised: normalising would start applying a name the KV path ignores.
131
+ const nmRaw = names[e];
132
+ return {
133
+ email: e,
134
+ // The DURABLE half: what the file says, or what the invitation said for somebody the
135
+ // file does not name yet.
136
+ //
137
+ // A value `members.role` will not accept — the column has a CHECK and `users:roster`
138
+ // does not — is passed through UNCHANGED so `writeIdentity` refuses it BY NAME rather
139
+ // than the copy quietly deciding what somebody's role is. The one exception is a legal
140
+ // overlay role on top of an illegal durable one: taking it keeps the person, where
141
+ // refusing loses them and their overlay together, and `role_overlay` still records
142
+ // that the overlay is where it came from.
143
+ role: MEMBER_ROLE_SET.includes(u.role) ? u.role
144
+ : (MEMBER_ROLE_SET.includes(roles[e]) ? roles[e] : (u.role || FALLBACK_ROLE)),
145
+ name: u.name || null,
146
+ initials: u.initials || null,
147
+ colour: u.color || null,
148
+ addedBy: u.addedBy || null,
149
+ source: fromConfig.has(e) ? "config" : "overlay",
150
+ // The OVERLAY half, one column each.
151
+ roleOverlay: typeof roles[e] === "string" && roles[e] ? roles[e] : null,
152
+ nameOverlay: nmRaw === undefined || nmRaw === null ? null : nmRaw,
153
+ avatarKey: av ? av.k ?? null : null,
154
+ avatarMime: av ? av.mime ?? null : null,
155
+ avatarAt: av ? av.at ?? null : null,
156
+ addedAt: u.addedAt || now,
157
+ removedAt: removedAt ?? null,
158
+ };
159
+ };
160
+
161
+ const members = merged.map((u) => rowFor(u));
162
+ // Tombstones for everyone the overlay removed, whether they came from the file or the
163
+ // overlay. Their role is whatever the record last said, so a re-invite starts from
164
+ // nothing rather than from the last holder's.
165
+ const byEmail = new Map((configUsers || []).map((u) => [lc(u && u.email), u]));
166
+ for (const e of removed) {
167
+ if (!e) continue;
168
+ const prior = byEmail.get(e) || (roster.add && roster.add[e]) || { email: e };
169
+ members.push(rowFor({ ...prior, email: e }, now));
170
+ }
171
+
172
+ // Invites are a RE-KEYING, not a copy: KV keys them by the raw token so it can look one
173
+ // up directly, and the object stores only a hash so a read of its storage cannot redeem
174
+ // anybody's invitation.
175
+ const invites = [];
176
+ if (has("users:invites")) {
177
+ take("users:invites");
178
+ if (typeof hashInvite !== "function") {
179
+ throw new Error("identityFromKv: users:invites needs a hashInvite function — the object stores only the hash");
180
+ }
181
+ for (const [token, rec] of Object.entries(doc("users:invites"))) {
182
+ if (!token || !rec) continue;
183
+ invites.push({
184
+ tokenHash: await hashInvite(token),
185
+ email: lc(rec.email),
186
+ createdAt: isoStamp(rec.createdAt, now),
187
+ expiresAt: isoStamp(rec.expires ?? rec.expiresAt, now),
188
+ // KV never recorded who sent an invite, and inventing a plausible author would be
189
+ // read as a fact about who let somebody in.
190
+ createdBy: null,
191
+ });
192
+ }
193
+ }
194
+
195
+ // Publish tokens need no re-keying: KV already stores them under a hash.
196
+ //
197
+ // ⚠️ THE SCOPE TRAVELS VERBATIM AND IS THE POINT OF THE ROW. KV's record is
198
+ // `{space, label, createdAt, expiresAt?}`, and `space` is not a label — it is what
199
+ // `publishAuthDetailed` refuses `wrong-space` on, with `*` meaning admin-equivalent
200
+ // because a star token can push the instance config, i.e. the roster. `*` stays `*` and a
201
+ // space id stays that space id: mapping either onto the other would widen every
202
+ // space-scoped token or refuse every star one, and nothing would say so until somebody
203
+ // published. A record with NO `space` copies across as null, which the read treats as
204
+ // "this object cannot answer for this token" rather than as any scope at all.
205
+ //
206
+ // ⚠️ AND `caps` TRAVELS BESIDE IT, FOR THE SAME REASON AND WITH A SHARPER FAILURE. KV's
207
+ // optional `caps` array is what `capabilityRefusal` reads deny-by-default: absent means
208
+ // unrestricted, a list means ONLY those routes. It is what lets the control plane hold a
209
+ // purge-only bearer instead of a star token that could publish over every workspace's
210
+ // content. A copy that dropped it handed the object a row saying `*` and nothing else —
211
+ // and since the object is what the request path reads FIRST, the narrow credential came
212
+ // back out of it as a FULL star token. `null` here is not "unknown": it is this
213
+ // translation stating that KV's record carries no capability, which is a fact it can see
214
+ // and a pre-`caps` copy could not.
215
+ const publishTokens = [];
216
+ if (has("publish:tokens")) {
217
+ take("publish:tokens");
218
+ for (const [hash, rec] of Object.entries(doc("publish:tokens"))) {
219
+ if (!hash || !rec) continue;
220
+ publishTokens.push({
221
+ tokenHash: hash,
222
+ scope: typeof rec.space === "string" && rec.space ? rec.space : null,
223
+ label: rec.label ?? null,
224
+ createdAt: rec.createdAt || now,
225
+ expiresAt: rec.expiresAt ?? null,
226
+ // Verbatim, including a malformed value: `capabilityRefusal` treats anything that is
227
+ // not a list as absent, and normalising here would decide on its behalf.
228
+ caps: rec.caps === undefined ? null : rec.caps,
229
+ });
230
+ }
231
+ }
232
+
233
+ const lastseen = [];
234
+ if (has("users:lastseen:")) {
235
+ take("users:lastseen:");
236
+ for (const [email, at] of Object.entries(doc("users:lastseen:"))) {
237
+ if (!email) continue;
238
+ lastseen.push({ email: lc(email), at: typeof at === "string" ? at : now });
239
+ }
240
+ }
241
+
242
+ // Small content-addressed blobs: profile photos and the workspace icon, both base64 data
243
+ // URIs. Canvas images are NOT here — they are megabytes and live in R2.
244
+ const blobs = [];
245
+ for (const [id, prefix] of [["avatar:", "avatar:"], ["spaceicon:", "spaceicon:"]]) {
246
+ if (!has(id)) continue;
247
+ take(id);
248
+ for (const [suffix, body] of Object.entries(doc(id))) {
249
+ if (!suffix || typeof body !== "string") continue;
250
+ blobs.push({ key: prefix + suffix, mime: null, body, at: now });
251
+ }
252
+ }
253
+
254
+ for (const [id, why] of Object.entries(UNMAPPED_WORKSPACE_FAMILIES)) {
255
+ if (has(id)) skipped.push({ id, why });
256
+ }
257
+
258
+ return { identity: { members, invites, publishTokens, lastseen, blobs }, consumed, skipped };
259
+ }