@augurworks/augur 0.15.6 → 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/drafts.md +2 -1
- package/package.json +1 -1
- package/scripts/restore.mjs +8 -0
- package/src/_worker.js +136 -9
- package/src/drafts/drafts.js +1 -0
- package/src/tenant-do.js +38 -4
- package/src/unit-object.mjs +11 -5
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
|
|
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
package/scripts/restore.mjs
CHANGED
|
@@ -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) => ({
|
|
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
|
|
|
@@ -12247,7 +12374,7 @@ async function handleRequest(request, env, ctx, url, trace) {
|
|
|
12247
12374
|
}
|
|
12248
12375
|
const asset = await assetFetch(tctx.tenantId, env, request);
|
|
12249
12376
|
if (asset.status === 404) return notFoundResponse(tctx);
|
|
12250
|
-
return serveContent(tctx, asset, url, me, env);
|
|
12377
|
+
return serveContent(tctx, asset, url, me, env, request);
|
|
12251
12378
|
}
|
|
12252
12379
|
|
|
12253
12380
|
// Published prototypes are public — never gated, regardless of the cookie.
|
|
@@ -12256,7 +12383,7 @@ async function handleRequest(request, env, ctx, url, trace) {
|
|
|
12256
12383
|
if (isPublicPath(tctx, url.pathname)) {
|
|
12257
12384
|
const asset = await assetFetch(tctx.tenantId, env, request);
|
|
12258
12385
|
if (asset.status === 404) return notFoundResponse(tctx);
|
|
12259
|
-
const res = await serveContent(tctx, asset, url, me, env);
|
|
12386
|
+
const res = await serveContent(tctx, asset, url, me, env, request);
|
|
12260
12387
|
const out = new Response(res.body, res);
|
|
12261
12388
|
out.headers.set("X-Robots-Tag", "noindex, nofollow, noarchive");
|
|
12262
12389
|
return out;
|
|
@@ -12287,14 +12414,14 @@ async function handleRequest(request, env, ctx, url, trace) {
|
|
|
12287
12414
|
// Where drafts are served, the gallery and its indexes are derived from the live
|
|
12288
12415
|
// store rather than read from it — a landing is on them at once.
|
|
12289
12416
|
const derived = await derivedPage(tctx, env, url);
|
|
12290
|
-
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));
|
|
12291
12418
|
const asset = await assetFetch(tctx.tenantId, env, request, { dsDraft: ds.draft });
|
|
12292
12419
|
if (asset.status === 404) {
|
|
12293
12420
|
const virt = await virtualCanvas(tctx, request, env, url);
|
|
12294
12421
|
if (virt) return stamp(virt);
|
|
12295
12422
|
return stamp(notFoundResponse(tctx));
|
|
12296
12423
|
}
|
|
12297
|
-
return stamp(await serveContent(tctx, asset, url, me, env));
|
|
12424
|
+
return stamp(await serveContent(tctx, asset, url, me, env, request));
|
|
12298
12425
|
}
|
|
12299
12426
|
|
|
12300
12427
|
// Created canvas boards are public like published prototypes — same obscure
|
|
@@ -12376,7 +12503,7 @@ export const __testables = Object.freeze({
|
|
|
12376
12503
|
doorFacts, doorText, wantsMachineDoor, gateResponse, DOOR_DOCS, DOOR_WELL_KNOWN,
|
|
12377
12504
|
resumeAfterDormancy,
|
|
12378
12505
|
PITI_VIEW_KEY, PITI_REMARKS_KEY,
|
|
12379
|
-
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,
|
|
12380
12507
|
adminStorageApi,
|
|
12381
12508
|
adminCustomDomainApi,
|
|
12382
12509
|
isPrefixBacked, backedPublicPrefixes,
|
package/src/drafts/drafts.js
CHANGED
|
@@ -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
|
/**
|
package/src/unit-object.mjs
CHANGED
|
@@ -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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|