@augurworks/augur 0.15.5 → 0.15.7

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/agents/README.md CHANGED
@@ -32,9 +32,11 @@ signed in to and types the code; the token lands in `~/.config/augur/tokens.json
32
32
  `augur ship` / `augur publish` use it from then on. `publish` runs the pairing itself when
33
33
  it finds no token, so inside a workspace tree nothing has to be done first. On a workspace
34
34
  that serves drafts (`/.well-known/augur.json` says `drafts.enabled`), the everyday verbs
35
- are `augur open` and `augur land` instead — see [drafts.md](./drafts.md). To get the
36
- tree from a hostname, pair first, then `npx @augurworks/augur clone --space <id>` it reads the
37
- origin from the pairing.
35
+ are `augur open` and `augur land` instead — see [drafts.md](./drafts.md). Where drafts
36
+ are served you need no tree at all: `augur open <opportunity>/<prototype>` fetches that one
37
+ prototype into a folder of its own (a cold agent that cloned a large workspace first spent
38
+ most of its first turn waiting). Elsewhere, `npx @augurworks/augur clone --space <id>` fetches
39
+ the tree — it reads the origin from the pairing.
38
40
 
39
41
  The instance says all of this itself at `GET /llms.txt` (and as data at
40
42
  `/.well-known/augur.json`); a signed-out request for an engine path answers `401` with
package/agents/drafts.md CHANGED
@@ -55,7 +55,8 @@ saves on every burst of changes; `augur save` saves once.
55
55
  ## Landing
56
56
 
57
57
  `augur land` replaces the prototype's real URL with your draft, records who landed it and
58
- when, and closes the draft. It is refused in exactly one case: somebody landed on this
58
+ when and whose draft it was, when a member lands somebody else's from the site — and
59
+ closes the draft. It is refused in exactly one case: somebody landed on this
59
60
  prototype since you opened yours. Then:
60
61
 
61
62
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@augurworks/augur",
3
- "version": "0.15.5",
3
+ "version": "0.15.7",
4
4
  "files": [
5
5
  "scripts/",
6
6
  "build.js",
@@ -39,10 +39,30 @@ export function resolveOrigin(root = ENGINE_ROOT) {
39
39
  cwdSpaceOrigin = JSON.parse(readFileSync(path.join(process.cwd(), "space.json"), "utf8")).siteOrigin || "";
40
40
  } catch (e) {}
41
41
  return (process.env.AUGUR_ORIGIN || env.AUGUR_ORIGIN ||
42
- deployConfig(root, originHost(cwdSpaceOrigin)).siteOrigin || cwdSpaceOrigin || "")
42
+ deployConfig(root, originHost(cwdSpaceOrigin)).siteOrigin || cwdSpaceOrigin || pairedOrigin() || "")
43
43
  .replace(/\/+$/, "");
44
44
  }
45
45
 
46
+ /**
47
+ * The origin this machine last paired with, when nothing else names one. A cold agent
48
+ * in an empty folder has just run `augur connect --origin X` and holds a token for X and
49
+ * nothing else — and `clone`/`open` then refused with "no target origin" until it typed
50
+ * AUGUR_ORIGIN, which the front door had told it the pairing would carry. One saved
51
+ * pairing is the answer; several is the most recent, said on stderr so a wrong guess is
52
+ * visible before anything is written.
53
+ */
54
+ export function pairedOrigin() {
55
+ try {
56
+ const saved = JSON.parse(readFileSync(path.join(os.homedir(), ".config", "augur", "tokens.json"), "utf8"));
57
+ const hosts = Object.entries(saved).filter(([h, v]) => h && v && v.token);
58
+ if (!hosts.length) return "";
59
+ hosts.sort((a, b) => String(b[1].at || "").localeCompare(String(a[1].at || "")));
60
+ const [host] = hosts[0];
61
+ if (hosts.length > 1) console.error(`[augur] no origin named — using the last pairing, https://${host} (set AUGUR_ORIGIN to pick another)`);
62
+ return `https://${host}`;
63
+ } catch (e) { return ""; }
64
+ }
65
+
46
66
  export function resolveToken(origin, root = ENGINE_ROOT) {
47
67
  const env = readEnvFile(path.join(root, ".env.deploy"));
48
68
  let token = process.env.AUGUR_TOKEN || env.AUGUR_TOKEN || "";
@@ -292,6 +292,14 @@ if (STATE) {
292
292
  })).json();
293
293
  if (!res.ok) die(`the instance refused the state: ${res.reason}${res.failed ? ` (${res.failed.join(", ")})` : ""}`);
294
294
  if (res.skipped && res.skipped.length) log(`\x1b[33mskipped (not in the instance's inventory): ${res.skipped.join(", ")}\x1b[0m`);
295
+ // A restore says "at least this": a member the copy does not name stays, and the
296
+ // instance names them so a keep is never mistaken for a replace.
297
+ if (res.members && res.members.kept && res.members.kept.length) {
298
+ log(`\x1b[33mkept ${res.members.kept.length} member(s) this copy does not name (a restore removes nobody): ${res.members.kept.join(", ")} — remove them in the people panel if they should go\x1b[0m`);
299
+ }
300
+ if (res.members && res.members.removed && res.members.removed.length) {
301
+ log(`removed ${res.members.removed.length} member(s) this copy does not name: ${res.members.removed.join(", ")}`);
302
+ }
295
303
  stateReport = res;
296
304
  }
297
305
  }
package/src/_worker.js CHANGED
@@ -5219,7 +5219,11 @@ function personFace(users, id) {
5219
5219
  : { name: null, initials: null, color: null };
5220
5220
  }
5221
5221
  const decorateDrafts = (drafts, users) => (drafts || []).map((d) => ({ ...d, ...personFace(users, d.owner) }));
5222
- const decorateLandings = (landings, users) => (landings || []).map((l) => ({ ...l, ...personFace(users, l.by) }));
5222
+ const decorateLandings = (landings, users) => (landings || []).map((l) => ({
5223
+ ...l, ...personFace(users, l.by),
5224
+ // The draft's owner, with a face, when the landing was made from somebody else's draft.
5225
+ draft: l.draftOwner && l.draftOwner !== l.by ? { owner: l.draftOwner, session: l.draftSession || "", ...personFace(users, l.draftOwner) } : null,
5226
+ }));
5223
5227
 
5224
5228
  /** How many units the gallery's index will ask about in one answer. */
5225
5229
  const DRAFTS_INDEX_MAX = 50;
@@ -6916,6 +6920,7 @@ function loginPage(tctx, redirect, error, requestUrl, opts = {}) {
6916
6920
  <head>
6917
6921
  <meta charset="utf-8" />
6918
6922
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6923
+ <meta name="description" content="A sign-in gate. Assistants and scripts: GET /llms.txt says how to get in." />
6919
6924
  <meta name="robots" content="noindex, nofollow" />
6920
6925
  ${previewHead(tctx, requestUrl)}
6921
6926
  <link rel="preload" href="/fonts/inter-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin />
@@ -7022,6 +7027,7 @@ function loginPage(tctx, redirect, error, requestUrl, opts = {}) {
7022
7027
  </div>
7023
7028
  ${formBody}
7024
7029
  ${tctx.LOGIN_HINT && !passwordless ? `<p class="hint">${escapeHtml(tctx.LOGIN_HINT)}</p>` : ""}
7030
+ <p class="hint door">Connecting an assistant or a script? It reads <a href="/llms.txt">/llms.txt</a>.</p>
7025
7031
  </main>
7026
7032
  </body>
7027
7033
  </html>`;
@@ -7359,8 +7365,78 @@ async function withDraftUi(tctx, res, url, me, env) {
7359
7365
  return new Response(html, { status: res.status, statusText: res.statusText, headers });
7360
7366
  }
7361
7367
  /** A content response, dressed: the live-reload poll, the current chrome, the draft bar, the cache policy. */
7362
- async function serveContent(tctx, asset, url, me, env) {
7363
- return withAssetCache(await withDraftUi(tctx, await composeChrome(tctx, withLiveReload(tctx, asset, url), url), url, me, env), url);
7368
+ async function serveContent(tctx, asset, url, me, env, request = null) {
7369
+ return withAgentPreface(withDoorLink(withAssetCache(await withDraftUi(tctx, await composeChrome(tctx, withLiveReload(tctx, asset, url), url), url, me, env), url)), request, url, me);
7370
+ }
7371
+
7372
+ /**
7373
+ * Is this fetch a BROWSER's? Every browser sends `Sec-Fetch-Dest` (and `-Mode`) on every
7374
+ * request it makes — a navigation, an iframe, an image, a prefetch — and no scripted
7375
+ * fetcher does: curl, node, Python, the fetch behind a summarising tool. The headers are
7376
+ * forbidden to page script, so a page cannot fake them off; a fetcher can only fake them
7377
+ * ON, which is the harmless direction. A `Mozilla/` user agent counts as a browser too,
7378
+ * for the browser old enough to send no Sec-Fetch at all and for the fetcher that dresses
7379
+ * as one — both get the page a person gets, which is the conservative side of the line.
7380
+ */
7381
+ function browserFetch(request) {
7382
+ if (!request || !request.headers) return true;
7383
+ if (request.headers.has("Sec-Fetch-Dest") || request.headers.has("Sec-Fetch-Mode")) return true;
7384
+ return /^Mozilla\//.test(request.headers.get("User-Agent") || "");
7385
+ }
7386
+
7387
+ /** The one paragraph a non-browser fetch of a prototype is given, first in the body. */
7388
+ function agentPreface(url) {
7389
+ const origin = escapeHtml(url.origin);
7390
+ return `<p data-augur-door>This prototype is served by an Augur workspace at ${origin}. `
7391
+ + `Assistants and scripts: <a href="${DOOR_DOCS}">${origin}${DOOR_DOCS}</a> says how to change it — `
7392
+ + `a terminal is paired with the person's approval, and nobody is ever asked for a password.</p>`;
7393
+ }
7394
+
7395
+ /**
7396
+ * A prototype fetched by something that is not a browser opens with the door.
7397
+ *
7398
+ * An agent handed a prototype's URL fetches the page and finds a finished site with
7399
+ * nothing on it about how it is edited — the customer's own HTML, and on a public
7400
+ * prototype never the gate. The `Link` header (below) reaches a scripted agent; a
7401
+ * summarising fetch keeps only the text. So a fetch that carries no browser headers gets
7402
+ * ONE paragraph prepended to the body, naming the origin and `/llms.txt`, and a browser
7403
+ * gets the page byte for byte as published — a person never sees it, an embed never
7404
+ * carries it, and the design is not touched. Decided 6 Sep 2026 after two of four cold
7405
+ * agents fetched the prototype rather than the workspace and told the person to ask a
7406
+ * developer.
7407
+ *
7408
+ * `no-store`, so the agent's variant is never what a cache hands a browser; the wrap is
7409
+ * the outermost on the serving path for the same reason — nothing below it stores what
7410
+ * this adds. Only a 200 HTML page: an error page, a redirect and a stylesheet are left alone.
7411
+ * Only a SIGNED-OUT fetch: a request carrying a member's session is already inside, and
7412
+ * what it is served — the gallery, a gated prototype — stays byte for byte what it was.
7413
+ */
7414
+ async function withAgentPreface(res, request, url, me = null) {
7415
+ if (!res || res.status !== 200 || !/text\/html/.test(res.headers.get("Content-Type") || "")) return res;
7416
+ if (me || browserFetch(request)) return res;
7417
+ const html = await res.text();
7418
+ const m = /<body[^>]*>/i.exec(html);
7419
+ const out = m ? html.slice(0, m.index + m[0].length) + agentPreface(url) + html.slice(m.index + m[0].length) : agentPreface(url) + html;
7420
+ const headers = new Headers(res.headers);
7421
+ headers.delete("Content-Length");
7422
+ headers.delete("ETag");
7423
+ headers.set("Cache-Control", "no-store");
7424
+ headers.set("Vary", "Sec-Fetch-Dest, User-Agent");
7425
+ return new Response(out, { status: res.status, statusText: res.statusText, headers });
7426
+ }
7427
+ /**
7428
+ * A served page carries the same `Link: </llms.txt>; rel="help"` the gate carries. An agent
7429
+ * handed the URL of a PUBLIC prototype never meets the gate — it fetches the page, finds a
7430
+ * finished site with nothing on it about how it is edited, and gives up. The header is the
7431
+ * one pointer that costs the page no bytes and no pixels; a scripted agent reads it, and a
7432
+ * summarising one at least reaches the gate's own words at the workspace root.
7433
+ */
7434
+ function withDoorLink(res) {
7435
+ if (!res || !/text\/html/.test(res.headers.get("Content-Type") || "")) return res;
7436
+ if (res.headers.get("Link")) return res;
7437
+ const headers = new Headers(res.headers);
7438
+ headers.set("Link", `<${DOOR_DOCS}>; rel="help"`);
7439
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
7364
7440
  }
7365
7441
 
7366
7442
  // Test seam: loadConfig fills the chrome pointer, the workspace list and the runtime-
@@ -8386,6 +8462,43 @@ async function readStateFamily(tctx, env, entry, store, kv) {
8386
8462
  }
8387
8463
  return await store.read(family, "");
8388
8464
  }
8465
+ // ── the identity families the workspace OBJECT holds since the cut-over ──────────
8466
+ //
8467
+ // `publish:tokens` is read by the publish path from the object FIRST and KV second, and a
8468
+ // mint writes both — so a row can be in one store and not the other. Reading KV alone
8469
+ // here (which is what this did) produced a `--full` copy that omitted tokens which were
8470
+ // live and answering; a restore from it would have dropped them without a word. The copy
8471
+ // is the union of both stores.
8472
+ //
8473
+ // ⚠️ FOR A HASH BOTH STORES HOLD, KV'S RECORD IS THE ONE COPIED, VERBATIM. The object's
8474
+ // row is a projection into columns — it stamps a `createdAt` on a record the copy never
8475
+ // carried and spells `label` as null where the record had none — and a copy that took
8476
+ // the projection stopped verifying: `augur migrate` compares the target's export with the
8477
+ // source's, and a KV→workspace migration reported `publish:tokens` as not matching on
8478
+ // data that had landed. What the object ADDS is the rows KV lacks; what KV holds is what a
8479
+ // restore wrote and what a verifier reads back. (The admin panel lists the same union the
8480
+ // other way round — the object's row is what governs a publish — and that is a display
8481
+ // question, not a copy question.)
8482
+ //
8483
+ // An object that CANNOT be asked throws, and the export files that under `failed` — a
8484
+ // restore refuses such a copy. Answering from KV alone instead would be a copy that calls
8485
+ // itself full while missing every object-held token, the exact thing this branch closes.
8486
+ if (entry.id === PUBLISH_TOKENS_KEY) {
8487
+ const ident = identityFor(env, tctx, "publishTokens");
8488
+ if (ident) {
8489
+ const listed = await ident.tokenList();
8490
+ const held = (listed && listed.tokens && typeof listed.tokens === "object") ? listed.tokens : {};
8491
+ let map = null;
8492
+ if (kv) {
8493
+ const raw = await kv.get(entry.id);
8494
+ if (raw != null) { try { map = JSON.parse(raw); } catch (e) { map = null; } }
8495
+ }
8496
+ // Absent stays absent: no document in KV and no row in the object is the one answer a
8497
+ // restore must LEAVE alone (it clears a `{}`). A seeded-but-empty object is `{}`.
8498
+ if (map === null && !Object.keys(held).length && !(listed && listed.seeded)) return null;
8499
+ return { ...held, ...(map || {}) };
8500
+ }
8501
+ }
8389
8502
  if (!kv) return null;
8390
8503
  if (STATE_KV_PREFIXED.includes(entry.id)) {
8391
8504
  const out = {};
@@ -8652,6 +8765,9 @@ async function importState(tctx, env, doc) {
8652
8765
  },
8653
8766
  );
8654
8767
 
8768
+ // Whether the copy carried the roster overlay at all: the object counts the people
8769
+ // the copy does not name only when the copy claims to say who belongs.
8770
+ identity.rosterCarried = Object.prototype.hasOwnProperty.call(doc.families, "users:roster");
8655
8771
  const res = await stub.fetch("https://workspace/state/import", {
8656
8772
  method: "POST",
8657
8773
  headers: { "content-type": "application/json" },
@@ -8660,7 +8776,15 @@ async function importState(tctx, env, doc) {
8660
8776
  }),
8661
8777
  });
8662
8778
  if (!res.ok) return { ok: false, reason: "workspace-refused", status: res.status };
8663
- const { atomic, refused } = await res.json();
8779
+ const { atomic, refused, members } = await res.json();
8780
+ // The people `prune` removed from the object are removed the way the admin panel
8781
+ // removes them: the session, the KV-side token and invite copies go too. Best-effort
8782
+ // and after the object's transaction — the row is the fact, these are its consequences.
8783
+ for (const email of (members && members.removed) || []) {
8784
+ try { await revokeSecret(env, email, tctx); } catch (e) { /* no KV binding: nothing to revoke there */ }
8785
+ await revokePublishTokens(tctx, env, email);
8786
+ await revokeInvitesFor(tctx, env, email);
8787
+ }
8664
8788
  // ⚠️ THE IDENTITY FAMILIES GO TO BOTH, AND THAT IS THE POINT OF THE SPLIT.
8665
8789
  // The object gets a faithful copy and KV stays exactly what the KV path reads, so a
8666
8790
  // restore cannot take an instance down whichever store is currently answering. With the
@@ -8681,6 +8805,9 @@ async function importState(tctx, env, doc) {
8681
8805
  // from a complete one, which is the failure this whole path exists to avoid.
8682
8806
  unmapped: identitySkipped,
8683
8807
  refusedRows: refused || [],
8808
+ // Who the copy did not name and what became of them — `kept` on a plain restore,
8809
+ // `removed` under `prune`. Absent when the copy carried no roster.
8810
+ ...(members ? { members } : {}),
8684
8811
  };
8685
8812
  }
8686
8813
 
@@ -9139,9 +9266,11 @@ function doorText(f) {
9139
9266
  + `turn, and run the same command again once they say they approved — the token is\n`
9140
9267
  + `collected then, for that same code. Do not poll for the approval in one turn:\n`
9141
9268
  + `the code lives five minutes and nobody sees it until you stop.\n\n`
9142
- + `With no\n`
9143
- + `source tree yet, \`npx @augurworks/augur clone --space ${f.workspace}\` then fetches one (it reads\n`
9144
- + `the origin from the pairing).\n\n`
9269
+ + (f.drafts && f.drafts.enabled
9270
+ ? `You need no source tree here: \`augur open <opportunity>/<prototype>\` fetches that one\n`
9271
+ + `prototype into a folder of its own (see below). Do not clone the workspace first.\n\n`
9272
+ : `With no source tree yet, \`npx @augurworks/augur clone --space ${f.workspace}\` then fetches\n`
9273
+ + `one (it reads the origin from the pairing).\n\n`)
9145
9274
  : `Device pairing is switched off on this workspace. Ask an admin for an invite;\n`
9146
9275
  + `once you have signed in, \`augur login\` (email and password, meant for CI)\n`
9147
9276
  + `trades that for a publish token.\n\n`;
@@ -12245,7 +12374,7 @@ async function handleRequest(request, env, ctx, url, trace) {
12245
12374
  }
12246
12375
  const asset = await assetFetch(tctx.tenantId, env, request);
12247
12376
  if (asset.status === 404) return notFoundResponse(tctx);
12248
- return serveContent(tctx, asset, url, me, env);
12377
+ return serveContent(tctx, asset, url, me, env, request);
12249
12378
  }
12250
12379
 
12251
12380
  // Published prototypes are public — never gated, regardless of the cookie.
@@ -12254,7 +12383,7 @@ async function handleRequest(request, env, ctx, url, trace) {
12254
12383
  if (isPublicPath(tctx, url.pathname)) {
12255
12384
  const asset = await assetFetch(tctx.tenantId, env, request);
12256
12385
  if (asset.status === 404) return notFoundResponse(tctx);
12257
- const res = await serveContent(tctx, asset, url, me, env);
12386
+ const res = await serveContent(tctx, asset, url, me, env, request);
12258
12387
  const out = new Response(res.body, res);
12259
12388
  out.headers.set("X-Robots-Tag", "noindex, nofollow, noarchive");
12260
12389
  return out;
@@ -12285,14 +12414,14 @@ async function handleRequest(request, env, ctx, url, trace) {
12285
12414
  // Where drafts are served, the gallery and its indexes are derived from the live
12286
12415
  // store rather than read from it — a landing is on them at once.
12287
12416
  const derived = await derivedPage(tctx, env, url);
12288
- if (derived) return stamp(derived.status === 308 ? derived : await serveContent(tctx, derived, url, me, env));
12417
+ if (derived) return stamp(derived.status === 308 ? derived : await serveContent(tctx, derived, url, me, env, request));
12289
12418
  const asset = await assetFetch(tctx.tenantId, env, request, { dsDraft: ds.draft });
12290
12419
  if (asset.status === 404) {
12291
12420
  const virt = await virtualCanvas(tctx, request, env, url);
12292
12421
  if (virt) return stamp(virt);
12293
12422
  return stamp(notFoundResponse(tctx));
12294
12423
  }
12295
- return stamp(await serveContent(tctx, asset, url, me, env));
12424
+ return stamp(await serveContent(tctx, asset, url, me, env, request));
12296
12425
  }
12297
12426
 
12298
12427
  // Created canvas boards are public like published prototypes — same obscure
@@ -12374,7 +12503,7 @@ export const __testables = Object.freeze({
12374
12503
  doorFacts, doorText, wantsMachineDoor, gateResponse, DOOR_DOCS, DOOR_WELL_KNOWN,
12375
12504
  resumeAfterDormancy,
12376
12505
  PITI_VIEW_KEY, PITI_REMARKS_KEY,
12377
- publishAuthDetailed, unitApi, unitCaller, personFace, withDraftUi, draftUiBoot, derivedPage, dsOverlay, isEngineChrome, publishRefusalBody, splitDraftPath,
12506
+ publishAuthDetailed, unitApi, unitCaller, personFace, withDoorLink, withAgentPreface, browserFetch, withDraftUi, draftUiBoot, derivedPage, dsOverlay, isEngineChrome, publishRefusalBody, splitDraftPath,
12378
12507
  adminStorageApi,
12379
12508
  adminCustomDomainApi,
12380
12509
  isPrefixBacked, backedPublicPrefixes,
@@ -223,6 +223,7 @@
223
223
  row.appendChild(face(l));
224
224
  var text = el("div");
225
225
  var who = l.by === "live" ? "Adopted from the live site" : label(l);
226
+ if (l.draft && l.draft.owner) who += " · draft by " + label(l.draft);
226
227
  text.appendChild(el("div", null, "#" + l.revision + " · " + who));
227
228
  var meta = (l.note ? l.note + " · " : "") + ago(l.at, "landed")
228
229
  + (l.restoredFrom ? " · restored from #" + l.restoredFrom : "")
package/src/tenant-do.js CHANGED
@@ -739,7 +739,7 @@ function seedCount(seed) {
739
739
  * `users:roster`'s `remove` list, and dropping the person here instead would let a
740
740
  * re-invite inherit the role the last holder of that address had.
741
741
  */
742
- function writeIdentity(sql, identity, at, written = [], refused = []) {
742
+ function writeIdentity(sql, identity, at, written = [], refused = [], { prune = false } = {}) {
743
743
  if (!identity || typeof identity !== "object") return { written, refused };
744
744
  const list = (k) => (Array.isArray(identity[k]) ? identity[k] : []);
745
745
  const touched = (family, n) => { if (n) written.push(family); };
@@ -775,6 +775,39 @@ function writeIdentity(sql, identity, at, written = [], refused = []) {
775
775
  touched("members", n);
776
776
  if (n) markSeeded(sql, "roster", at);
777
777
 
778
+ // ── the people the copy does NOT name ──────────────────────────────────────────
779
+ //
780
+ // On KV the roster overlay is one document and an import replaces it, so anybody the
781
+ // copy does not name is gone. Here the rows above were upserted and every other row was
782
+ // left as it was — a member the copy does not name STAYS, with their session and their
783
+ // tokens, and for a while this reported `users:roster` as written and said nothing else,
784
+ // which read as a replace. So the ones left are counted and named:
785
+ //
786
+ // · a plain restore says "at least this" and KEEPS them — `kept` says who, so a person
787
+ // reading the result cannot mistake a keep for a replace;
788
+ // · `prune` says "exactly this" and REMOVES them the way the admin panel's remove
789
+ // does — a tombstone (a re-invite must not inherit the old role), no publish token,
790
+ // no outstanding invite. The worker revokes the session and the KV-side copies.
791
+ //
792
+ // Only overlay rows: a config member is the durable roster, not the copy's to remove.
793
+ // Only when the copy CARRIED the roster family — a copy of nothing but statuses says
794
+ // nothing about who belongs. `rosterCarried` is the worker's word for that.
795
+ let members;
796
+ if (identity.rosterCarried) {
797
+ const named = new Set(list("members").map((m) => lcAddr(m && m.email)).filter(Boolean));
798
+ const left = [...sql.exec(
799
+ `SELECT email FROM members WHERE source = 'overlay' AND removed_at IS NULL ORDER BY email`,
800
+ )].map((r) => String(r.email)).filter((e) => !named.has(e));
801
+ members = { kept: [], removed: [] };
802
+ for (const e of left) {
803
+ if (!prune) { members.kept.push(e); continue; }
804
+ sql.exec(`UPDATE members SET removed_at = ? WHERE email = ?`, at, e);
805
+ sql.exec(`DELETE FROM publish_tokens WHERE LOWER(label) = ?`, e);
806
+ sql.exec(`DELETE FROM invites WHERE email = ?`, e);
807
+ members.removed.push(e);
808
+ }
809
+ }
810
+
778
811
  n = 0;
779
812
  for (const i of list("invites")) {
780
813
  if (!i || !i.tokenHash) continue;
@@ -838,7 +871,7 @@ function writeIdentity(sql, identity, at, written = [], refused = []) {
838
871
  }
839
872
  touched("blobs", n);
840
873
 
841
- return { written, refused };
874
+ return { written, refused, members };
842
875
  }
843
876
 
844
877
  /**
@@ -2550,11 +2583,12 @@ export class TenantStore {
2550
2583
  written.push(scope ? `${family}/${scope}` : family);
2551
2584
  }
2552
2585
  }
2553
- writeIdentity(this.sql, identity, stamp, written, refused);
2586
+ members = writeIdentity(this.sql, identity, stamp, written, refused, { prune }).members;
2554
2587
  };
2588
+ let members;
2555
2589
  const atomic = typeof this.ctx.storage.transactionSync === "function";
2556
2590
  if (atomic) this.ctx.storage.transactionSync(body); else body();
2557
- return { written, refused, atomic };
2591
+ return members ? { written, refused, atomic, members } : { written, refused, atomic };
2558
2592
  }
2559
2593
 
2560
2594
  /**
@@ -265,11 +265,17 @@ export class UnitObject {
265
265
  const rows = [...this.sql.exec(`SELECT * FROM landings ORDER BY revision DESC`)];
266
266
  return {
267
267
  revision: this.mainRevision(),
268
- landings: rows.map((r) => ({
269
- revision: Number(r.revision), by: r.by || null, session: r.session || "", at: r.at, note: r.note || "",
270
- draftId: r.draft_id || null, restoredFrom: r.restored_from == null ? null : Number(r.restored_from),
271
- files: Object.keys(JSON.parse(r.tbl)).length,
272
- })),
268
+ landings: rows.map((r) => {
269
+ // Any member may land any draft from the bar, so a landing has two people on it:
270
+ // who pressed Land (`by`) and whose draft it was. The draft row keeps the second.
271
+ const d = r.draft_id ? this.draft(r.draft_id) : null;
272
+ return {
273
+ revision: Number(r.revision), by: r.by || null, session: r.session || "", at: r.at, note: r.note || "",
274
+ draftId: r.draft_id || null, restoredFrom: r.restored_from == null ? null : Number(r.restored_from),
275
+ draftOwner: d ? d.owner : null, draftSession: d ? d.session : "",
276
+ files: Object.keys(JSON.parse(r.tbl)).length,
277
+ };
278
+ }),
273
279
  };
274
280
  }
275
281