@marver-design/marver 0.3.1 → 0.5.0
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/CHANGELOG.md +151 -0
- package/README.md +2 -0
- package/dist/auth-B36fMCM3.mjs +245 -0
- package/dist/{build-Ckmyci3O.mjs → build-BrCl9hJS.mjs} +56 -14
- package/dist/cli.mjs +15 -6
- package/dist/collab-CXy8gqoz.mjs +297 -0
- package/dist/comments-Ba8mU600.mjs +90 -0
- package/dist/comments-odHzYdO3.mjs +179 -0
- package/dist/dev-DdeU-Jst.mjs +162 -0
- package/dist/{init-DolP_Ld4.mjs → init-DsCUmlCW.mjs} +2 -1
- package/dist/{manifest-DW-T52MM.mjs → manifest-C8FODq2S.mjs} +26 -1
- package/dist/{plugin-Crp4CAma.mjs → plugin-BtSGAm2h.mjs} +169 -19
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/serve-D_KBK7Oy.mjs +431 -0
- package/dist/sync-CkBk-tUk.mjs +248 -0
- package/package.json +3 -1
- package/src/client/content/diagram.tsx +46 -2
- package/src/client/content/index.tsx +6 -2
- package/src/client/content/md.ts +29 -0
- package/src/client/content/palette.ts +6 -0
- package/src/client/frame-host/bridge.js +269 -3
- package/src/client/frame-host/serialize.ts +195 -0
- package/src/client/shell/App.tsx +131 -17
- package/src/client/shell/Comments.tsx +464 -0
- package/src/client/shell/Play.tsx +24 -0
- package/src/client/shell/canvas/Canvas.tsx +66 -29
- package/src/client/shell/canvas/FrameNode.tsx +130 -10
- package/src/client/shell/canvas/frame-registry.ts +18 -0
- package/src/client/shell/canvas/snapshots.ts +233 -0
- package/src/client/shell/comments-store.ts +180 -0
- package/src/client/shell/cursor-arrow-dark.svg +1 -0
- package/src/client/shell/cursor-arrow.svg +1 -0
- package/src/client/shell/hash.ts +14 -3
- package/src/client/shell/icons.tsx +6 -0
- package/src/client/shell/labels.ts +10 -0
- package/src/client/shell/perf.ts +92 -0
- package/src/client/shell/store.ts +101 -6
- package/src/client/shell/styles.css +196 -7
- package/src/client/stage/main.tsx +2 -0
- package/src/shared/events.ts +103 -0
- package/templates/AGENTS-embedded.md +13 -1
- package/templates/AGENTS-studio.md +13 -1
- package/templates/instructions/boards.md +6 -3
- package/templates/instructions/configure.md +28 -0
- package/templates/instructions/iterate.md +38 -0
- package/templates/instructions/publish.md +129 -0
- package/templates/instructions/reference/color.md +22 -1
- package/templates/instructions/shape.md +23 -16
- package/dist/dev-C2oTKuXe.mjs +0 -97
- package/dist/serve-OtA9Nlow.mjs +0 -207
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,157 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes to `@marver-design/marver`. Format follows [Keep a Changelog](https://keepachangelog.com); versions follow semver.
|
|
4
4
|
|
|
5
|
+
## 0.5.0 - 2026-08-15
|
|
6
|
+
|
|
7
|
+
The performance & fidelity release (SPEC-M5): the canvas stops jiggling. Moving around a board no
|
|
8
|
+
longer swaps between two documents on every pan/zoom - each passive frame renders as a lean DOM
|
|
9
|
+
snapshot that IS what you see, and the real live app takes over the moment you interact with it.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Lean-primary rendering.** Every passive frame shows a **DOM snapshot** - a self-contained static
|
|
14
|
+
copy of the frame (real DOM + real CSS, zero JavaScript) served in a `sandbox="allow-same-origin"`
|
|
15
|
+
iframe. It reflows on resize with the browser's own layout engine and carries the app's exact colors
|
|
16
|
+
(no rasterisation), so panning, zooming, and device-sweeping a board is smooth and pixel-honest. The
|
|
17
|
+
full live app sits underneath and swaps in instantly when you focus a frame (double-click), or in
|
|
18
|
+
laser/comment mode. This replaces the earlier screenshot facade, which invented colors and jittered.
|
|
19
|
+
- **Publish parity.** The lean tier now works in published builds (`marver build` → `marver serve`),
|
|
20
|
+
not just dev - captured client-side from the bundled same-origin frames, no build-time renderer.
|
|
21
|
+
- **Faster first paint.** Leans capture bounded-parallel and viewport-first, so the frames you're
|
|
22
|
+
looking at appear first and a big board settles in seconds instead of tens of seconds.
|
|
23
|
+
- **Content-frame color families.** Tag a diagram node with a built-in family - `HQ:::blue`,
|
|
24
|
+
`Carriers:::orange`, `Drivers:::purple` (also `green red gray`) - and it gets a filled, on-brand
|
|
25
|
+
color with a legible border in both themes, no `classDef` boilerplate. The same six names work in
|
|
26
|
+
`Md` prose as `:blue[the shipper's world]`, so a sentence and the diagram beside it read as one
|
|
27
|
+
color language.
|
|
28
|
+
- **`Head :: gloss` diagram labels.** A node label written `Head :: gloss` renders the head bold on
|
|
29
|
+
top with the gloss lighter and smaller below - a box scans as label-then-detail, no run-on.
|
|
30
|
+
- **Authoring doctrine that ships with the tool.** The scaffolded instructions
|
|
31
|
+
(`instructions/shape.md`, `instructions/reference/color.md`) now teach an agent these conventions -
|
|
32
|
+
the `::` label hierarchy, the `:::family` / `:blue[…]` palette, and "pick one family per concept and
|
|
33
|
+
hold it" - so diagrams and highlighted prose come out consistent by default instead of hand-rolled
|
|
34
|
+
hex and one-off `classDef`s.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- **Sidebar header** shows the humanized repo name (`marver-pilot` → "Marver Pilot", ellipsed if
|
|
39
|
+
long); the logo links to marver.design.
|
|
40
|
+
- **Sidebar board/scene labels** are humanized - kebab filenames render Title Case (`tms-specs` →
|
|
41
|
+
"Tms Specs"), dropping the dashes, while an explicit `meta.title` is honored verbatim.
|
|
42
|
+
- **App cursor** is the marver arrowhead - tilted, rounded, small, soft-shadowed, and theme-adaptive
|
|
43
|
+
(black-on-light / white-on-dark); reverts to a normal pointer in interact/prototype and keeps the
|
|
44
|
+
pin/crosshair in comment/laser mode.
|
|
45
|
+
- **Copy-file-path shortcut** moved to `Shift+P` (was a mislabeled `C`).
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- The canvas "jiggle" - text shifting ~1-2px when you click or zoom a frame - is gone; there is no
|
|
50
|
+
longer a per-gesture document swap to shift it.
|
|
51
|
+
- Mermaid diagrams no longer pop in/out or flash the wrong theme during zoom (async render is awaited;
|
|
52
|
+
a diagram's baked colors re-capture on theme change; the cover's color-scheme is pinned to the
|
|
53
|
+
frame theme, not the viewer's OS).
|
|
54
|
+
- A frame you've scrolled, typed into, or themed re-captures faithfully; agent edits (HMR) drop the
|
|
55
|
+
stale snapshot and rebuild; slow data that lands shortly after load triggers one bounded re-capture
|
|
56
|
+
(data that changes much later shows live the moment you focus the frame, and the lean rebuilds when
|
|
57
|
+
you leave it).
|
|
58
|
+
|
|
59
|
+
### Known limitations
|
|
60
|
+
|
|
61
|
+
- Memory targets typical authoring boards (~15-20 frames); dozens of heavy production apps need the
|
|
62
|
+
bounded-residency milestone. A frame the serializer can't render faithfully (canvas/video/open- or
|
|
63
|
+
script-created-closed shadow-DOM/nested-iframe/cross-origin-CSS/blocked-CSP/oversized) degrades to
|
|
64
|
+
live automatically. (One narrow edge: a declarative closed shadow root can't be detected and may
|
|
65
|
+
render stale - rare in practice.)
|
|
66
|
+
|
|
67
|
+
## 0.4.0 - 2026-08-14
|
|
68
|
+
|
|
69
|
+
The collaboration release (SPEC-M3): the canvas becomes a place where colleagues,
|
|
70
|
+
the designer, and the coding agent close the feedback loop together.
|
|
71
|
+
|
|
72
|
+
### Added
|
|
73
|
+
|
|
74
|
+
- **Element-anchored comments.** `C` enters comment mode: click any element inside a
|
|
75
|
+
frame (laser outlines guide you) and the thread pins to it - fractional position
|
|
76
|
+
inside the element's box, so pins ride through responsive reflow. Threads are
|
|
77
|
+
Google-Docs-shaped: root + one level of replies, resolve/reopen. Anchors survive
|
|
78
|
+
agent edits via a verified ladder (semantics → CSS path → fuzzy quote match);
|
|
79
|
+
a dead anchor parks the thread visibly at the frame edge, never silently deleted.
|
|
80
|
+
Inactive frames collapse their open threads into a top-right avatar stack.
|
|
81
|
+
`Shift+C` hides all pins; `?c=<thread>` deep-links a specific thread through the
|
|
82
|
+
password gate (copy-link on every card).
|
|
83
|
+
- **Real commenter identity, no email infrastructure.** Viewers on a published canvas
|
|
84
|
+
claim an account through a single-use invite link (owner mints it; the link travels
|
|
85
|
+
over Slack/DM) - display name, password, avatar. Accounts are scrypt-verified with
|
|
86
|
+
per-user salts; sessions survive restarts. The first account owns the canvas;
|
|
87
|
+
`MARVER_OWNER_EMAIL` prints the owner's one-time claim link in the deploy logs.
|
|
88
|
+
Avatars fall back to initials on a deterministic color.
|
|
89
|
+
- **Gate v2: one credential per persona.** The gate on a collaboration canvas has
|
|
90
|
+
three doors: guests pay the canvas password (read-only), members sign in with
|
|
91
|
+
their OWN password (their session IS gate passage - they never touch the shared
|
|
92
|
+
secret), and an invite link (`<url>/#/i/<token>`) opens straight into the claim -
|
|
93
|
+
email shown as an INVITED chip, profile-picture picker (client-side 128px
|
|
94
|
+
downscale), display name, password. Sign-in/claim endpoints sit in front of the
|
|
95
|
+
gate (rate-limited, non-enumerating); rotating the canvas password only ever
|
|
96
|
+
affects guests. Primary buttons stay disabled until their mandatory fields are
|
|
97
|
+
filled, with a tooltip naming what's missing. The boards payload carries the
|
|
98
|
+
owner's display name, so a read-only refusal says who to ask. Static canvases
|
|
99
|
+
keep the single-field gate untouched.
|
|
100
|
+
- **One deploy, comments live everywhere.** `marver serve` grows a collaboration API
|
|
101
|
+
(REST + SSE) when `MARVER_DATA_DIR` names a durable volume; the published canvas is
|
|
102
|
+
the comments' home. `marver dev` two-way syncs every 30s (`marver comments connect
|
|
103
|
+
<url>` once), so client feedback lands in `design/comments/<board>.jsonl` - a
|
|
104
|
+
git-tracked, append-only event log the agent reads with zero tooling. Republishing
|
|
105
|
+
never clobbers collected feedback (the store unions the bundle seed on boot).
|
|
106
|
+
- **The agent works the queue.** `marver comments list --open --json` / `reply` /
|
|
107
|
+
`resolve --addressed-in <frame>` - resolving records which variant answered the
|
|
108
|
+
feedback, making the fork-don't-overwrite doctrine auditable. `marver comments
|
|
109
|
+
invite <email>` mints single-use invite links from the CLI (owner only), `revoke
|
|
110
|
+
<email>` retires an account. `instructions/iterate.md` carries the
|
|
111
|
+
comments-as-work-queue discipline; `instructions/publish.md` is the agent-facing
|
|
112
|
+
deploy runbook (boards policy, gate, volume, accounts) and AGENTS.md routes to it.
|
|
113
|
+
- **Laser mode.** `L` (or the toolbar crosshair) outlines every element in every frame
|
|
114
|
+
with depth-hued borders (60° per nesting level) plus a DevTools-style hover label.
|
|
115
|
+
Zero layout shift - outlines only. Clicking an element copies its full address for
|
|
116
|
+
the agent - frame source file + CSS path (+ JSX source location when stamped).
|
|
117
|
+
Comment mode shows only the hover highlight (no full rainbow) and a chat-teardrop
|
|
118
|
+
cursor, so picking an element to comment on stays calm.
|
|
119
|
+
- **Default-closed publishing.** `marver build` now requires a publish policy:
|
|
120
|
+
`design/publish.json` names each published board with `read` or `comment` rights
|
|
121
|
+
(enforced server-side, not just hidden in UI). `--boards a,b` stays as an explicit
|
|
122
|
+
override; publishing everything takes a deliberate `--all-boards`.
|
|
123
|
+
|
|
124
|
+
### Changed
|
|
125
|
+
|
|
126
|
+
- **`c` is comment mode now** (the Figma/Miro convention). Copy-selected-file-paths
|
|
127
|
+
moved from `c` to `y`.
|
|
128
|
+
- `marver build` without a publish policy fails with instructions instead of
|
|
129
|
+
publishing everything - the privacy default flipped closed.
|
|
130
|
+
|
|
131
|
+
### Security
|
|
132
|
+
|
|
133
|
+
- Comment mutations are CSRF-protected (double-submit cookie + origin checks) and
|
|
134
|
+
rate-limited; session and invite tokens are stored hashed; every pushed event is
|
|
135
|
+
validated hard (author must match the session, thread ids cannot be hijacked,
|
|
136
|
+
edits are owner-only, timestamps cannot come from the future) - and accepted
|
|
137
|
+
events are never rewritten, so id-keyed sync converges byte-identically.
|
|
138
|
+
- v1 trust boundary, stated plainly: frame code in your design repo runs same-origin
|
|
139
|
+
with the shell - review what you merge. Full frame sandboxing is the v1.1 follow-up
|
|
140
|
+
(SPEC-M3 §0 records the probe results and the plan).
|
|
141
|
+
|
|
142
|
+
### Durability & concurrency
|
|
143
|
+
|
|
144
|
+
- Comment appends and auth writes are `fsync`'d before they are acknowledged - a
|
|
145
|
+
comment or account confirmed to the client survives a crash or volume interruption,
|
|
146
|
+
not just the page cache.
|
|
147
|
+
- `auth.json`'s read-modify-write is guarded by a cross-process lock (stale-stolen
|
|
148
|
+
after 10s, never deadlocks) so a deploy overlap or an accidental second instance
|
|
149
|
+
cannot resurrect a revoked account or drop an invite. The comment log needs no lock:
|
|
150
|
+
append-only + id-keyed union is conflict-free by construction (and git merges it
|
|
151
|
+
with `merge=union`). Two honest v1 limits, documented not hidden: thread ordering
|
|
152
|
+
uses client timestamps (a badly-skewed clock can mis-order a resolve/reopen race -
|
|
153
|
+
re-resolve to fix), and comment logs have no hard size ceiling (fine at the design-
|
|
154
|
+
review scale marver targets; a per-board cap is a later concern).
|
|
155
|
+
|
|
5
156
|
## 0.3.1 - 2026-08-13
|
|
6
157
|
|
|
7
158
|
### Fixed
|
package/README.md
CHANGED
|
@@ -19,6 +19,8 @@ Then, to your agent:
|
|
|
19
19
|
- **Devices view**: the Devices menu (or hotkeys `1`-`5`) sizes every frame to mobile / tablet / laptop / monitor / tv to sweep your breakpoints; `0` restores your own layout exactly. Widths live in `design/config.ts`.
|
|
20
20
|
- `data-goto="scene/frame"` on any element links frames into a walkable prototype.
|
|
21
21
|
- **Content frames**: specs, Mermaid diagrams, and mood boards live on the same canvas as the screens - import `Doc`, `Md`, `Diagram`, `Img` from `@marver-design/marver/content` and think a feature through *before* any pixels exist. Diagrams ship pre-themed (both modes), content frames auto-size to their content, and everything - devices, play mode, publish - works on them identically. Works in a repo with no app at all: idea first, design second.
|
|
22
|
+
- **Comments**: Google-Docs-style feedback, pinned to actual elements. Press `C`, click a div inside a frame, write - the thread lives on that element, survives edits via a layered anchor (source semantics → structure → fuzzy text), and collapses to an avatar stack when the frame isn't active. Viewers on a published canvas comment with real names and avatars (invite-link accounts, no email infrastructure); `marver dev` syncs the same threads into `design/comments/*.jsonl`, where your agent works the queue: `npx marver comments list --open --json` → fork a variant → `resolve --addressed-in`. Live via SSE; one deploy, no extra services (set `MARVER_DATA_DIR` on a volume + `MARVER_OWNER_EMAIL` for the first account).
|
|
23
|
+
- **Laser mode**: `L` outlines every element in every frame with depth-hued borders plus a hover label - the fastest way to see structure. Click any element to copy its full address (frame file + CSS path) for the agent. Comment mode is the calm cousin - one at a time with laser - showing only the hovered element so picking a comment target never overwhelms.
|
|
22
24
|
- **Upgrade**: `npm i -D @marver-design/marver@latest && npx marver init`. The canvas tells you when a new version is out (one anonymous registry check per day, cached in `design/.local/`; `MARVER_NO_UPDATE_CHECK=1` disables). Re-running init refreshes the managed files (AGENTS.md, `design/instructions/`) - your edits to them are detected and preserved; when both you and a release changed a file, the fresh version is staged at `design/.local/latest/` for you (or your agent) to merge. Everything else in `design/` is yours and never touched.
|
|
23
25
|
- Uninstall: delete `design/`, remove the dependency. (If `init` patched your tsconfig `exclude`, revert that one line.)
|
|
24
26
|
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
4
|
+
//#region src/server/auth.ts
|
|
5
|
+
/**
|
|
6
|
+
* Accounts, invites, sessions (SPEC-M3 §3) - the minimal credible implementation,
|
|
7
|
+
* extending the gate's own idiom. No framework: per-user salted scrypt verifiers,
|
|
8
|
+
* opaque session tokens stored hashed, single-use invite links as the identity
|
|
9
|
+
* bootstrap (no email infrastructure anywhere).
|
|
10
|
+
*
|
|
11
|
+
* Password = read, account = comment: everything here concerns accounts only; the
|
|
12
|
+
* shared gate password (serve.ts) remains the outer READ boundary.
|
|
13
|
+
*
|
|
14
|
+
* State lives in MARVER_DATA_DIR as two small JSON files rewritten atomically -
|
|
15
|
+
* users change rarely; the event-log treatment is reserved for comments.
|
|
16
|
+
*/
|
|
17
|
+
const SCRYPT = {
|
|
18
|
+
N: 2 ** 15,
|
|
19
|
+
r: 8,
|
|
20
|
+
p: 1,
|
|
21
|
+
keylen: 32,
|
|
22
|
+
maxmem: 67108864
|
|
23
|
+
};
|
|
24
|
+
const INVITE_TTL = 6048e5;
|
|
25
|
+
const SESSION_TTL = 2592e6;
|
|
26
|
+
const normEmail = (e) => e.trim().toLowerCase();
|
|
27
|
+
const sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
28
|
+
const token = () => randomBytes(32).toString("base64url");
|
|
29
|
+
const storeFile = (dir) => join(dir, "auth.json");
|
|
30
|
+
/** Only a MISSING file is an empty store. A present-but-unreadable/corrupt auth.json
|
|
31
|
+
* must fail CLOSED - treating it as empty would let the owner bootstrap re-run and
|
|
32
|
+
* a later save overwrite every account. */
|
|
33
|
+
function loadStore(dir) {
|
|
34
|
+
let raw;
|
|
35
|
+
try {
|
|
36
|
+
raw = readFileSync(storeFile(dir), "utf8");
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (err.code === "ENOENT") return {
|
|
39
|
+
users: [],
|
|
40
|
+
invites: [],
|
|
41
|
+
sessions: []
|
|
42
|
+
};
|
|
43
|
+
throw new Error(`auth store unreadable (${err.message}) - refusing to treat it as empty`);
|
|
44
|
+
}
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(raw);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error("auth store is corrupt JSON - refusing to treat it as empty. Restore it or delete it deliberately.");
|
|
50
|
+
}
|
|
51
|
+
if (!Array.isArray(parsed?.users) || !Array.isArray(parsed?.invites) || !Array.isArray(parsed?.sessions)) throw new Error("auth store has an unexpected shape - refusing to load it");
|
|
52
|
+
return parsed;
|
|
53
|
+
}
|
|
54
|
+
/** Atomic rewrite (tmp + rename) - a crash mid-write must never lose every account.
|
|
55
|
+
* 0600 throughout: the store holds emails and password verifiers. */
|
|
56
|
+
function saveStore(dir, store) {
|
|
57
|
+
const file = storeFile(dir);
|
|
58
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
store.invites = store.invites.filter((i) => i.exp > now);
|
|
61
|
+
store.sessions = store.sessions.filter((s) => s.exp > now);
|
|
62
|
+
const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
|
|
63
|
+
const fd = openSync(tmp, "wx", 384);
|
|
64
|
+
try {
|
|
65
|
+
writeSync(fd, JSON.stringify(store, null, 2));
|
|
66
|
+
fsyncSync(fd);
|
|
67
|
+
} finally {
|
|
68
|
+
closeSync(fd);
|
|
69
|
+
}
|
|
70
|
+
renameSync(tmp, file);
|
|
71
|
+
}
|
|
72
|
+
/** Cross-process mutex over auth.json's read-modify-write. Within one Node process
|
|
73
|
+
* the store mutations are already synchronous and atomic; this covers deploy overlap
|
|
74
|
+
* and any accidental multi-instance run (the supported setup is single-instance) -
|
|
75
|
+
* without it, a sign-in that loaded a pre-revoke snapshot could rename it back over a
|
|
76
|
+
* successful revoke. A crashed holder's lock is stolen after 10s; waiting past 5s
|
|
77
|
+
* fails loudly rather than hanging. */
|
|
78
|
+
function withLock(dir, fn) {
|
|
79
|
+
mkdirSync(dir, { recursive: true });
|
|
80
|
+
const lock = join(dir, ".auth.lock");
|
|
81
|
+
const nap = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
82
|
+
const deadline = Date.now() + 5e3;
|
|
83
|
+
for (;;) try {
|
|
84
|
+
closeSync(openSync(lock, "wx"));
|
|
85
|
+
break;
|
|
86
|
+
} catch (e) {
|
|
87
|
+
if (e.code !== "EEXIST") throw e;
|
|
88
|
+
try {
|
|
89
|
+
if (Date.now() - statSync(lock).mtimeMs > 1e4) {
|
|
90
|
+
unlinkSync(lock);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (Date.now() > deadline) throw new Error("auth store is busy - please retry");
|
|
97
|
+
nap(25);
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
return fn();
|
|
101
|
+
} finally {
|
|
102
|
+
try {
|
|
103
|
+
unlinkSync(lock);
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const findUser = (store, email) => store.users.find((u) => normEmail(u.email) === normEmail(email));
|
|
108
|
+
/** Mint a single-use invite for an email (this IS the allowlist entry - inviting an
|
|
109
|
+
* address authorizes it). Re-inviting an email replaces its pending invite. The raw
|
|
110
|
+
* token is returned exactly once; only its hash is stored. */
|
|
111
|
+
/** The owner's display name - public-safe (never the email). Null before claim. */
|
|
112
|
+
function ownerName(dir) {
|
|
113
|
+
return loadStore(dir).users.find((u) => u.role === "owner")?.name?.trim() || null;
|
|
114
|
+
}
|
|
115
|
+
/** Peek at a live invite: the claim screens show WHO the invite is for. The raw
|
|
116
|
+
* token is the proof - holding it means the owner sent it to you. */
|
|
117
|
+
function inviteInfo(dir, rawToken) {
|
|
118
|
+
const hash = sha256(rawToken);
|
|
119
|
+
const invite = loadStore(dir).invites.find((i) => i.tokenHash === hash && i.exp > Date.now());
|
|
120
|
+
return invite ? { email: invite.emailNorm } : null;
|
|
121
|
+
}
|
|
122
|
+
function createInvite(dir, email) {
|
|
123
|
+
return withLock(dir, () => {
|
|
124
|
+
const store = loadStore(dir);
|
|
125
|
+
if (findUser(store, email)) throw new Error(`${normEmail(email)} already has an account`);
|
|
126
|
+
const raw = token();
|
|
127
|
+
const exp = Date.now() + INVITE_TTL;
|
|
128
|
+
store.invites = store.invites.filter((i) => i.emailNorm !== normEmail(email));
|
|
129
|
+
store.invites.push({
|
|
130
|
+
emailNorm: normEmail(email),
|
|
131
|
+
tokenHash: sha256(raw),
|
|
132
|
+
exp
|
|
133
|
+
});
|
|
134
|
+
saveStore(dir, store);
|
|
135
|
+
return {
|
|
136
|
+
token: raw,
|
|
137
|
+
exp
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/** Claim an invite: burns it, creates the account, opens the first session. */
|
|
142
|
+
function claimInvite(dir, rawToken, profile) {
|
|
143
|
+
if (profile.password.length < 8) throw new Error("password must be at least 8 characters");
|
|
144
|
+
if (!profile.name.trim()) throw new Error("a display name is required");
|
|
145
|
+
return withLock(dir, () => {
|
|
146
|
+
const store = loadStore(dir);
|
|
147
|
+
const hash = sha256(rawToken);
|
|
148
|
+
const invite = store.invites.find((i) => i.tokenHash === hash && i.exp > Date.now());
|
|
149
|
+
if (!invite) throw new Error("this invite link is invalid, expired, or already used");
|
|
150
|
+
store.invites = store.invites.filter((i) => i !== invite);
|
|
151
|
+
const salt = randomBytes(16).toString("hex");
|
|
152
|
+
const user = {
|
|
153
|
+
email: invite.emailNorm,
|
|
154
|
+
name: profile.name.trim(),
|
|
155
|
+
avatar: profile.avatar,
|
|
156
|
+
role: store.users.length ? "member" : "owner",
|
|
157
|
+
salt,
|
|
158
|
+
hash: scryptSync(profile.password, Buffer.from(salt, "hex"), SCRYPT.keylen, SCRYPT).toString("hex"),
|
|
159
|
+
params: SCRYPT,
|
|
160
|
+
createdAt: Date.now()
|
|
161
|
+
};
|
|
162
|
+
store.users.push(user);
|
|
163
|
+
const session = pushSession(store, user);
|
|
164
|
+
saveStore(dir, store);
|
|
165
|
+
return {
|
|
166
|
+
user,
|
|
167
|
+
session
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/** Password sign-in. One generic failure - never reveal whether the email exists. */
|
|
172
|
+
function signIn(dir, email, password) {
|
|
173
|
+
return withLock(dir, () => {
|
|
174
|
+
const store = loadStore(dir);
|
|
175
|
+
const user = findUser(store, email);
|
|
176
|
+
const salt = user ? Buffer.from(user.salt, "hex") : randomBytes(16);
|
|
177
|
+
const params = user?.params ?? SCRYPT;
|
|
178
|
+
const got = scryptSync(password, salt, params.keylen, params);
|
|
179
|
+
const want = user ? Buffer.from(user.hash, "hex") : randomBytes(SCRYPT.keylen);
|
|
180
|
+
if (!user || got.length !== want.length || !timingSafeEqual(got, want)) return null;
|
|
181
|
+
const session = pushSession(store, user);
|
|
182
|
+
saveStore(dir, store);
|
|
183
|
+
return {
|
|
184
|
+
user,
|
|
185
|
+
session
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
function pushSession(store, user) {
|
|
190
|
+
const raw = token();
|
|
191
|
+
store.sessions.push({
|
|
192
|
+
tokenHash: sha256(raw),
|
|
193
|
+
emailNorm: normEmail(user.email),
|
|
194
|
+
exp: Date.now() + SESSION_TTL
|
|
195
|
+
});
|
|
196
|
+
return raw;
|
|
197
|
+
}
|
|
198
|
+
/** Resolve a session token to its user; null when unknown or expired. */
|
|
199
|
+
function sessionUser(dir, rawToken) {
|
|
200
|
+
const store = loadStore(dir);
|
|
201
|
+
const hash = sha256(rawToken);
|
|
202
|
+
const s = store.sessions.find((s) => s.tokenHash === hash && s.exp > Date.now());
|
|
203
|
+
return s ? store.users.find((u) => normEmail(u.email) === s.emailNorm) ?? null : null;
|
|
204
|
+
}
|
|
205
|
+
function signOut(dir, rawToken) {
|
|
206
|
+
withLock(dir, () => {
|
|
207
|
+
const store = loadStore(dir);
|
|
208
|
+
const hash = sha256(rawToken);
|
|
209
|
+
store.sessions = store.sessions.filter((s) => s.tokenHash !== hash);
|
|
210
|
+
saveStore(dir, store);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/** Update name/avatar on an existing account. */
|
|
214
|
+
function updateProfile(dir, email, patch) {
|
|
215
|
+
return withLock(dir, () => {
|
|
216
|
+
const store = loadStore(dir);
|
|
217
|
+
const user = findUser(store, email);
|
|
218
|
+
if (!user) throw new Error("no such account");
|
|
219
|
+
if (patch.name?.trim()) user.name = patch.name.trim();
|
|
220
|
+
if (patch.avatar !== void 0) user.avatar = patch.avatar || void 0;
|
|
221
|
+
saveStore(dir, store);
|
|
222
|
+
return user;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
/** Remove an account and all its sessions (owner action). The LAST owner cannot be
|
|
226
|
+
* removed - a store with members but no owner has no one left to administer it,
|
|
227
|
+
* and bootstrap will not re-run while any user exists. */
|
|
228
|
+
function revokeUser(dir, email) {
|
|
229
|
+
withLock(dir, () => {
|
|
230
|
+
const store = loadStore(dir);
|
|
231
|
+
if (store.users.find((u) => normEmail(u.email) === normEmail(email))?.role === "owner" && !store.users.some((u) => u.role === "owner" && normEmail(u.email) !== normEmail(email))) throw new Error("cannot remove the last owner - the canvas would have no administrator left");
|
|
232
|
+
store.users = store.users.filter((u) => normEmail(u.email) !== normEmail(email));
|
|
233
|
+
store.sessions = store.sessions.filter((s) => s.emailNorm !== normEmail(email));
|
|
234
|
+
store.invites = store.invites.filter((i) => i.emailNorm !== normEmail(email));
|
|
235
|
+
saveStore(dir, store);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
/** The public shape of a user - what other viewers (and events) may see. */
|
|
239
|
+
const publicUser = (u) => ({
|
|
240
|
+
email: u.email,
|
|
241
|
+
name: u.name,
|
|
242
|
+
avatar: u.avatar
|
|
243
|
+
});
|
|
244
|
+
//#endregion
|
|
245
|
+
export { claimInvite, createInvite, inviteInfo, loadStore, normEmail, ownerName, publicUser, revokeUser, sessionUser, signIn, signOut, updateProfile };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { i as ROUTE, n as NAME } from "./cli.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-
|
|
2
|
+
import { o as loadConfig, r as scanFrames, s as detectHost } from "./manifest-C8FODq2S.mjs";
|
|
3
|
+
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-BtSGAm2h.mjs";
|
|
4
4
|
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, join, sep } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -43,6 +43,37 @@ function scanAssetRefs(src, moduleId) {
|
|
|
43
43
|
}
|
|
44
44
|
/** Same shape the client's assetUrl accepts: relative, inside design/assets/, no tricks. */
|
|
45
45
|
const isLocalAssetRef = (p) => !!p && !p.includes(":") && !p.startsWith("/") && !p.startsWith("\\") && !p.split("/").some((s) => s === ".." || s === "");
|
|
46
|
+
/** The publish policy (SPEC-M3 §4): design/publish.json names what ships and with what
|
|
47
|
+
* rights. Publishing is default-CLOSED - no policy and no explicit flag = no build.
|
|
48
|
+
* Returns name -> 'read' | 'comment'. Boards absent from the result do not ship. */
|
|
49
|
+
function resolvePublish(root, allBoards, boardsFlag, allBoardsFlag) {
|
|
50
|
+
const known = (n) => n === "all-scenes" || !!allBoards[n];
|
|
51
|
+
if (boardsFlag !== void 0) {
|
|
52
|
+
const names = boardsFlag.split(",").map((s) => s.trim()).filter(Boolean);
|
|
53
|
+
if (!names.length) throw new Error("--boards was given but named no boards");
|
|
54
|
+
const missing = names.filter((n) => !known(n));
|
|
55
|
+
if (missing.length) throw new Error(`--boards names not found in design/boards/: ${missing.join(", ")}`);
|
|
56
|
+
return Object.fromEntries(names.map((n) => [n, "comment"]));
|
|
57
|
+
}
|
|
58
|
+
if (allBoardsFlag) return Object.fromEntries(["all-scenes", ...Object.keys(allBoards).filter((n) => n !== "all-scenes")].map((n) => [n, "comment"]));
|
|
59
|
+
const policyFile = join(root, "design", "publish.json");
|
|
60
|
+
if (!existsSync(policyFile)) throw new Error("publishing is default-closed and no publish policy exists.\n Either declare one in design/publish.json: { \"boards\": { \"<board>\": \"read\" | \"comment\" } }\n or be explicit: --boards a,b (publish just those) · --all-boards (publish everything)");
|
|
61
|
+
let policy;
|
|
62
|
+
try {
|
|
63
|
+
policy = JSON.parse(readFileSync(policyFile, "utf8"));
|
|
64
|
+
} catch {
|
|
65
|
+
throw new Error("design/publish.json is not valid JSON");
|
|
66
|
+
}
|
|
67
|
+
const entries = Object.entries(policy?.boards ?? {});
|
|
68
|
+
if (!entries.length) throw new Error("design/publish.json has no \"boards\" entries - publishing is default-closed, name what ships");
|
|
69
|
+
const out = {};
|
|
70
|
+
for (const [n, level] of entries) {
|
|
71
|
+
if (!known(n)) throw new Error(`design/publish.json names an unknown board: ${n}`);
|
|
72
|
+
if (level !== "read" && level !== "comment") throw new Error(`design/publish.json: board "${n}" has level "${level}" - use "read" or "comment"`);
|
|
73
|
+
out[n] = level;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
46
77
|
/** Read every board file; returns name -> parsed json. Bad JSON fails the build loudly. */
|
|
47
78
|
function readBoards(root) {
|
|
48
79
|
const dir = join(root, "design", "boards");
|
|
@@ -104,7 +135,7 @@ export function layoutChain(fileKey) {
|
|
|
104
135
|
}
|
|
105
136
|
`;
|
|
106
137
|
}
|
|
107
|
-
async function buildSite(root, boardsFlag) {
|
|
138
|
+
async function buildSite(root, boardsFlag, allBoardsFlag) {
|
|
108
139
|
const config = await loadConfig(root);
|
|
109
140
|
const host = detectHost(root);
|
|
110
141
|
const pkgDir = packageDir();
|
|
@@ -112,13 +143,8 @@ async function buildSite(root, boardsFlag) {
|
|
|
112
143
|
const outDir = join(root, "design", ".dist");
|
|
113
144
|
const manifest = scanFrames(root);
|
|
114
145
|
const allBoards = readBoards(root);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
publishedNames = boardsFlag.split(",").map((s) => s.trim()).filter(Boolean);
|
|
118
|
-
if (!publishedNames.length) throw new Error("--boards was given but named no boards");
|
|
119
|
-
const missing = publishedNames.filter((n) => n !== "all-scenes" && !allBoards[n]);
|
|
120
|
-
if (missing.length) throw new Error(`--boards names not found in design/boards/: ${missing.join(", ")}`);
|
|
121
|
-
} else publishedNames = ["all-scenes", ...Object.keys(allBoards).filter((n) => n !== "all-scenes")];
|
|
146
|
+
const rights = resolvePublish(root, allBoards, boardsFlag, allBoardsFlag);
|
|
147
|
+
const publishedNames = Object.keys(rights);
|
|
122
148
|
const includeAll = publishedNames.includes("all-scenes");
|
|
123
149
|
const boards = {};
|
|
124
150
|
for (const n of publishedNames) if (allBoards[n]) boards[n] = allBoards[n];
|
|
@@ -138,7 +164,8 @@ async function buildSite(root, boardsFlag) {
|
|
|
138
164
|
},
|
|
139
165
|
boards,
|
|
140
166
|
names: publishedNames,
|
|
141
|
-
default: publishedNames[0]
|
|
167
|
+
default: publishedNames[0],
|
|
168
|
+
rights
|
|
142
169
|
};
|
|
143
170
|
const registryFile = posix(join(clientDir, "frame-host", "registry.ts"));
|
|
144
171
|
const plugins = [{
|
|
@@ -223,7 +250,9 @@ async function buildSite(root, boardsFlag) {
|
|
|
223
250
|
for (const f of frames.filter((x) => x.kind === "html")) {
|
|
224
251
|
const src = readFileSync(join(root, f.file), "utf8");
|
|
225
252
|
const inject = `${frameCss}\n<script type="module" src="/assets/bridge.js?html=1"><\/script>\n`;
|
|
226
|
-
|
|
253
|
+
let html = src.includes("</head>") ? src.replace("</head>", `${inject}</head>`) : inject + src;
|
|
254
|
+
const shim = `<script>(function(){var a=Element.prototype.attachShadow;if(a)Element.prototype.attachShadow=function(i){if(i&&i.mode==='closed')window.__mvClosedShadow=1;return a.call(this,i)};})();<\/script>`;
|
|
255
|
+
html = /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, (m) => m + shim) : shim + html;
|
|
227
256
|
mkdirSync(dirname(join(outDir, f.file)), { recursive: true });
|
|
228
257
|
writeFileSync(join(outDir, f.file), html);
|
|
229
258
|
}
|
|
@@ -258,6 +287,18 @@ async function buildSite(root, boardsFlag) {
|
|
|
258
287
|
cpSync(srcFile, dest);
|
|
259
288
|
copiedAssets++;
|
|
260
289
|
}
|
|
290
|
+
const commentsDir = join(root, "design", "comments");
|
|
291
|
+
if (existsSync(commentsDir)) {
|
|
292
|
+
let seeded = 0;
|
|
293
|
+
for (const n of publishedNames) {
|
|
294
|
+
const f = join(commentsDir, `${n}.jsonl`);
|
|
295
|
+
if (!existsSync(f)) continue;
|
|
296
|
+
mkdirSync(join(outDir, "design", "comments"), { recursive: true });
|
|
297
|
+
cpSync(f, join(outDir, "design", "comments", `${n}.jsonl`));
|
|
298
|
+
seeded++;
|
|
299
|
+
}
|
|
300
|
+
if (seeded) console.log(` comments: ${seeded} board log${seeded === 1 ? "" : "s"} seeded`);
|
|
301
|
+
}
|
|
261
302
|
let name = basename(root);
|
|
262
303
|
try {
|
|
263
304
|
name = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name ?? name;
|
|
@@ -282,10 +323,11 @@ async function buildSite(root, boardsFlag) {
|
|
|
282
323
|
writeFileSync(join(outDir, "meta.json"), JSON.stringify({
|
|
283
324
|
name,
|
|
284
325
|
branding: config.share.branding,
|
|
285
|
-
logo
|
|
326
|
+
logo,
|
|
327
|
+
rights
|
|
286
328
|
}));
|
|
287
329
|
console.log(`\n ${NAME} build → design/.dist`);
|
|
288
|
-
console.log(` boards: ${publishedNames.join(", ")}`);
|
|
330
|
+
console.log(` boards: ${publishedNames.map((n) => `${n} (${rights[n]})`).join(", ")}`);
|
|
289
331
|
console.log(` frames: ${frames.length}${includeAll ? "" : ` of ${manifest.frames.length} (build-time filter)`}`);
|
|
290
332
|
if (copiedAssets) console.log(` assets: ${copiedAssets} referenced file${copiedAssets === 1 ? "" : "s"} from design/assets/ (unreferenced assets never ship)`);
|
|
291
333
|
if (!includeAll && existsSync(join(root, "public"))) console.log(` note: the host public/ directory ships in full - the --boards filter covers frames, not public assets`);
|
package/dist/cli.mjs
CHANGED
|
@@ -39,14 +39,14 @@ function version() {
|
|
|
39
39
|
}
|
|
40
40
|
const cli = cac(NAME);
|
|
41
41
|
cli.command("init", "Scaffold design/ in this repo").option("--mode <mode>", "studio | embedded", { default: "studio" }).option("--no-demo", "Skip the demo scene (the demo ships unless this flag is passed)").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
|
|
42
|
-
const { init } = await import("./init-
|
|
42
|
+
const { init } = await import("./init-DsCUmlCW.mjs");
|
|
43
43
|
init(resolve(opts.root), {
|
|
44
44
|
mode: opts.mode === "embedded" ? "embedded" : "studio",
|
|
45
45
|
demo: opts.demo !== false
|
|
46
46
|
});
|
|
47
47
|
});
|
|
48
48
|
cli.command("dev", "Start the canvas").option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default 5199)").action(async (opts) => {
|
|
49
|
-
const { dev } = await import("./dev-
|
|
49
|
+
const { dev } = await import("./dev-DdeU-Jst.mjs");
|
|
50
50
|
let port;
|
|
51
51
|
if (opts.port !== void 0) {
|
|
52
52
|
const n = Number(opts.port);
|
|
@@ -55,18 +55,18 @@ cli.command("dev", "Start the canvas").option("--root <dir>", "Host repo root",
|
|
|
55
55
|
}
|
|
56
56
|
await dev(resolve(opts.root), port);
|
|
57
57
|
});
|
|
58
|
-
cli.command("build", "Static export → design/.dist").option("--boards <names>", "Publish only these boards (comma-separated); the
|
|
59
|
-
const { buildSite } = await import("./build-
|
|
58
|
+
cli.command("build", "Static export → design/.dist (what ships comes from design/publish.json - publishing is default-closed)").option("--boards <names>", "Publish only these boards (comma-separated); overrides the publish policy").option("--all-boards", "Publish every board - the loud override for the default-closed policy").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
|
|
59
|
+
const { buildSite } = await import("./build-BrCl9hJS.mjs");
|
|
60
60
|
try {
|
|
61
61
|
const boards = opts.boards === void 0 ? void 0 : typeof opts.boards === "string" ? opts.boards : "";
|
|
62
|
-
await buildSite(resolve(opts.root), boards);
|
|
62
|
+
await buildSite(resolve(opts.root), boards, opts.allBoards === true);
|
|
63
63
|
} catch (err) {
|
|
64
64
|
console.error(`[${NAME}] build failed: ${err.message}`);
|
|
65
65
|
process.exit(1);
|
|
66
66
|
}
|
|
67
67
|
});
|
|
68
68
|
cli.command("serve", "Serve design/.dist (set MARVER_PASSWORD to gate it)").option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default $PORT or 4199)").action(async (opts) => {
|
|
69
|
-
const { serve } = await import("./serve-
|
|
69
|
+
const { serve } = await import("./serve-D_KBK7Oy.mjs");
|
|
70
70
|
let port;
|
|
71
71
|
if (opts.port !== void 0) {
|
|
72
72
|
const n = Number(opts.port);
|
|
@@ -74,6 +74,15 @@ cli.command("serve", "Serve design/.dist (set MARVER_PASSWORD to gate it)").opti
|
|
|
74
74
|
}
|
|
75
75
|
serve(resolve(opts.root), port);
|
|
76
76
|
});
|
|
77
|
+
cli.command("comments <action> [value]", "Comment collaboration: connect <url> · sync · list · reply <thread> · resolve <thread> · invite <email> · revoke <email>").option("--root <dir>", "Host repo root", { default: "." }).option("--invite <token>", "connect: claim this invite instead of signing in").option("--canvas-password <password>", "connect: the canvas gate password (default $MARVER_PASSWORD or prompt)").option("--email <email>", "connect: account email (skips the prompt)").option("--password <password>", "connect: account password (skips the prompt - mind your shell history)").option("--name <name>", "connect --invite: display name for the new account").option("--open", "list: only unresolved threads").option("--json", "list: machine-readable output").option("--board <board>", "scope to one board").option("--body <text>", "reply: the reply text").option("--addressed-in <frame>", "resolve: the variant frame that answered the feedback").action(async (action, value, opts) => {
|
|
78
|
+
const { commentsCommand } = await import("./comments-odHzYdO3.mjs");
|
|
79
|
+
try {
|
|
80
|
+
await commentsCommand(resolve(opts.root), action, value, opts);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error(`[${NAME}] ${err.message}`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
77
86
|
cli.help();
|
|
78
87
|
cli.version(version());
|
|
79
88
|
cli.parse();
|