@augurworks/augur 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CANVAS.md +858 -0
- package/INSTALL.md +603 -0
- package/LICENSE +27 -0
- package/README.md +150 -0
- package/TRADEMARKS.md +72 -0
- package/agents/README.md +65 -0
- package/agents/canon-extract.md +117 -0
- package/agents/canon.md +151 -0
- package/agents/canvas.md +88 -0
- package/agents/currency.md +74 -0
- package/agents/drafts.md +92 -0
- package/agents/identity.md +148 -0
- package/agents/prototype-contract.md +69 -0
- package/agents/publishing.md +324 -0
- package/agents/review-feedback.md +76 -0
- package/agents/space-json.md +132 -0
- package/agents/ui-skill.md +101 -0
- package/agents/working-marks.md +86 -0
- package/brand/augur-eye.svg +3 -0
- package/brand/augur-icon.svg +41 -0
- package/brand/augur-mark.svg +5 -0
- package/brand/lentianova-bulky.otf +0 -0
- package/brand/lentianova-median.otf +0 -0
- package/brand/lentianova.woff2 +0 -0
- package/build.js +8409 -0
- package/changelog.md +133 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/package.json +57 -0
- package/pitis/PETS.md +121 -0
- package/pitis/README.md +117 -0
- package/pitis/index.html +266 -0
- package/pitis/piti.build.js +81 -0
- package/pitis/piti.js +929 -0
- package/pitis/roast-agent.md +185 -0
- package/realtime/src/index.js +119 -0
- package/realtime/wrangler.example.toml +44 -0
- package/scripts/adopt.mjs +106 -0
- package/scripts/board-snapshot.mjs +197 -0
- package/scripts/build-seed-pack.mjs +33 -0
- package/scripts/bundle-rekey.mjs +112 -0
- package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
- package/scripts/canon-extract.mjs +578 -0
- package/scripts/canon.mjs +599 -0
- package/scripts/canvas-screen.mjs +333 -0
- package/scripts/check-local.mjs +136 -0
- package/scripts/clawd-art.mjs +75 -0
- package/scripts/clawd-canvas.mjs +741 -0
- package/scripts/cli.mjs +100 -0
- package/scripts/clone.mjs +422 -0
- package/scripts/close.mjs +30 -0
- package/scripts/connect.mjs +103 -0
- package/scripts/deploy.mjs +157 -0
- package/scripts/dev.mjs +54 -0
- package/scripts/doc-lint.mjs +143 -0
- package/scripts/export.mjs +300 -0
- package/scripts/fork.mjs +64 -0
- package/scripts/freeze.mjs +74 -0
- package/scripts/frontdoor-parity.mjs +180 -0
- package/scripts/hook.mjs +67 -0
- package/scripts/identity-rekey.mjs +112 -0
- package/scripts/init.mjs +122 -0
- package/scripts/land.mjs +40 -0
- package/scripts/lib/adapters.mjs +156 -0
- package/scripts/lib/board-snapshot.mjs +511 -0
- package/scripts/lib/draft.mjs +385 -0
- package/scripts/lib/graduate.mjs +276 -0
- package/scripts/lib/instance.mjs +74 -0
- package/scripts/lib/marks.mjs +107 -0
- package/scripts/lib/materialize.mjs +138 -0
- package/scripts/lib/merge3.mjs +124 -0
- package/scripts/lib/offline-posture.mjs +120 -0
- package/scripts/lib/offline-respawn.mjs +18 -0
- package/scripts/lib/offline-wrangler.mjs +78 -0
- package/scripts/lib/publish-compose.mjs +12 -0
- package/scripts/lib/publish-conflict.mjs +78 -0
- package/scripts/lib/publish-evidence.mjs +144 -0
- package/scripts/lib/refine-compare.mjs +140 -0
- package/scripts/lib/refine-ledger.mjs +150 -0
- package/scripts/lib/refine-png.mjs +149 -0
- package/scripts/lib/seed-pack-build.mjs +141 -0
- package/scripts/lib/state-compare.mjs +68 -0
- package/scripts/lib/store.mjs +122 -0
- package/scripts/login.mjs +84 -0
- package/scripts/mark.mjs +112 -0
- package/scripts/migrate-board-keys.mjs +164 -0
- package/scripts/migrate.mjs +265 -0
- package/scripts/no-foreign-vocabulary.mjs +216 -0
- package/scripts/no-product-names.mjs +157 -0
- package/scripts/no-tenant-globals.mjs +1134 -0
- package/scripts/offline.mjs +247 -0
- package/scripts/og.mjs +170 -0
- package/scripts/one-tenant-resolver.mjs +146 -0
- package/scripts/open.mjs +63 -0
- package/scripts/publish.mjs +1041 -0
- package/scripts/read.mjs +31 -0
- package/scripts/refine.mjs +455 -0
- package/scripts/release-drift.mjs +138 -0
- package/scripts/restore.mjs +322 -0
- package/scripts/review.mjs +125 -0
- package/scripts/save.mjs +21 -0
- package/scripts/shell-lint.mjs +189 -0
- package/scripts/ship.mjs +460 -0
- package/scripts/shoot.mjs +264 -0
- package/scripts/smoke-invite.mjs +113 -0
- package/scripts/state-inventory.mjs +223 -0
- package/scripts/status.mjs +177 -0
- package/scripts/sync.mjs +27 -0
- package/scripts/tenant-do-rehearsal.mjs +1314 -0
- package/scripts/ui-copy-lint.mjs +202 -0
- package/scripts/watch.mjs +32 -0
- package/scripts/wrangler-preflight.mjs +246 -0
- package/seed/CANON.md +43 -0
- package/seed/README.md +119 -0
- package/seed/prototype-status.json +9 -0
- package/seed/registry.json +88 -0
- package/seed/skills/starter-ui/SKILL.md +52 -0
- package/seed/skills/starter-ui/skill.json +11 -0
- package/seed/skills/starter-ui/starter-tokens.css +73 -0
- package/seed/skills/starter-ui/starter-ui.css +318 -0
- package/seed/skills/starter-ui/starter-ui.js +50 -0
- package/seed/space.json +5 -0
- package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
- package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
- package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
- package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
- package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
- package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
- package/seed/threads.json +52 -0
- package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
- package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
- package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
- package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
- package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
- package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
- package/src/_worker.js +12668 -0
- package/src/board-key.mjs +57 -0
- package/src/board-room.mjs +881 -0
- package/src/bundle-keys.mjs +138 -0
- package/src/canon/collect-in-browser.js +215 -0
- package/src/canon/emit.mjs +232 -0
- package/src/canon/observe.mjs +260 -0
- package/src/canon/schema.mjs +451 -0
- package/src/canvas/DSEG-LICENSE.txt +95 -0
- package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
- package/src/canvas/canvas.css +885 -0
- package/src/canvas/canvas.js +5780 -0
- package/src/canvas/capture.js +485 -0
- package/src/chrome/appchrome.mjs +568 -0
- package/src/currency.mjs +203 -0
- package/src/delete-confirmation.mjs +254 -0
- package/src/drafts/drafts.js +326 -0
- package/src/entry.js +58 -0
- package/src/galleries.mjs +400 -0
- package/src/health-cron.mjs +173 -0
- package/src/identity.json +1 -0
- package/src/kv-codec.mjs +135 -0
- package/src/kv-identity.mjs +259 -0
- package/src/mail.mjs +599 -0
- package/src/provenance.mjs +74 -0
- package/src/publish-compose.mjs +363 -0
- package/src/publish-fork.mjs +221 -0
- package/src/publish-units.mjs +44 -0
- package/src/purge.mjs +81 -0
- package/src/review/COMMENTING-UX.md +306 -0
- package/src/review/cat.png +0 -0
- package/src/review/comment-cursor.svg +7 -0
- package/src/review/comments.js +1738 -0
- package/src/room-ticket.mjs +96 -0
- package/src/roster-chip.mjs +32 -0
- package/src/seed-pack.mjs +261 -0
- package/src/state-inventory.mjs +249 -0
- package/src/sw-logic.mjs +48 -0
- package/src/tenant-cache.mjs +141 -0
- package/src/tenant-context.mjs +386 -0
- package/src/tenant-do.js +2940 -0
- package/src/tenant-host.mjs +144 -0
- package/src/tenant-quotas.mjs +140 -0
- package/src/unit-core.mjs +103 -0
- package/src/unit-object.mjs +338 -0
- package/templates/README.md +67 -0
- package/templates/shell/deploy.yml +145 -0
- package/templates/shell/engine-bump.yml +142 -0
- package/templates/shell/health.yml +233 -0
- package/templates/shell/kv-backup.yml +201 -0
- package/templates/shell/roster-update.yml +110 -0
- package/templates/shell/space-preflight.yml +35 -0
- package/templates/shell/space-rebake.yml +79 -0
- package/templates/shell/store-backup.yml +152 -0
- package/templates/shell/wrangler.example.toml +266 -0
- package/templates/space/publish.yml +91 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// Composed publish — the pure half of protocol 5.
|
|
2
|
+
//
|
|
3
|
+
// A publish used to ship one machine's tree as the WHOLE space, then spend
|
|
4
|
+
// hundreds of lines reconciling the damage that implied for every unit the
|
|
5
|
+
// publisher never touched (adopt-into-tree, peels, forks, mechanical commits).
|
|
6
|
+
// Protocol 5 inverts it: the LIVE manifest is the base, and this module decides,
|
|
7
|
+
// per authored unit, whether the publisher's build may replace what is live.
|
|
8
|
+
// The rule is git's own: a unit ships when it is a FAST-FORWARD of live —
|
|
9
|
+
// live's recorded source commit is in the publisher's history — or when there
|
|
10
|
+
// is local evidence of an edit. Everything else keeps live's bytes, verbatim.
|
|
11
|
+
// The working tree is never touched; a conflict forks in the MANIFEST only.
|
|
12
|
+
//
|
|
13
|
+
// Per-unit decision (unit in both, built bytes differ):
|
|
14
|
+
// 1. live IS my last publish (caller proves via cache+version) → whole tree safe,
|
|
15
|
+
// this module is not called at all.
|
|
16
|
+
// 2. live's unit is the SEED (`isSeedSource`, F-seed-yields-to-real-publish): the
|
|
17
|
+
// platform wrote it and it is nobody's work, so it reverts nobody → ship mine,
|
|
18
|
+
// outright — no evidence asked for. Unless only the decoration differs (same
|
|
19
|
+
// source hash, or tolerant-equal): then live's bytes AND its seed marker stay,
|
|
20
|
+
// because a page nobody edited must not become "theirs" on the first publish.
|
|
21
|
+
// 3. live's unit source: clean commit in my history → ship mine (fast-forward).
|
|
22
|
+
// 4. else: no local evidence I edited it → keep live's.
|
|
23
|
+
// evidence, but tolerant-equal → keep live's (chrome-only churn).
|
|
24
|
+
// evidence and really different → CONTESTED: theirs keeps the URL,
|
|
25
|
+
// mine composes at <unit>-conflict-<who>/ + a synthesized CONFLICT.md.
|
|
26
|
+
// Only in mine → new unit, ships. Only in live → stays live: implicit unpublish
|
|
27
|
+
// is impossible by construction. Removal needs an evidenced deletion AND
|
|
28
|
+
// --allow-unpublish: git-evidenced (the caller passes those units in
|
|
29
|
+
// `evidence.deletedUnits`), or a SEED unit the tree lacks — the platform's page
|
|
30
|
+
// is not a deletion anyone has to prove, but it is still an unpublish, so without
|
|
31
|
+
// the flag it stays live and is NAMED (removalBlocked) rather than kept in silence.
|
|
32
|
+
//
|
|
33
|
+
// ⚠️ THE SEED RULE LIVES HERE AND NOWHERE ELSE. The evidence is what each caller can
|
|
34
|
+
// prove — git for the CLI, the base manifest for the store — and the store runs this
|
|
35
|
+
// same composer to resolve a repo-less publisher's stale base (C-fork-on-conflict). A
|
|
36
|
+
// rule written into one caller's evidence would leave the other deciding by byte
|
|
37
|
+
// identity, which agrees only until a re-seed changes bytes under a seed marker.
|
|
38
|
+
//
|
|
39
|
+
// Hard rule, lint-grade: a tree folder named `*-conflict-*` NEVER ships
|
|
40
|
+
// implicitly (filterLitter below). Composed fork prefixes are added after the
|
|
41
|
+
// filter, so real conflicts still surface — but stale fork litter in a working
|
|
42
|
+
// tree cannot re-enter the live site, ever.
|
|
43
|
+
//
|
|
44
|
+
// Everything impure (git, the store, the tree) is the caller's problem: this
|
|
45
|
+
// module sees two manifests plus predicates and answers with a manifest.
|
|
46
|
+
|
|
47
|
+
// ⚠️ NO NODE IMPORTS. This module is the publish stack's BRAIN and the worker runs the same
|
|
48
|
+
// one — `C-fork-on-conflict` resolves a stale base inside the commit handler, and a second
|
|
49
|
+
// implementation of "who keeps the URL" would disagree with the client on exactly the
|
|
50
|
+
// publishes a conflict is about. So hashing arrives as a parameter and bytes are Uint8Array:
|
|
51
|
+
// `node:crypto` and `Buffer` exist in the CLI and in neither workerd nor a Durable Object.
|
|
52
|
+
import { authoredUnits, unitOfPath as unitOf, unitPaths } from "./publish-units.mjs";
|
|
53
|
+
import { isSeedSource } from "./provenance.mjs";
|
|
54
|
+
export { authoredUnits, unitPaths };
|
|
55
|
+
|
|
56
|
+
const dec = (s) => { try { return decodeURIComponent(String(s)); } catch (e) { return String(s); } };
|
|
57
|
+
const norm = (p) => String(p == null ? "" : p).replace(/\/?$/, "/");
|
|
58
|
+
/** The default hasher, for the CLI. A worker passes its own — see `sha256` in the options. */
|
|
59
|
+
async function nodeSha256(bytes) {
|
|
60
|
+
const { createHash } = await import("node:crypto");
|
|
61
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const LITTER_RE = /-conflict-[a-z0-9][a-z0-9-]*\/$/;
|
|
65
|
+
|
|
66
|
+
const sameUnitBytes = (a, b, unit) => {
|
|
67
|
+
const pa = unitPaths(a, unit), pb = unitPaths(b, unit);
|
|
68
|
+
if (pa.length !== pb.length) return false;
|
|
69
|
+
const bh = new Map(pb.map((p) => [p, (b.files[p] || {}).h]));
|
|
70
|
+
return pa.every((p) => bh.get(p) === (a.files[p] || {}).h);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Same SOURCE, whatever the served bytes: `sh` is the hash build.js records of a page's
|
|
74
|
+
// bytes BEFORE it decorates them (og meta, the linked stamp — everything an engine change
|
|
75
|
+
// rewrites). It is the field the commit handler already judges per-file provenance on,
|
|
76
|
+
// so "did a person change this" means the same thing at both ends. A file without one
|
|
77
|
+
// (verbatim copies carry none) compares its served bytes.
|
|
78
|
+
const sameFileSource = (a, b) => (a && b && a.sh && b.sh) ? a.sh === b.sh : !!a && !!b && a.h === b.h;
|
|
79
|
+
const sameUnitSource = (a, b, unit) => {
|
|
80
|
+
const pa = unitPaths(a, unit), pb = unitPaths(b, unit);
|
|
81
|
+
if (pa.length !== pb.length) return false;
|
|
82
|
+
const bf = new Map(pb.map((p) => [p, b.files[p]]));
|
|
83
|
+
return pa.every((p) => bf.has(p) && sameFileSource(a.files[p], bf.get(p)));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Has this live `-conflict-` fork's content reached its origin URL in `next`? True when
|
|
87
|
+
// the origin unit is in `next` and every file of the fork (CONFLICT.md aside) has the same
|
|
88
|
+
// source — `sh` when both carry it, served bytes otherwise — at the origin. This is the one
|
|
89
|
+
// verifiable reason a public page may vanish unasked, so the composer, the CLI's pre-check
|
|
90
|
+
// and the store's unpublish guard all ask this same function.
|
|
91
|
+
export function forkLanded(live, next, fork) {
|
|
92
|
+
const m = dec(fork).match(/^(.*)-conflict-([a-z0-9][a-z0-9-]*)\/$/);
|
|
93
|
+
if (!m) return false;
|
|
94
|
+
const origin = norm(m[1] + "/");
|
|
95
|
+
if (!(((next || {}).routing || {}).publicPrefixes || []).map(norm).includes(origin)) return false;
|
|
96
|
+
const strip = (u, p) => dec(p).slice(dec(u).length);
|
|
97
|
+
const fPaths = unitPaths(live, fork).filter((p) => !/\/CONFLICT\.md$/.test(dec(p)));
|
|
98
|
+
const of = new Map(unitPaths(next, origin).map((p) => [strip(origin, p), next.files[p]]));
|
|
99
|
+
return fPaths.length > 0 && fPaths.length === of.size
|
|
100
|
+
&& fPaths.every((p) => sameFileSource(live.files[p], of.get(strip(fork, p))));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Drop tree-derived `*-conflict-*` units from a built manifest, in place.
|
|
104
|
+
// Returns the unit prefixes it removed (for one summary log line).
|
|
105
|
+
export function filterLitter(manifest) {
|
|
106
|
+
const routing = (manifest || {}).routing || {};
|
|
107
|
+
const litter = (routing.publicPrefixes || []).map(norm).filter((u) => LITTER_RE.test(dec(u)));
|
|
108
|
+
if (!litter.length) return [];
|
|
109
|
+
const under = (p) => litter.some((u) => dec(p).startsWith(dec(u)));
|
|
110
|
+
routing.publicPrefixes = (routing.publicPrefixes || []).filter((u) => !LITTER_RE.test(dec(norm(u))));
|
|
111
|
+
for (const p of Object.keys(manifest.files || {})) if (under(p)) delete manifest.files[p];
|
|
112
|
+
for (const k of Object.keys(routing.versionMap || {})) if (under(k) || LITTER_RE.test(dec(norm(k)))) delete routing.versionMap[k];
|
|
113
|
+
if (routing.unitSources) for (const k of Object.keys(routing.unitSources)) if (LITTER_RE.test(dec(norm(k)))) delete routing.unitSources[k];
|
|
114
|
+
return litter;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const conflictNote = (unit, theirName) =>
|
|
118
|
+
`# Live edit conflict\n\n` +
|
|
119
|
+
`You and **${theirName}** changed \`${dec(unit)}\` at the same time. Their version was\n` +
|
|
120
|
+
`live from work your git history has never seen, so \`augur publish\` kept **theirs**\n` +
|
|
121
|
+
`at the original URL — any shared link still resolves — and published **yours** here.\n\n` +
|
|
122
|
+
`Your working tree was NOT touched: your copy still lives at its real folder.\n` +
|
|
123
|
+
`Compare the two pages, fold in whatever should survive, then ship — the next\n` +
|
|
124
|
+
`publish that fast-forwards live retires this URL. Nothing has been lost.\n`;
|
|
125
|
+
|
|
126
|
+
// Compose the manifest a publish will commit: live as the base, the publisher's
|
|
127
|
+
// build overlaid where it is allowed to land.
|
|
128
|
+
//
|
|
129
|
+
// mine, live — built manifest / live manifest (live non-empty; caller handles
|
|
130
|
+
// bootstrap and the live-is-my-last-publish case without us)
|
|
131
|
+
// who — fork suffix (git author identity)
|
|
132
|
+
// evidence — { editedUnits:Set, dirtyUnits:Set, deletedUnits:Set, editedPaths:Set }
|
|
133
|
+
// (git-derived by the caller; editedPaths covers shared skill files)
|
|
134
|
+
// ffUnits — Set of units where live's source is a clean commit in my history
|
|
135
|
+
// allowUnpublish — evidenced deletions actually drop (else they are kept + noted)
|
|
136
|
+
// tolerantEqual — async (unit) => bool: my built bytes vs live's, volatile head ignored
|
|
137
|
+
export async function composePublish({
|
|
138
|
+
mine, live, who = "someone", evidence, ffUnits,
|
|
139
|
+
allowUnpublish = false, tolerantEqual = async () => false,
|
|
140
|
+
// Injected so this module needs no runtime-specific import. The CLI gets node's; the
|
|
141
|
+
// worker passes one built on crypto.subtle, which is the only one it has.
|
|
142
|
+
sha256 = nodeSha256,
|
|
143
|
+
}) {
|
|
144
|
+
const ev = {
|
|
145
|
+
editedUnits: evidence?.editedUnits || new Set(),
|
|
146
|
+
dirtyUnits: evidence?.dirtyUnits || new Set(),
|
|
147
|
+
deletedUnits: evidence?.deletedUnits || new Set(),
|
|
148
|
+
editedPaths: evidence?.editedPaths || new Set(),
|
|
149
|
+
};
|
|
150
|
+
const ff = ffUnits || new Set();
|
|
151
|
+
const mineUnits = authoredUnits(mine);
|
|
152
|
+
const liveUnits = authoredUnits(live);
|
|
153
|
+
const allUnits = new Set([...mineUnits, ...liveUnits]);
|
|
154
|
+
|
|
155
|
+
const skillPrefixes = new Set();
|
|
156
|
+
for (const m of [mine, live]) {
|
|
157
|
+
for (const p of ((m || {}).routing || {}).publicSkillPrefixes || []) skillPrefixes.add(norm(p));
|
|
158
|
+
}
|
|
159
|
+
const underSkill = (p) => { const d = dec(p); return [...skillPrefixes].some((s) => d.startsWith(dec(s))); };
|
|
160
|
+
|
|
161
|
+
const mineFiles = (mine || {}).files || {};
|
|
162
|
+
const liveFiles = (live || {}).files || {};
|
|
163
|
+
const mineRouting = (mine || {}).routing || {};
|
|
164
|
+
const liveRouting = (live || {}).routing || {};
|
|
165
|
+
|
|
166
|
+
// Is live's copy of this unit the platform's seed? Asked through `isSeedSource()`, the
|
|
167
|
+
// one predicate, of the per-unit marker — falling back to the manifest's own source for
|
|
168
|
+
// a manifest that predates `unitSources`. After a real publish the manifest's source is
|
|
169
|
+
// a person's while the units nobody touched still carry the sentinel, so it is per unit.
|
|
170
|
+
const liveSeedUnit = (u) => isSeedSource((liveRouting.unitSources || {})[u] || (live || {}).source);
|
|
171
|
+
// Shared skill files carry no per-file marker; they are the seed's only while the whole
|
|
172
|
+
// live manifest still is (i.e. before the first real publish).
|
|
173
|
+
const liveSeedAll = isSeedSource((live || {}).source);
|
|
174
|
+
|
|
175
|
+
const out = {
|
|
176
|
+
...mine,
|
|
177
|
+
files: {},
|
|
178
|
+
routing: { ...mineRouting, publicPrefixes: [], versionMap: { ...(mineRouting.versionMap || {}) }, unitSources: {} },
|
|
179
|
+
};
|
|
180
|
+
const readMap = {}; // composed path → path whose bytes exist in dist (fork re-keys)
|
|
181
|
+
const extraBlobs = {}; // hash → Uint8Array (synthesized CONFLICT.md)
|
|
182
|
+
// `seeded`: seed units this publish replaced. `seedKept`: seed units this tree carries
|
|
183
|
+
// unchanged — byte-identical, or with only their decoration changed — where live's bytes
|
|
184
|
+
// AND ITS SEED MARKER stay; NOT reported as kept (nothing was held back), but the caller's
|
|
185
|
+
// cache must know live is not exactly this tree, so the next publish composes again rather
|
|
186
|
+
// than fast-pathing the tree over it. The marker is the reason the byte-identical case is
|
|
187
|
+
// in here too: the built manifest stamps every unit as the publisher's, so a fast path
|
|
188
|
+
// that ships it whole would take the seed marker off five untouched pages while shipping
|
|
189
|
+
// one edit — measured, by the clone round trip, on the second publish from a clone.
|
|
190
|
+
const summary = { shipped: [], kept: [], forked: [], removed: [], removalBlocked: [], newUnits: [], keptDiffer: [], seeded: [], seedKept: [], healed: [], retired: [] };
|
|
191
|
+
|
|
192
|
+
const takeMine = (u) => {
|
|
193
|
+
for (const p of unitPaths(mine, u)) out.files[p] = mineFiles[p];
|
|
194
|
+
out.routing.publicPrefixes.push(u);
|
|
195
|
+
out.routing.unitSources[u] = {
|
|
196
|
+
sha: (mine.source || {}).sha || null,
|
|
197
|
+
dirty: ev.dirtyUnits.has(u),
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
const takeLive = (u) => {
|
|
201
|
+
for (const p of unitPaths(live, u)) out.files[p] = liveFiles[p];
|
|
202
|
+
out.routing.publicPrefixes.push(u);
|
|
203
|
+
const vm = (liveRouting.versionMap || {})[u];
|
|
204
|
+
if (vm) out.routing.versionMap[u] = vm;
|
|
205
|
+
const src = (liveRouting.unitSources || {})[u];
|
|
206
|
+
out.routing.unitSources[u] = src || {
|
|
207
|
+
sha: (live.source || {}).sha || null, dirty: !!(live.source || {}).dirty,
|
|
208
|
+
};
|
|
209
|
+
// My build may have generated different bytes for this unit; make sure none
|
|
210
|
+
// of mine leak in (paths only mine has under a kept unit stay out).
|
|
211
|
+
for (const p of unitPaths(mine, u)) if (!(p in out.files)) { /* dropped */ }
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Identical bytes from a clean tree are PROOF that the unit is this commit. When live's
|
|
215
|
+
// provenance for it is dirty or unknown, say so. This is what stops one dirty publish
|
|
216
|
+
// from poisoning every untouched unit for everybody: a legacy manifest (space-level
|
|
217
|
+
// `{sha, dirty:true}`, no `unitSources`) once got synthesized onto 158 units nobody had
|
|
218
|
+
// touched, and every clean edit after that forked as "contested" — three times for one
|
|
219
|
+
// person — because a dirty base can never fast-forward. A clean provenance is kept even
|
|
220
|
+
// when mine is newer: the older commit is the one more people can prove.
|
|
221
|
+
const healable = (u) => {
|
|
222
|
+
if (!(mine.source || {}).sha || ev.dirtyUnits.has(u)) return false;
|
|
223
|
+
const src = out.routing.unitSources[u];
|
|
224
|
+
return !(src && src.sha && !src.dirty);
|
|
225
|
+
};
|
|
226
|
+
const heal = (u) => {
|
|
227
|
+
if (!healable(u)) return;
|
|
228
|
+
out.routing.unitSources[u] = { sha: (mine.source || {}).sha, dirty: false };
|
|
229
|
+
summary.healed.push(u);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// My own earlier fork of the same unit is mine to replace: a person who forks twice
|
|
233
|
+
// gets ONE `-conflict-<who>` copy with their newest bytes, not -2, -3, -4. The anonymous
|
|
234
|
+
// fallback name is nobody's in particular, so it still numbers.
|
|
235
|
+
const forkName = (u) => {
|
|
236
|
+
const base = norm(dec(u).replace(/\/$/, "") + `-conflict-${who}`);
|
|
237
|
+
if (who !== "someone" && liveUnits.has(base) && !mineUnits.has(base)) return base;
|
|
238
|
+
let fork = base;
|
|
239
|
+
let n = 2;
|
|
240
|
+
while (liveUnits.has(fork) || mineUnits.has(fork)) fork = norm(dec(u).replace(/\/$/, "") + `-conflict-${who}-${n++}`);
|
|
241
|
+
return fork;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
for (const u of [...allUnits].sort()) {
|
|
245
|
+
const inMine = mineUnits.has(u), inLive = liveUnits.has(u);
|
|
246
|
+
if (inMine && !inLive) {
|
|
247
|
+
takeMine(u);
|
|
248
|
+
summary.newUnits.push(u);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (inLive && !inMine) {
|
|
252
|
+
// A seed unit the tree lacks needs no proof of deletion — but it is still an
|
|
253
|
+
// unpublish, so the flag still gates it and without the flag it is named.
|
|
254
|
+
if (ev.deletedUnits.has(u) || liveSeedUnit(u)) {
|
|
255
|
+
if (allowUnpublish) { summary.removed.push(u); continue; }
|
|
256
|
+
summary.removalBlocked.push(u);
|
|
257
|
+
} else {
|
|
258
|
+
// Reported as kept: live is NOT exactly this tree, so the caller's cache must not
|
|
259
|
+
// let the next publish fast-path the whole tree over it (which would drop this unit).
|
|
260
|
+
summary.kept.push(u);
|
|
261
|
+
}
|
|
262
|
+
takeLive(u);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
// In both.
|
|
266
|
+
if (sameUnitBytes(mine, live, u)) {
|
|
267
|
+
takeLive(u);
|
|
268
|
+
if (liveSeedUnit(u)) summary.seedKept.push(u); // the marker is live's, and the cache must know
|
|
269
|
+
else heal(u);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (liveSeedUnit(u)) {
|
|
273
|
+
// The platform's page yields to anybody's — unless nobody actually changed it.
|
|
274
|
+
if (sameUnitSource(mine, live, u) || await tolerantEqual(u)) { takeLive(u); summary.seedKept.push(u); continue; }
|
|
275
|
+
takeMine(u);
|
|
276
|
+
summary.seeded.push(u);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (ff.has(u)) { takeMine(u); summary.shipped.push(u); continue; }
|
|
280
|
+
if (!ev.editedUnits.has(u)) {
|
|
281
|
+
takeLive(u); summary.kept.push(u);
|
|
282
|
+
// Same source under a different engine's decoration is the same proof as identical bytes.
|
|
283
|
+
if (healable(u) && (sameUnitSource(mine, live, u) || await tolerantEqual(u))) heal(u);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (await tolerantEqual(u)) { takeLive(u); summary.kept.push(u); heal(u); continue; }
|
|
287
|
+
// Contested: theirs keeps the URL, mine composes at a fork prefix.
|
|
288
|
+
takeLive(u);
|
|
289
|
+
summary.keptDiffer.push(u);
|
|
290
|
+
const fork = forkName(u);
|
|
291
|
+
const theirName = (live && live.publishedBy) || ((live || {}).source || {}).actor || "a collaborator";
|
|
292
|
+
if (liveUnits.has(fork)) {
|
|
293
|
+
// Replacing my own earlier fork: its files were taken as a live-only unit above.
|
|
294
|
+
for (const p of unitPaths(live, fork)) delete out.files[p];
|
|
295
|
+
out.routing.publicPrefixes = out.routing.publicPrefixes.filter((x) => x !== fork);
|
|
296
|
+
delete out.routing.versionMap[fork];
|
|
297
|
+
}
|
|
298
|
+
for (const p of unitPaths(mine, u)) {
|
|
299
|
+
const fp = fork + dec(p).slice(dec(u).length);
|
|
300
|
+
out.files[fp] = mineFiles[p];
|
|
301
|
+
readMap[fp] = p;
|
|
302
|
+
}
|
|
303
|
+
const note = new TextEncoder().encode(conflictNote(u, theirName));
|
|
304
|
+
const noteHash = await sha256(note);
|
|
305
|
+
out.files[fork + "CONFLICT.md"] = { h: noteHash, ct: "text/markdown; charset=utf-8", s: note.length };
|
|
306
|
+
extraBlobs[noteHash] = note;
|
|
307
|
+
out.routing.publicPrefixes.push(fork);
|
|
308
|
+
const vm = (mineRouting.versionMap || {})[u];
|
|
309
|
+
if (vm) out.routing.versionMap[fork] = vm;
|
|
310
|
+
out.routing.unitSources[fork] = { sha: (mine.source || {}).sha || null, dirty: ev.dirtyUnits.has(u) };
|
|
311
|
+
summary.forked.push({ unit: u, fork, theirs: theirName });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Shared skill files: per file — mine ships only with evidence; live's are never
|
|
315
|
+
// implicitly dropped; a file only I have is new and ships.
|
|
316
|
+
for (const p of new Set([...Object.keys(mineFiles), ...Object.keys(liveFiles)])) {
|
|
317
|
+
if (!underSkill(p)) continue;
|
|
318
|
+
const a = mineFiles[p], b = liveFiles[p];
|
|
319
|
+
if (a && !b) { out.files[p] = a; continue; }
|
|
320
|
+
if (!a && b) { out.files[p] = b; continue; }
|
|
321
|
+
if (a.h === b.h) { out.files[p] = a; continue; }
|
|
322
|
+
if (ev.editedPaths.has(p)) { out.files[p] = a; }
|
|
323
|
+
else if (liveSeedAll) { out.files[p] = sameFileSource(a, b) ? b : a; }
|
|
324
|
+
else { out.files[p] = b; summary.kept.push(p); }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Everything else is generated output (galleries, indexes, landing, tokens):
|
|
328
|
+
// mine wins wholesale — any publish regenerates all of it.
|
|
329
|
+
for (const [p, f] of Object.entries(mineFiles)) {
|
|
330
|
+
if (p in out.files) continue;
|
|
331
|
+
if (unitOf(p, allUnits) || underSkill(p)) continue;
|
|
332
|
+
out.files[p] = f;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// A fork retires the moment it is redundant: its bytes are at the origin URL now
|
|
336
|
+
// (whoever shipped them), or its author just shipped the origin (their fold-in
|
|
337
|
+
// superseded it). CONFLICT.md promised exactly this. Nothing else ever removes one:
|
|
338
|
+
// a stranger's fork of a unit nobody shipped stays, like any live-only unit.
|
|
339
|
+
const shippedNow = new Set([...summary.shipped, ...summary.newUnits, ...summary.seeded]);
|
|
340
|
+
const forkedNow = new Set(summary.forked.map((f) => f.fork));
|
|
341
|
+
for (const f of [...out.routing.publicPrefixes]) {
|
|
342
|
+
const m = dec(f).match(/^(.*)-conflict-([a-z0-9][a-z0-9-]*)\/$/);
|
|
343
|
+
if (!m || forkedNow.has(f) || !liveUnits.has(f)) continue;
|
|
344
|
+
const origin = norm(m[1] + "/");
|
|
345
|
+
if (!out.routing.publicPrefixes.includes(origin)) continue;
|
|
346
|
+
const mineFork = who !== "someone" && (m[2] === who || m[2].startsWith(who + "-"));
|
|
347
|
+
// The author's own fork also retires when they ship the origin with FURTHER changes —
|
|
348
|
+
// the store cannot verify that one, so it needs --allow-unpublish; the CLI says so.
|
|
349
|
+
if (!forkLanded(live, out, f) && !(mineFork && shippedNow.has(origin))) continue;
|
|
350
|
+
for (const p of unitPaths(live, f)) delete out.files[p];
|
|
351
|
+
out.routing.publicPrefixes = out.routing.publicPrefixes.filter((x) => x !== f);
|
|
352
|
+
delete out.routing.versionMap[f];
|
|
353
|
+
delete out.routing.unitSources[f];
|
|
354
|
+
summary.retired.push(f);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Carry unitSources for kept units even when live predates the field entirely
|
|
358
|
+
// (out.routing.unitSources was filled per unit above; nothing else to do), and
|
|
359
|
+
// keep prefix order stable for humans reading the manifest.
|
|
360
|
+
out.routing.publicPrefixes.sort();
|
|
361
|
+
|
|
362
|
+
return { manifest: out, readMap, extraBlobs, summary };
|
|
363
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Fork as a VERB — copying an artifact by aliasing its blobs, never by moving bytes.
|
|
2
|
+
//
|
|
3
|
+
// `F-fork-verb`. Forking already existed here twice, and neither one was a verb. It existed
|
|
4
|
+
// as conflict RESOLUTION (`C-fork-on-conflict`: two people edit the same unit, theirs keeps
|
|
5
|
+
// the URL, mine is re-keyed under a fork prefix), and it existed as "an agent copies a
|
|
6
|
+
// folder and publishes it" — which needs a repo, a checkout and an upload of every byte for
|
|
7
|
+
// content the store is already holding. Divergence-then-convergence as the collaboration
|
|
8
|
+
// model needs the deliberate one: take this artifact, give me my own copy of it, remember
|
|
9
|
+
// where it came from.
|
|
10
|
+
//
|
|
11
|
+
// ⚠️ IT IS THE SAME TRICK THE CONFLICT FORK USES, AND ON PURPOSE. A manifest maps a URL to a
|
|
12
|
+
// CONTENT HASH; the bytes live once in a content-addressed pool. So a copy is new manifest
|
|
13
|
+
// KEYS pointing at hashes the store already has — a hundred-file prototype forks without
|
|
14
|
+
// uploading, reading or hashing a single blob, and `bytesReferenced` does not move, because
|
|
15
|
+
// the fork references exactly the same blobs the source does. Inventing a second mechanism
|
|
16
|
+
// (re-upload, server-side blob copy) would cost bytes to produce a byte-identical result and
|
|
17
|
+
// would give the two forks two different definitions of what a copy is.
|
|
18
|
+
//
|
|
19
|
+
// ⚠️ THE COPY ESCAPES THE ORIGINAL'S ACL, which is the rule `B-resource-authz-hook` states:
|
|
20
|
+
// stamp the FORKER as the new unit's owner rather than inheriting the source's, or "fork it
|
|
21
|
+
// to get my own editable copy" hands the fork the same restriction it was forked to escape.
|
|
22
|
+
// The owner is a `personId`, never an address — see below.
|
|
23
|
+
//
|
|
24
|
+
// ⚠️ LINEAGE IS ADDITIVE AND OPTIONAL, so there is no flag day. `routing.forkedFrom` and
|
|
25
|
+
// `routing.unitOwners` are unit-keyed maps beside `unitSources`, which is already exactly
|
|
26
|
+
// this shape. A manifest that carries neither means today what it meant before they existed
|
|
27
|
+
// — `test/manifest-lineage-compat.test.mjs` renders a pre-lineage manifest against a
|
|
28
|
+
// baseline generated by the code that predates the fields, rather than asserting it.
|
|
29
|
+
//
|
|
30
|
+
// ⚠️ PEOPLE ARE `personId`, NEVER AN ADDRESS. Same deviation the per-file provenance stamp
|
|
31
|
+
// records at the commit handler, for the same reason: a manifest is read by more things than
|
|
32
|
+
// a comment thread is, and the store's manifests are readable by any holder of a publish
|
|
33
|
+
// token. The ONE field that carries an address is `publishedBy`, which predates all of this
|
|
34
|
+
// and has its own erasure path (`redactProvenance`); nothing this module writes adds a
|
|
35
|
+
// second one.
|
|
36
|
+
//
|
|
37
|
+
// ⚠️ NO NODE IMPORTS, and no hashing. The worker runs this module directly, so it may not
|
|
38
|
+
// reach for `node:crypto` — and it never needs to, because a fork computes no new bytes.
|
|
39
|
+
|
|
40
|
+
import { authoredUnits, unitPaths } from "./publish-units.mjs";
|
|
41
|
+
import { LITTER_RE } from "./publish-compose.mjs";
|
|
42
|
+
|
|
43
|
+
const dec = (s) => { try { return decodeURIComponent(String(s)); } catch (e) { return String(s); } };
|
|
44
|
+
const norm = (p) => String(p == null ? "" : p).replace(/\/?$/, "/");
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A fork TARGET is a plain path, and that is a deliberate narrowing.
|
|
48
|
+
*
|
|
49
|
+
* Manifest file keys are the literal decoded paths the build wrote; routing prefixes are
|
|
50
|
+
* percent-encoded. Re-keying a hundred files across that seam is where an encoding bug would
|
|
51
|
+
* live, so a new verb simply declines the ambiguity: a fork target is ASCII path segments,
|
|
52
|
+
* and anything needing an escape is refused with a reason rather than guessed at. The SOURCE
|
|
53
|
+
* is under no such rule — it is matched against the manifest's own spelling of its units, so
|
|
54
|
+
* an already-published unit with an escaped name can still be forked.
|
|
55
|
+
*/
|
|
56
|
+
const PLAIN_PATH = /^\/(?:[A-Za-z0-9._~-]+\/)+$/;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Compose the manifest a fork commits: live, plus one unit aliased at a new path.
|
|
60
|
+
*
|
|
61
|
+
* live — the live manifest (the fork is always OF what is live; forking a historical
|
|
62
|
+
* version would publish bytes no URL currently serves)
|
|
63
|
+
* from — the source unit, any spelling of a `routing.publicPrefixes` entry
|
|
64
|
+
* to — the new path
|
|
65
|
+
* by — the forker's `personId`, or null when the caller cannot name one
|
|
66
|
+
*
|
|
67
|
+
* Returns `{manifest, aliased, sourceVersion}` or `{error, …}`. It reads no bytes and
|
|
68
|
+
* computes no hashes: every entry it writes is a copy of an entry live already carries.
|
|
69
|
+
*/
|
|
70
|
+
export function composeFork({ live, from, to, by = null }) {
|
|
71
|
+
const m = live || {};
|
|
72
|
+
const files = m.files || {};
|
|
73
|
+
const routing = m.routing || {};
|
|
74
|
+
const src0 = norm(from), dst = norm(to);
|
|
75
|
+
const bad = (reason) => ({ error: "bad-fork-path", reason });
|
|
76
|
+
|
|
77
|
+
if (!src0.startsWith("/") || !dst.startsWith("/")) return bad("a fork path is absolute");
|
|
78
|
+
if (dec(src0) === "/" || dec(dst) === "/") return bad("the site root is not an artifact");
|
|
79
|
+
if (!PLAIN_PATH.test(dst)) return bad("a fork target is plain ASCII path segments");
|
|
80
|
+
if (LITTER_RE.test(dec(dst))) return bad("`-conflict-` names belong to conflict forks");
|
|
81
|
+
|
|
82
|
+
// A fork that nests inside its source (or contains it) puts one unit's files under
|
|
83
|
+
// another's prefix, and `unitOfPath` resolves longest-prefix-wins — so the two would
|
|
84
|
+
// disagree about which unit half the files belong to for the rest of the space's life.
|
|
85
|
+
const sd = dec(src0), dd = dec(dst);
|
|
86
|
+
if (sd === dd) return bad("a fork needs a path of its own");
|
|
87
|
+
if (dd.startsWith(sd) || sd.startsWith(dd)) return bad("a fork cannot nest inside its source, or contain it");
|
|
88
|
+
|
|
89
|
+
// Match the source against the manifest's OWN spelling of its units, so a caller may name
|
|
90
|
+
// it either encoded or decoded and still hit the same unit.
|
|
91
|
+
const units = authoredUnits(m);
|
|
92
|
+
const src = [...units].find((u) => dec(u) === sd);
|
|
93
|
+
if (!src) return { error: "unknown-unit", path: src0 };
|
|
94
|
+
const paths = unitPaths(m, src);
|
|
95
|
+
// A prefix with no files behind it serves nothing already — copying it would publish an
|
|
96
|
+
// empty URL and call it an artifact.
|
|
97
|
+
if (!paths.length) return { error: "unknown-unit", path: src0 };
|
|
98
|
+
|
|
99
|
+
if (units.has(dst) || [...units].some((u) => dec(u) === dd)) return { error: "fork-target-exists", path: dst };
|
|
100
|
+
for (const p of Object.keys(files)) {
|
|
101
|
+
if (dec(p).startsWith(dd)) return { error: "fork-target-exists", path: dst };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const out = {
|
|
105
|
+
...m,
|
|
106
|
+
files: { ...files },
|
|
107
|
+
routing: {
|
|
108
|
+
...routing,
|
|
109
|
+
publicPrefixes: [...(routing.publicPrefixes || [])],
|
|
110
|
+
versionMap: { ...(routing.versionMap || {}) },
|
|
111
|
+
unitSources: { ...(routing.unitSources || {}) },
|
|
112
|
+
forkedFrom: { ...(routing.forkedFrom || {}) },
|
|
113
|
+
unitOwners: { ...(routing.unitOwners || {}) },
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// The alias itself. The entry is copied WHOLE — hash, mime, size, and the per-file
|
|
118
|
+
// provenance stamp the source carries. The bytes did not change, so neither did who last
|
|
119
|
+
// changed them: restamping a hundred files with the forker is precisely the "a mass commit
|
|
120
|
+
// reset every card to edited-now" failure `C-manifest-provenance` exists to retire. WHO
|
|
121
|
+
// MADE THE COPY is a different fact, and it is `unitOwners` below.
|
|
122
|
+
const aliased = [];
|
|
123
|
+
for (const p of paths) {
|
|
124
|
+
const q = dst + dec(p).slice(sd.length);
|
|
125
|
+
out.files[q] = { ...files[p] };
|
|
126
|
+
aliased.push([q, p]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
out.routing.publicPrefixes = [...new Set([...out.routing.publicPrefixes, dst])].sort();
|
|
130
|
+
const vm = (routing.versionMap || {})[src];
|
|
131
|
+
if (vm) out.routing.versionMap[dst] = vm;
|
|
132
|
+
const us = (routing.unitSources || {})[src];
|
|
133
|
+
// The fork's bytes came from that commit, whatever the forker has (or has not) checked out.
|
|
134
|
+
if (us) out.routing.unitSources[dst] = { ...us };
|
|
135
|
+
else if (m.source) out.routing.unitSources[dst] = { sha: m.source.sha || null, dirty: !!m.source.dirty };
|
|
136
|
+
|
|
137
|
+
// The lineage record. The VERSION is live's, so "forked from /x/ at v41" names a manifest
|
|
138
|
+
// the store still holds — versions are never pruned, so the parent is always recoverable
|
|
139
|
+
// even after the source has moved on or been unpublished.
|
|
140
|
+
out.routing.forkedFrom[dst] = { path: src, version: Number(m.version) || 0 };
|
|
141
|
+
if (by) out.routing.unitOwners[dst] = by;
|
|
142
|
+
|
|
143
|
+
return { manifest: out, aliased, from: src, to: dst, sourceVersion: Number(m.version) || 0 };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Which unit-keyed lineage entries survive into the next manifest.
|
|
148
|
+
*
|
|
149
|
+
* ⚠️ THIS IS WHY A FORK DOES NOT EVAPORATE ON THE NEXT ORDINARY PUBLISH. A publisher's
|
|
150
|
+
* manifest is built from their own tree and knows nothing about a fork somebody else made,
|
|
151
|
+
* so a commit that took `routing` verbatim would drop the lineage and the owner while
|
|
152
|
+
* keeping the files — the fork would still serve, and would have quietly become an
|
|
153
|
+
* anonymous folder nobody owns.
|
|
154
|
+
*
|
|
155
|
+
* ⚠️ AND IT IS WHY OWNERSHIP IS NOT A FIELD YOU CAN TYPE. `B-resource-owner-schema-basis`'s
|
|
156
|
+
* first rule is that an owner is stamped from the session and never read from the body; the
|
|
157
|
+
* same has to hold here, or an ACL becomes a thing a publish can assert about itself. So
|
|
158
|
+
* whatever the incoming manifest says is DISCARDED, and the answer is derived from what the
|
|
159
|
+
* server already recorded, for the units that are still there.
|
|
160
|
+
*/
|
|
161
|
+
/**
|
|
162
|
+
* The lineage a manifest may assert about ITSELF, when there is nothing to shadow.
|
|
163
|
+
*
|
|
164
|
+
* ⚠️ THIS EXISTS FOR ONE CALLER: A RESTORE INTO AN EMPTY STORE. `augur restore` (and the
|
|
165
|
+
* migration built on it) replays an exported manifest as an ordinary publish, so a
|
|
166
|
+
* workspace that is moved would otherwise arrive with its forks still serving and their
|
|
167
|
+
* parentage and owners gone — silently, since nothing renders them yet.
|
|
168
|
+
*
|
|
169
|
+
* ⚠️ AND IT IS TRUST-ON-FIRST-PUBLISH, the pattern this handler already uses for
|
|
170
|
+
* `space.adminOnly` and `space.default`: a claim is honoured only when the store holds no
|
|
171
|
+
* prior value it could be overwriting. The moment there is one, `carriedLineage` decides and
|
|
172
|
+
* the body is ignored, so no publish can ever reassign an owner by asserting one.
|
|
173
|
+
*
|
|
174
|
+
* The shapes are checked rather than trusted: an owner must look like a `personId` and MAY
|
|
175
|
+
* NOT contain an address, so even the one trusted path cannot put one in a manifest, and a
|
|
176
|
+
* lineage row must name a real declared unit.
|
|
177
|
+
*/
|
|
178
|
+
const PERSON_ID = /^[0-9a-z]{1,32}$/;
|
|
179
|
+
|
|
180
|
+
export function assertedLineage(routing) {
|
|
181
|
+
const r = routing || {};
|
|
182
|
+
const declared = new Set();
|
|
183
|
+
for (const p of r.publicPrefixes || []) { declared.add(String(p)); declared.add(dec(p)); }
|
|
184
|
+
const out = {};
|
|
185
|
+
const from = r.forkedFrom;
|
|
186
|
+
if (from && typeof from === "object" && !Array.isArray(from)) {
|
|
187
|
+
const kept = {};
|
|
188
|
+
for (const [u, v] of Object.entries(from)) {
|
|
189
|
+
if (!declared.has(u) && !declared.has(dec(u))) continue;
|
|
190
|
+
if (!v || typeof v !== "object" || typeof v.path !== "string" || !Number.isInteger(v.version)) continue;
|
|
191
|
+
kept[u] = { path: v.path, version: v.version };
|
|
192
|
+
}
|
|
193
|
+
if (Object.keys(kept).length) out.forkedFrom = kept;
|
|
194
|
+
}
|
|
195
|
+
const owners = r.unitOwners;
|
|
196
|
+
if (owners && typeof owners === "object" && !Array.isArray(owners)) {
|
|
197
|
+
const kept = {};
|
|
198
|
+
for (const [u, v] of Object.entries(owners)) {
|
|
199
|
+
if (!declared.has(u) && !declared.has(dec(u))) continue;
|
|
200
|
+
if (typeof v !== "string" || !PERSON_ID.test(v)) continue;
|
|
201
|
+
kept[u] = v;
|
|
202
|
+
}
|
|
203
|
+
if (Object.keys(kept).length) out.unitOwners = kept;
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function carriedLineage(cur, nextRouting) {
|
|
209
|
+
const prev = (cur && cur.routing) || {};
|
|
210
|
+
const keep = new Set();
|
|
211
|
+
for (const p of (nextRouting && nextRouting.publicPrefixes) || []) { keep.add(String(p)); keep.add(dec(p)); }
|
|
212
|
+
const out = {};
|
|
213
|
+
for (const field of ["forkedFrom", "unitOwners"]) {
|
|
214
|
+
const from = prev[field];
|
|
215
|
+
if (!from || typeof from !== "object") continue;
|
|
216
|
+
const kept = {};
|
|
217
|
+
for (const [u, v] of Object.entries(from)) if (keep.has(u) || keep.has(dec(u))) kept[u] = v;
|
|
218
|
+
if (Object.keys(kept).length) out[field] = kept;
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The UNIT of publishing, as a pure module both the CLI and the WORKER can hold.
|
|
2
|
+
//
|
|
3
|
+
// A unit is a prototype/playground folder — the thing a URL names and a person edits, never
|
|
4
|
+
// a lone file — and units are exactly the routing fragment's `publicPrefixes`. Galleries get
|
|
5
|
+
// `versionMap` entries and no prefix, which is what keeps them out.
|
|
6
|
+
//
|
|
7
|
+
// ⚠️ IT LIVES IN src/ BECAUSE THE SERVER NEEDS IT TOO. `C-fork-on-conflict` resolves a stale
|
|
8
|
+
// base inside the commit handler, and it has to agree with the client about what a unit is
|
|
9
|
+
// down to the last character. Two definitions of "which folder does this path belong to"
|
|
10
|
+
// would disagree on exactly the paths a conflict is about. scripts/lib/publish-conflict.mjs
|
|
11
|
+
// re-exports these three so no CLI import has to move.
|
|
12
|
+
|
|
13
|
+
const dec = (s) => { try { return decodeURIComponent(String(s)); } catch (e) { return String(s); } };
|
|
14
|
+
const norm = (p) => String(p == null ? "" : p).replace(/\/?$/, "/");
|
|
15
|
+
|
|
16
|
+
export function authoredUnits(manifest) {
|
|
17
|
+
const out = new Set();
|
|
18
|
+
for (const p of ((manifest || {}).routing || {}).publicPrefixes || []) out.add(norm(p));
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Which unit owns this path, or null for a path outside every unit.
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ THE LONGEST PREFIX WINS, and the first version of this returned the first match. With
|
|
26
|
+
* `/toolkit/` and `/toolkit/embed/` both units, a file under the second belonged to the
|
|
27
|
+
* first or the second depending on Set iteration order — which is insertion order, which is
|
|
28
|
+
* whatever the manifest happened to list. A conflict decided by JSON key order is a conflict
|
|
29
|
+
* decided at random.
|
|
30
|
+
*/
|
|
31
|
+
export function unitOfPath(path, unitSet) {
|
|
32
|
+
const p = dec(path);
|
|
33
|
+
let best = null;
|
|
34
|
+
for (const u of unitSet) {
|
|
35
|
+
const d = dec(u);
|
|
36
|
+
if (p.startsWith(d) && (!best || d.length > dec(best).length)) best = u;
|
|
37
|
+
}
|
|
38
|
+
return best;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function unitPaths(manifest, unit) {
|
|
42
|
+
const prefix = dec(unit);
|
|
43
|
+
return Object.keys((manifest || {}).files || {}).filter((p) => dec(p).startsWith(prefix));
|
|
44
|
+
}
|