@augurworks/augur 0.15.6 → 0.15.8

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/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.6",
3
+ "version": "0.15.8",
4
4
  "files": [
5
5
  "scripts/",
6
6
  "build.js",
@@ -64,6 +64,32 @@ async function post(pathPart, body) {
64
64
  return { status: r.status, json };
65
65
  }
66
66
 
67
+ // A machine that is ALREADY connected to this origin does not pair again by accident. One
68
+ // cold agent collected its token with a blocking `connect`, did not read the answer, ran
69
+ // `connect` again, minted a second code, and had the person approve that too. If the saved
70
+ // token still answers, say so and stop; `--again` pairs afresh on purpose, and a token the
71
+ // workspace no longer honours (revoked, another workspace's) falls through to a new pairing.
72
+ const AGAIN = argv.includes("--again");
73
+ const TOKENS_FILE = path.join(os.homedir(), ".config", "augur", "tokens.json");
74
+ function savedToken() {
75
+ try { const t = JSON.parse(readFileSync(TOKENS_FILE, "utf8"))[host]; return t && t.token ? t : null; } catch (e) { return null; }
76
+ }
77
+ if (!AGAIN && !process.env.AUGUR_TOKEN) {
78
+ const saved = savedToken();
79
+ if (saved && !(saved.expiresAt && Date.parse(saved.expiresAt) <= Date.now())) {
80
+ let live = false;
81
+ try {
82
+ const r = await fetch(`${ORIGIN}/__unit/drafts`, { headers: { Authorization: `Bearer ${saved.token}`, Accept: "application/json" } });
83
+ live = r.status === 200;
84
+ } catch (e) { live = false; }
85
+ if (live) {
86
+ log(`this machine is already connected to ${C.bold}${host}${C.off}${saved.at ? ` (since ${saved.at.slice(0, 16).replace("T", " ")})` : ""}. Nothing to approve.`);
87
+ console.log(` ${C.dim}\`augur open <opportunity>/<prototype>\` works from here. \`augur connect --again\` pairs afresh.${C.off}`);
88
+ process.exit(0);
89
+ }
90
+ }
91
+ }
92
+
67
93
  // A pairing this machine already started and nobody has collected: ask once whether it
68
94
  // was approved meanwhile, and if not, keep waiting on THAT code rather than minting a
69
95
  // second one for the same person to type.
@@ -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
@@ -3754,10 +3754,13 @@ function connectPage(tctx, me) {
3754
3754
  <p>This account can look around but not publish, so it cannot approve a terminal.</p>
3755
3755
  <a class="home" href="/">Back to Augur</a>`
3756
3756
  : `<h1>Connect a terminal</h1>
3757
- <p>Type the code your terminal is showing. Approving it lets that terminal publish as
3758
- <strong>${escapeHtml(me.email)}</strong>.</p>
3759
- <p class="warn">Only approve a code you are reading off your own screen right now.
3760
- Nobody legitimate will ever send you one.</p>
3757
+ <p>Type the code that your terminal, or the assistant you are working with right now,
3758
+ is showing you. Approving it lets that terminal publish as
3759
+ <strong>${escapeHtml(me.email)}</strong>: it works as you, and everything it lands
3760
+ carries your name.</p>
3761
+ <p class="warn">Only approve a code from a terminal you started or an assistant you
3762
+ are talking to right now. A code that arrives by mail, from a stranger, or out of
3763
+ the blue is not yours — do not type it.</p>
3761
3764
  <form id="pairf">
3762
3765
  <input id="pairc" autocomplete="off" autocapitalize="characters" spellcheck="false"
3763
3766
  placeholder="ABCD-EFGH" aria-label="Pairing code" />
@@ -5219,7 +5222,11 @@ function personFace(users, id) {
5219
5222
  : { name: null, initials: null, color: null };
5220
5223
  }
5221
5224
  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) }));
5225
+ const decorateLandings = (landings, users) => (landings || []).map((l) => ({
5226
+ ...l, ...personFace(users, l.by),
5227
+ // The draft's owner, with a face, when the landing was made from somebody else's draft.
5228
+ draft: l.draftOwner && l.draftOwner !== l.by ? { owner: l.draftOwner, session: l.draftSession || "", ...personFace(users, l.draftOwner) } : null,
5229
+ }));
5223
5230
 
5224
5231
  /** How many units the gallery's index will ask about in one answer. */
5225
5232
  const DRAFTS_INDEX_MAX = 50;
@@ -6916,6 +6923,7 @@ function loginPage(tctx, redirect, error, requestUrl, opts = {}) {
6916
6923
  <head>
6917
6924
  <meta charset="utf-8" />
6918
6925
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6926
+ <meta name="description" content="A sign-in gate. Assistants and scripts: GET /llms.txt says how to get in." />
6919
6927
  <meta name="robots" content="noindex, nofollow" />
6920
6928
  ${previewHead(tctx, requestUrl)}
6921
6929
  <link rel="preload" href="/fonts/inter-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin />
@@ -7022,6 +7030,7 @@ function loginPage(tctx, redirect, error, requestUrl, opts = {}) {
7022
7030
  </div>
7023
7031
  ${formBody}
7024
7032
  ${tctx.LOGIN_HINT && !passwordless ? `<p class="hint">${escapeHtml(tctx.LOGIN_HINT)}</p>` : ""}
7033
+ <p class="hint door">Connecting an assistant or a script? It reads <a href="/llms.txt">/llms.txt</a>.</p>
7025
7034
  </main>
7026
7035
  </body>
7027
7036
  </html>`;
@@ -7359,8 +7368,86 @@ async function withDraftUi(tctx, res, url, me, env) {
7359
7368
  return new Response(html, { status: res.status, statusText: res.statusText, headers });
7360
7369
  }
7361
7370
  /** 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);
7371
+ async function serveContent(tctx, asset, url, me, env, request = null) {
7372
+ return withAgentPreface(withDoorLink(withAssetCache(await withDraftUi(tctx, await composeChrome(tctx, withLiveReload(tctx, asset, url), url), url, me, env), url)), request, url, me);
7373
+ }
7374
+
7375
+ /**
7376
+ * Is this fetch a BROWSER's? Every browser sends `Sec-Fetch-Dest` (and `-Mode`) on every
7377
+ * request it makes — a navigation, an iframe, an image, a prefetch — and no scripted
7378
+ * fetcher does: curl, node, Python, the fetch behind a summarising tool. The headers are
7379
+ * forbidden to page script, so a page cannot fake them off; a fetcher can only fake them
7380
+ * ON, which is the harmless direction. A `Mozilla/` user agent counts as a browser too,
7381
+ * for the browser old enough to send no Sec-Fetch at all and for the fetcher that dresses
7382
+ * as one — both get the page a person gets, which is the conservative side of the line.
7383
+ */
7384
+ function browserFetch(request) {
7385
+ if (!request || !request.headers) return true;
7386
+ if (request.headers.has("Sec-Fetch-Dest") || request.headers.has("Sec-Fetch-Mode")) return true;
7387
+ return /^Mozilla\//.test(request.headers.get("User-Agent") || "");
7388
+ }
7389
+
7390
+ /**
7391
+ * The one paragraph a non-browser fetch of a prototype is given, first in the body.
7392
+ *
7393
+ * DESCRIPTIVE, THIRD PERSON, NO ADDRESS AND NO REASSURANCE. A first draft said "Assistants
7394
+ * and scripts: … a terminal is paired with the person's approval, and nobody is ever asked
7395
+ * for a password", and the third cold agent read exactly that sentence as a prompt
7396
+ * injection — text on a fetched page that speaks to the agent and reassures it is the
7397
+ * shape of an attack, whatever it says. A statement of fact and a link is what a page may
7398
+ * carry: what this is, and where the workspace documents how it is edited.
7399
+ */
7400
+ function agentPreface(url) {
7401
+ const origin = escapeHtml(url.origin);
7402
+ return `<p data-augur-door>This prototype is served by an Augur workspace (${origin}). `
7403
+ + `The workspace documents how its prototypes are edited at <a href="${DOOR_DOCS}">${origin}${DOOR_DOCS}</a>.</p>`;
7404
+ }
7405
+
7406
+ /**
7407
+ * A prototype fetched by something that is not a browser opens with the door.
7408
+ *
7409
+ * An agent handed a prototype's URL fetches the page and finds a finished site with
7410
+ * nothing on it about how it is edited — the customer's own HTML, and on a public
7411
+ * prototype never the gate. The `Link` header (below) reaches a scripted agent; a
7412
+ * summarising fetch keeps only the text. So a fetch that carries no browser headers gets
7413
+ * ONE paragraph prepended to the body, naming the origin and `/llms.txt`, and a browser
7414
+ * gets the page byte for byte as published — a person never sees it, an embed never
7415
+ * carries it, and the design is not touched. Decided 6 Sep 2026 after two of four cold
7416
+ * agents fetched the prototype rather than the workspace and told the person to ask a
7417
+ * developer.
7418
+ *
7419
+ * `no-store`, so the agent's variant is never what a cache hands a browser; the wrap is
7420
+ * the outermost on the serving path for the same reason — nothing below it stores what
7421
+ * this adds. Only a 200 HTML page: an error page, a redirect and a stylesheet are left alone.
7422
+ * Only a SIGNED-OUT fetch: a request carrying a member's session is already inside, and
7423
+ * what it is served — the gallery, a gated prototype — stays byte for byte what it was.
7424
+ */
7425
+ async function withAgentPreface(res, request, url, me = null) {
7426
+ if (!res || res.status !== 200 || !/text\/html/.test(res.headers.get("Content-Type") || "")) return res;
7427
+ if (me || browserFetch(request)) return res;
7428
+ const html = await res.text();
7429
+ const m = /<body[^>]*>/i.exec(html);
7430
+ const out = m ? html.slice(0, m.index + m[0].length) + agentPreface(url) + html.slice(m.index + m[0].length) : agentPreface(url) + html;
7431
+ const headers = new Headers(res.headers);
7432
+ headers.delete("Content-Length");
7433
+ headers.delete("ETag");
7434
+ headers.set("Cache-Control", "no-store");
7435
+ headers.set("Vary", "Sec-Fetch-Dest, User-Agent");
7436
+ return new Response(out, { status: res.status, statusText: res.statusText, headers });
7437
+ }
7438
+ /**
7439
+ * A served page carries the same `Link: </llms.txt>; rel="help"` the gate carries. An agent
7440
+ * handed the URL of a PUBLIC prototype never meets the gate — it fetches the page, finds a
7441
+ * finished site with nothing on it about how it is edited, and gives up. The header is the
7442
+ * one pointer that costs the page no bytes and no pixels; a scripted agent reads it, and a
7443
+ * summarising one at least reaches the gate's own words at the workspace root.
7444
+ */
7445
+ function withDoorLink(res) {
7446
+ if (!res || !/text\/html/.test(res.headers.get("Content-Type") || "")) return res;
7447
+ if (res.headers.get("Link")) return res;
7448
+ const headers = new Headers(res.headers);
7449
+ headers.set("Link", `<${DOOR_DOCS}>; rel="help"`);
7450
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
7364
7451
  }
7365
7452
 
7366
7453
  // Test seam: loadConfig fills the chrome pointer, the workspace list and the runtime-
@@ -8386,6 +8473,43 @@ async function readStateFamily(tctx, env, entry, store, kv) {
8386
8473
  }
8387
8474
  return await store.read(family, "");
8388
8475
  }
8476
+ // ── the identity families the workspace OBJECT holds since the cut-over ──────────
8477
+ //
8478
+ // `publish:tokens` is read by the publish path from the object FIRST and KV second, and a
8479
+ // mint writes both — so a row can be in one store and not the other. Reading KV alone
8480
+ // here (which is what this did) produced a `--full` copy that omitted tokens which were
8481
+ // live and answering; a restore from it would have dropped them without a word. The copy
8482
+ // is the union of both stores.
8483
+ //
8484
+ // ⚠️ FOR A HASH BOTH STORES HOLD, KV'S RECORD IS THE ONE COPIED, VERBATIM. The object's
8485
+ // row is a projection into columns — it stamps a `createdAt` on a record the copy never
8486
+ // carried and spells `label` as null where the record had none — and a copy that took
8487
+ // the projection stopped verifying: `augur migrate` compares the target's export with the
8488
+ // source's, and a KV→workspace migration reported `publish:tokens` as not matching on
8489
+ // data that had landed. What the object ADDS is the rows KV lacks; what KV holds is what a
8490
+ // restore wrote and what a verifier reads back. (The admin panel lists the same union the
8491
+ // other way round — the object's row is what governs a publish — and that is a display
8492
+ // question, not a copy question.)
8493
+ //
8494
+ // An object that CANNOT be asked throws, and the export files that under `failed` — a
8495
+ // restore refuses such a copy. Answering from KV alone instead would be a copy that calls
8496
+ // itself full while missing every object-held token, the exact thing this branch closes.
8497
+ if (entry.id === PUBLISH_TOKENS_KEY) {
8498
+ const ident = identityFor(env, tctx, "publishTokens");
8499
+ if (ident) {
8500
+ const listed = await ident.tokenList();
8501
+ const held = (listed && listed.tokens && typeof listed.tokens === "object") ? listed.tokens : {};
8502
+ let map = null;
8503
+ if (kv) {
8504
+ const raw = await kv.get(entry.id);
8505
+ if (raw != null) { try { map = JSON.parse(raw); } catch (e) { map = null; } }
8506
+ }
8507
+ // Absent stays absent: no document in KV and no row in the object is the one answer a
8508
+ // restore must LEAVE alone (it clears a `{}`). A seeded-but-empty object is `{}`.
8509
+ if (map === null && !Object.keys(held).length && !(listed && listed.seeded)) return null;
8510
+ return { ...held, ...(map || {}) };
8511
+ }
8512
+ }
8389
8513
  if (!kv) return null;
8390
8514
  if (STATE_KV_PREFIXED.includes(entry.id)) {
8391
8515
  const out = {};
@@ -8652,6 +8776,9 @@ async function importState(tctx, env, doc) {
8652
8776
  },
8653
8777
  );
8654
8778
 
8779
+ // Whether the copy carried the roster overlay at all: the object counts the people
8780
+ // the copy does not name only when the copy claims to say who belongs.
8781
+ identity.rosterCarried = Object.prototype.hasOwnProperty.call(doc.families, "users:roster");
8655
8782
  const res = await stub.fetch("https://workspace/state/import", {
8656
8783
  method: "POST",
8657
8784
  headers: { "content-type": "application/json" },
@@ -8660,7 +8787,15 @@ async function importState(tctx, env, doc) {
8660
8787
  }),
8661
8788
  });
8662
8789
  if (!res.ok) return { ok: false, reason: "workspace-refused", status: res.status };
8663
- const { atomic, refused } = await res.json();
8790
+ const { atomic, refused, members } = await res.json();
8791
+ // The people `prune` removed from the object are removed the way the admin panel
8792
+ // removes them: the session, the KV-side token and invite copies go too. Best-effort
8793
+ // and after the object's transaction — the row is the fact, these are its consequences.
8794
+ for (const email of (members && members.removed) || []) {
8795
+ try { await revokeSecret(env, email, tctx); } catch (e) { /* no KV binding: nothing to revoke there */ }
8796
+ await revokePublishTokens(tctx, env, email);
8797
+ await revokeInvitesFor(tctx, env, email);
8798
+ }
8664
8799
  // ⚠️ THE IDENTITY FAMILIES GO TO BOTH, AND THAT IS THE POINT OF THE SPLIT.
8665
8800
  // The object gets a faithful copy and KV stays exactly what the KV path reads, so a
8666
8801
  // restore cannot take an instance down whichever store is currently answering. With the
@@ -8681,6 +8816,9 @@ async function importState(tctx, env, doc) {
8681
8816
  // from a complete one, which is the failure this whole path exists to avoid.
8682
8817
  unmapped: identitySkipped,
8683
8818
  refusedRows: refused || [],
8819
+ // Who the copy did not name and what became of them — `kept` on a plain restore,
8820
+ // `removed` under `prune`. Absent when the copy carried no roster.
8821
+ ...(members ? { members } : {}),
8684
8822
  };
8685
8823
  }
8686
8824
 
@@ -9100,6 +9238,8 @@ function wantsJson(request, url) {
9100
9238
  // probe and the "an unknown path and the root are the same page" contract depend on it.
9101
9239
  const DOOR_DOCS = "/llms.txt";
9102
9240
  const DOOR_WELL_KNOWN = "/.well-known/augur.json";
9241
+ /** The engine's public source — what the CLI on npm is built from, named so an agent can check. */
9242
+ const ENGINE_SOURCE = "https://github.com/andratwiro/augur";
9103
9243
 
9104
9244
  /** Does this deployment serve drafts — a unit store beside a bundle store. The same two checks `unitApi` makes. */
9105
9245
  const draftsServedHere = (env) => !!(env && env.BUNDLES && unitNamespace(env));
@@ -9114,6 +9254,10 @@ function doorFacts(tctx, url, env) {
9114
9254
  engine: tctx.INSTANCE_ENGINE_VERSION ? { version: tctx.INSTANCE_ENGINE_VERSION } : {},
9115
9255
  pairing: { enabled: pairing, start: "/__publish/_pair/start", approve: "/__connect" },
9116
9256
  connect: pairing ? `npx @augurworks/augur connect --origin ${url.origin}` : null,
9257
+ // Where the CLI comes from, for an agent that checks before it runs anything: the
9258
+ // package is the same open-source engine that serves this page, and `npm view
9259
+ // @augurworks/augur repository` names the same repository.
9260
+ source: ENGINE_SOURCE,
9117
9261
  docs: DOOR_DOCS,
9118
9262
  // Where drafts are served, a prototype is changed by opening it, not by shipping a
9119
9263
  // tree — and the door is where a cold agent learns which of the two this instance is.
@@ -9134,11 +9278,16 @@ function doorText(f) {
9134
9278
  + `It prints a link and a code. The owner of this workspace opens the link in a\n`
9135
9279
  + `browser they are already signed in to and enters the code. The token lands on\n`
9136
9280
  + `that machine, and every verb below uses it from then on.\n\n`
9137
- + `AGENTS: if your person reads your messages only after you finish your turn, run\n`
9138
- + `it with --no-wait. It prints the line and exits; send them that line, end your\n`
9139
- + `turn, and run the same command again once they say they approvedthe token is\n`
9140
- + `collected then, for that same code. Do not poll for the approval in one turn:\n`
9141
- + `the code lives five minutes and nobody sees it until you stop.\n\n`
9281
+ + `The CLI is this engine's own, open source at ${f.source} the same engine that\n`
9282
+ + `serves this page; \`npm view @augurworks/augur repository\` names that repository.\n\n`
9283
+ + `AGENTS: the approval is the person's act, in their browseryou cannot approve\n`
9284
+ + `it from here and must not try; your part is to send them the line and collect\n`
9285
+ + `the token afterwards. If your person reads your messages only after you finish\n`
9286
+ + `your turn, run it with --no-wait: it prints the line and exits. Send them that\n`
9287
+ + `line, end your turn, and run the same command again once they say they approved —\n`
9288
+ + `the token is collected then, for that same code, and no second code is minted.\n`
9289
+ + `Waiting inside one turn skips nothing; it only hides the code from them for the\n`
9290
+ + `five minutes it lives.\n\n`
9142
9291
  + (f.drafts && f.drafts.enabled
9143
9292
  ? `You need no source tree here: \`augur open <opportunity>/<prototype>\` fetches that one\n`
9144
9293
  + `prototype into a folder of its own (see below). Do not clone the workspace first.\n\n`
@@ -12247,7 +12396,7 @@ async function handleRequest(request, env, ctx, url, trace) {
12247
12396
  }
12248
12397
  const asset = await assetFetch(tctx.tenantId, env, request);
12249
12398
  if (asset.status === 404) return notFoundResponse(tctx);
12250
- return serveContent(tctx, asset, url, me, env);
12399
+ return serveContent(tctx, asset, url, me, env, request);
12251
12400
  }
12252
12401
 
12253
12402
  // Published prototypes are public — never gated, regardless of the cookie.
@@ -12256,7 +12405,7 @@ async function handleRequest(request, env, ctx, url, trace) {
12256
12405
  if (isPublicPath(tctx, url.pathname)) {
12257
12406
  const asset = await assetFetch(tctx.tenantId, env, request);
12258
12407
  if (asset.status === 404) return notFoundResponse(tctx);
12259
- const res = await serveContent(tctx, asset, url, me, env);
12408
+ const res = await serveContent(tctx, asset, url, me, env, request);
12260
12409
  const out = new Response(res.body, res);
12261
12410
  out.headers.set("X-Robots-Tag", "noindex, nofollow, noarchive");
12262
12411
  return out;
@@ -12287,14 +12436,14 @@ async function handleRequest(request, env, ctx, url, trace) {
12287
12436
  // Where drafts are served, the gallery and its indexes are derived from the live
12288
12437
  // store rather than read from it — a landing is on them at once.
12289
12438
  const derived = await derivedPage(tctx, env, url);
12290
- if (derived) return stamp(derived.status === 308 ? derived : await serveContent(tctx, derived, url, me, env));
12439
+ if (derived) return stamp(derived.status === 308 ? derived : await serveContent(tctx, derived, url, me, env, request));
12291
12440
  const asset = await assetFetch(tctx.tenantId, env, request, { dsDraft: ds.draft });
12292
12441
  if (asset.status === 404) {
12293
12442
  const virt = await virtualCanvas(tctx, request, env, url);
12294
12443
  if (virt) return stamp(virt);
12295
12444
  return stamp(notFoundResponse(tctx));
12296
12445
  }
12297
- return stamp(await serveContent(tctx, asset, url, me, env));
12446
+ return stamp(await serveContent(tctx, asset, url, me, env, request));
12298
12447
  }
12299
12448
 
12300
12449
  // Created canvas boards are public like published prototypes — same obscure
@@ -12376,7 +12525,7 @@ export const __testables = Object.freeze({
12376
12525
  doorFacts, doorText, wantsMachineDoor, gateResponse, DOOR_DOCS, DOOR_WELL_KNOWN,
12377
12526
  resumeAfterDormancy,
12378
12527
  PITI_VIEW_KEY, PITI_REMARKS_KEY,
12379
- publishAuthDetailed, unitApi, unitCaller, personFace, withDraftUi, draftUiBoot, derivedPage, dsOverlay, isEngineChrome, publishRefusalBody, splitDraftPath,
12528
+ publishAuthDetailed, unitApi, unitCaller, personFace, withDoorLink, withAgentPreface, browserFetch, withDraftUi, draftUiBoot, derivedPage, dsOverlay, isEngineChrome, publishRefusalBody, splitDraftPath,
12380
12529
  adminStorageApi,
12381
12530
  adminCustomDomainApi,
12382
12531
  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