@marver-design/marver 0.10.2 → 0.11.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/CHANGELOG.md +161 -0
- package/README.md +16 -3
- package/dist/auth-B5yuwnOq.mjs +494 -0
- package/dist/{build-zUf_kwKp.mjs → build-DI31MFTB.mjs} +3 -2
- package/dist/cli.mjs +6 -6
- package/dist/{collab-s3k5byM1.mjs → collab-CWaG3Q4w.mjs} +43 -11
- package/dist/{comments-J06jqCVV.mjs → comments-CRqaO2MH.mjs} +26 -11
- package/dist/{comments-BZBKhKRO.mjs → comments-DZyobpxG.mjs} +8 -2
- package/dist/{manifest-DIsp3ldB.mjs → config-t9coJ-Pq.mjs} +3 -233
- package/dist/{daemon-BpKBJZee.mjs → daemon-DSmaS453.mjs} +23 -5
- package/dist/{dev-D2PG6igI.mjs → dev-BWIL2ewj.mjs} +117 -13
- package/dist/{init-DwV0_d9G.mjs → init-BSclDg4I.mjs} +2 -1
- package/dist/manifest-C2tzkNaC.mjs +233 -0
- package/dist/marver-id-B8-3WiHk.mjs +411 -0
- package/dist/marver-id-gate-BDzW6ahN.mjs +728 -0
- package/dist/{plugin-C1sDewZ3.mjs → plugin-DjBFxEQ7.mjs} +12 -84
- package/dist/{profile-BkiWglVE.mjs → profile-DcsJyppw.mjs} +9 -6
- package/dist/secure-cookie-_K1Hsx8H.mjs +26 -0
- package/dist/{serve-CZqPnj19.mjs → serve-3APqaNSB.mjs} +196 -43
- package/dist/sync-DELGomPk.mjs +397 -0
- package/dist/update-DuWDj5nR.mjs +77 -0
- package/dist/utm-CxC3QN5X.mjs +20 -0
- package/package.json +1 -1
- package/src/shared/utm.ts +2 -2
- package/templates/instructions/configure.md +4 -0
- package/templates/instructions/publish.md +99 -16
- package/dist/auth-KQ9Aj-nB.mjs +0 -245
- package/dist/events-BMtBvvgU.mjs +0 -101
- package/dist/sync-BJKKmy1n.mjs +0 -150
- package/dist/{shot-D7I0MCLu.mjs → shot-Cyv3GN79.mjs} +1 -1
|
@@ -18,11 +18,32 @@ vars if the host has no CLI.
|
|
|
18
18
|
`--boards a,b` overrides ad hoc (grants comment), `--all-boards` ships
|
|
19
19
|
everything loudly.
|
|
20
20
|
|
|
21
|
-
2. **
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
2. **Who gets in** - pick ONE of two gates at serve time. They are alternatives,
|
|
22
|
+
not layers; setting both weakens the invite list to "an account OR whoever
|
|
23
|
+
has the password". No env var at all = an open canvas.
|
|
24
|
+
|
|
25
|
+
**Marver Sign In - `MARVER_ID_ISSUER`. Use this by default.** People sign in
|
|
26
|
+
as themselves (Google, or an emailed code) instead of sharing a secret. One
|
|
27
|
+
sign-in opens every canvas gated this way, there is no password to leak or
|
|
28
|
+
rotate, and removing one person removes exactly them. Needs
|
|
29
|
+
`MARVER_PUBLIC_ORIGIN` set to the canvas's exact public origin, and
|
|
30
|
+
`MARVER_DATA_DIR`.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
MARVER_ID_ISSUER=https://id.marver.design
|
|
34
|
+
MARVER_PUBLIC_ORIGIN=https://<the deployed url>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**The canvas password - `MARVER_PASSWORD`.** The sovereign option, and the
|
|
38
|
+
right one when the canvas must depend on nothing outside itself: no outbound
|
|
39
|
+
request of any kind. One shared password for GUESTS; members never need it,
|
|
40
|
+
since their own account signs them in and invite links skip it entirely.
|
|
41
|
+
Choose it deliberately - offline or air-gapped hosting, or an explicit
|
|
42
|
+
preference for no third party in the sign-in path - not by default.
|
|
43
|
+
|
|
44
|
+
**Who may enter is decided by the canvas either way**, from the invite list
|
|
45
|
+
the owner keeps. Marver Sign In proves who somebody is; it has no say in
|
|
46
|
+
where they may go, and is never told the answer.
|
|
26
47
|
|
|
27
48
|
3. **Collaboration on or off** - `MARVER_DATA_DIR` at serve time. Set it to a
|
|
28
49
|
path on a PERSISTENT disk and the serve grows accounts + live comments.
|
|
@@ -35,9 +56,12 @@ vars if the host has no CLI.
|
|
|
35
56
|
| Var | Meaning |
|
|
36
57
|
|---|---|
|
|
37
58
|
| `PORT` | listen port (hosts inject this) |
|
|
38
|
-
| `
|
|
59
|
+
| `MARVER_ID_ISSUER` | `https://id.marver.design` - people sign in as themselves (**preferred**) |
|
|
60
|
+
| `MARVER_PUBLIC_ORIGIN` | REQUIRED with `MARVER_ID_ISSUER`: this canvas's exact public origin |
|
|
61
|
+
| `MARVER_PASSWORD` | the sovereign alternative: one shared password; unset = open |
|
|
39
62
|
| `MARVER_DATA_DIR` | persistent dir for `comments/` + `auth.json`; unset = no collaboration |
|
|
40
|
-
| `MARVER_OWNER_EMAIL` |
|
|
63
|
+
| `MARVER_OWNER_EMAIL` | who owns an empty canvas. With Marver Sign In they just sign in; with a password, a single-use claim link prints in the deploy logs on first boot |
|
|
64
|
+
| `MARVER_CLI_TOKEN` | generated 32+ char secret (`openssl rand -hex 24`) that lets this repo run `comments invite`/`revoke`/`sync` as the owner. Required with `MARVER_ID_ISSUER` - identity accounts have no password for the CLI to use |
|
|
41
65
|
| `MARVER_TRUSTED_PROXY` | set to `1` behind a reverse proxy (Railway, Fly) so rate limits see real IPs |
|
|
42
66
|
|
|
43
67
|
## The host contract (works on any volume-capable host)
|
|
@@ -71,10 +95,17 @@ Then, once per service:
|
|
|
71
95
|
```bash
|
|
72
96
|
railway init # or `railway link` an existing service
|
|
73
97
|
railway volume add --mount-path /data
|
|
74
|
-
|
|
75
|
-
|
|
98
|
+
# The default gate: people sign in as themselves.
|
|
99
|
+
railway variables --set MARVER_ID_ISSUER=https://id.marver.design \
|
|
100
|
+
--set MARVER_PUBLIC_ORIGIN=https://<the deployed url> --set MARVER_DATA_DIR=/data \
|
|
101
|
+
--set MARVER_OWNER_EMAIL=<owner@email> --set MARVER_TRUSTED_PROXY=1 \
|
|
102
|
+
--set MARVER_CLI_TOKEN="$(openssl rand -hex 24)"
|
|
103
|
+
# ...or the sovereign alternative, if this canvas must depend on nothing external:
|
|
104
|
+
# --set MARVER_PASSWORD=<pw> (instead of MARVER_ID_ISSUER/MARVER_PUBLIC_ORIGIN)
|
|
76
105
|
railway up # uploads the repo; Railway runs build then start
|
|
77
|
-
railway logs #
|
|
106
|
+
railway logs # with a PASSWORD gate, the owner claim link prints
|
|
107
|
+
# here once. With Marver Sign In there is no link -
|
|
108
|
+
# the owner simply signs in. See below.
|
|
78
109
|
```
|
|
79
110
|
|
|
80
111
|
Republishing is just `railway up` again: the server unions the seeded logs on
|
|
@@ -83,7 +114,12 @@ the event log is single-writer by design.
|
|
|
83
114
|
|
|
84
115
|
## What the deployed gate offers (so you know what you're wiring)
|
|
85
116
|
|
|
86
|
-
|
|
117
|
+
**With `MARVER_ID_ISSUER`** there is one door: people sign in at
|
|
118
|
+
id.marver.design with Google or an emailed code, come back, and are let in if
|
|
119
|
+
the owner's list has their address. Nobody types a canvas password because there
|
|
120
|
+
isn't one. An address that is not on the list is refused by name, on screen.
|
|
121
|
+
|
|
122
|
+
**With `MARVER_PASSWORD`** the gate has three doors, one credential each:
|
|
87
123
|
- **Guest** - the canvas password → read-only across published boards.
|
|
88
124
|
- **Member** - "Sign in instead" → their own email + password → read + comment.
|
|
89
125
|
A member session IS gate passage; they never touch the shared password again.
|
|
@@ -92,9 +128,51 @@ On a collaboration canvas the gate has three doors, one credential each:
|
|
|
92
128
|
|
|
93
129
|
## Wiring people up (after first deploy)
|
|
94
130
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
131
|
+
**With Marver Sign In**, the owner just signs in - `MARVER_OWNER_EMAIL` claims an
|
|
132
|
+
empty canvas for that address, with no token to pass around.
|
|
133
|
+
|
|
134
|
+
**To manage people from this machine, the canvas needs `MARVER_CLI_TOKEN`.**
|
|
135
|
+
`comments invite`, `revoke` and `sync` sign the CLI in with a password, and
|
|
136
|
+
identity mode has none. Generate a secret - do not choose one, nothing
|
|
137
|
+
rate-limits this and nothing slows a guess down - set it on the canvas, and hand
|
|
138
|
+
the same value back:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
# on the canvas (deployment environment): MARVER_CLI_TOKEN=$(openssl rand -hex 24)
|
|
142
|
+
MARVER_CLI_TOKEN='<that same value>' marver comments connect https://canvas.example.com
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Hex, not base64: an `Authorization` header carries letters, digits, `_` and `-`,
|
|
146
|
+
and the canvas refuses to start on a value it could never accept. `--token` works
|
|
147
|
+
too, but a value on the command line is visible to anything that can list
|
|
148
|
+
processes, so prefer the variable.
|
|
149
|
+
|
|
150
|
+
It acts as whoever owns the canvas, so let the owner sign in once first - until
|
|
151
|
+
somebody owns it there is nobody for the token to be. `connect` trades it for an
|
|
152
|
+
ordinary session and stores THAT, so the secret never lands in your repo.
|
|
153
|
+
Rotating `MARVER_CLI_TOKEN` ends every session it minted.
|
|
154
|
+
|
|
155
|
+
Then invite people by address, and send them nothing:
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
marver comments invite colleague@company.com
|
|
159
|
+
# → in identity mode the ADDRESS is the invitation. They open the canvas URL,
|
|
160
|
+
# sign in as themselves, and the invite is spent by that sign-in. There is
|
|
161
|
+
# no link to forward and no canvas password to send with it - the claim link
|
|
162
|
+
# is deliberately shut off in identity mode, so do not go looking for one.
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
It is a deployment variable and not something a page hands out, because authored
|
|
166
|
+
frames run same-origin in a canvas: frame code can read `mv_c` and ride the
|
|
167
|
+
viewer's session, so anything a browser can mint, a frame can mint silently. A
|
|
168
|
+
browser-approved device flow was built for this job and removed before release for
|
|
169
|
+
that reason. Do not reintroduce one, and do not invent a workaround that posts to
|
|
170
|
+
internal endpoints.
|
|
171
|
+
|
|
172
|
+
**With a canvas password**, `MARVER_OWNER_EMAIL` makes the first boot print an
|
|
173
|
+
owner bootstrap in the logs: a browser link (`<url>/#/i/<token>`) AND the exact
|
|
174
|
+
repo command. The token is single-use. Claim it from the repo so this machine can
|
|
175
|
+
mint invites:
|
|
98
176
|
|
|
99
177
|
```bash
|
|
100
178
|
# 1. claim the owner account - copy the command the deploy logs printed:
|
|
@@ -124,6 +202,11 @@ through sign-in.
|
|
|
124
202
|
- `<MARVER_DATA_DIR>/comments/<board>.jsonl` - the live event log, on the volume.
|
|
125
203
|
- `<MARVER_DATA_DIR>/auth.json` - accounts (scrypt), sessions, invites, on the volume.
|
|
126
204
|
- `design/comments/<board>.jsonl` - the dev-side mirror, git-tracked: feedback
|
|
127
|
-
has history, and the volume has an off-site replica for free.
|
|
128
|
-
|
|
205
|
+
has history, and the volume has an off-site replica for free. **Each event
|
|
206
|
+
carries its author's email address** - that is how the canvas decides who may
|
|
207
|
+
edit or resolve their own thread. Harmless in a private repo; if yours is
|
|
208
|
+
public, or may become public, gitignore `design/comments/` and let the volume
|
|
209
|
+
be the record. The canvas keeps its own copy either way, so nothing is lost.
|
|
210
|
+
- `~/.marver/canvases/<project-hash>.json` - THIS machine's device credential, kept
|
|
211
|
+
OUTSIDE the repo because `marver dev` serves the repo;
|
|
129
212
|
never commit it.
|
package/dist/auth-KQ9Aj-nB.mjs
DELETED
|
@@ -1,245 +0,0 @@
|
|
|
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 - 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 };
|
package/dist/events-BMtBvvgU.mjs
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
//#region src/shared/events.ts
|
|
2
|
-
/** Events the other side lacks, by id - the sync payload in either direction. */
|
|
3
|
-
function diffEvents(mine, theirIds) {
|
|
4
|
-
const have = new Set(theirIds);
|
|
5
|
-
return mine.filter((e) => !have.has(e.id));
|
|
6
|
-
}
|
|
7
|
-
/** Replay a board's events into current thread state. Deterministic: replay order is
|
|
8
|
-
* (ts, id) so two stores holding the same event SET always derive the same state.
|
|
9
|
-
* TWO passes - creates first, then everything else - so a reply whose author's clock
|
|
10
|
-
* ran ahead of the creator's still lands instead of being dropped forever. */
|
|
11
|
-
function replay(events) {
|
|
12
|
-
const ordered = [...events].sort((a, b) => a.ts - b.ts || (a.id < b.id ? -1 : 1));
|
|
13
|
-
const threads = /* @__PURE__ */ new Map();
|
|
14
|
-
for (const ev of ordered) {
|
|
15
|
-
if (ev.type !== "create") continue;
|
|
16
|
-
if (!ev.commentId || threads.has(ev.commentId)) continue;
|
|
17
|
-
threads.set(ev.commentId, {
|
|
18
|
-
id: ev.commentId,
|
|
19
|
-
board: ev.board,
|
|
20
|
-
nodeKey: ev.nodeKey,
|
|
21
|
-
frame: ev.frame,
|
|
22
|
-
anchor: ev.anchor,
|
|
23
|
-
author: ev.author,
|
|
24
|
-
body: ev.body,
|
|
25
|
-
ts: ev.ts,
|
|
26
|
-
resolved: false,
|
|
27
|
-
agent: ev.agent,
|
|
28
|
-
agentMeta: ev.agentMeta,
|
|
29
|
-
replies: [],
|
|
30
|
-
reactions: {}
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
for (const ev of ordered) switch (ev.type) {
|
|
34
|
-
case "reply": {
|
|
35
|
-
const t = ev.parentId ? threads.get(ev.parentId) : void 0;
|
|
36
|
-
if (!t || !ev.commentId || t.replies.some((r) => r.id === ev.commentId)) break;
|
|
37
|
-
t.replies.push({
|
|
38
|
-
id: ev.commentId,
|
|
39
|
-
author: ev.author,
|
|
40
|
-
body: ev.body,
|
|
41
|
-
ts: ev.ts,
|
|
42
|
-
agent: ev.agent,
|
|
43
|
-
agentMeta: ev.agentMeta
|
|
44
|
-
});
|
|
45
|
-
break;
|
|
46
|
-
}
|
|
47
|
-
case "edit": {
|
|
48
|
-
if (!ev.commentId || ev.body === void 0) break;
|
|
49
|
-
const t = threads.get(ev.commentId);
|
|
50
|
-
if (t) {
|
|
51
|
-
t.body = ev.body;
|
|
52
|
-
break;
|
|
53
|
-
}
|
|
54
|
-
for (const th of threads.values()) {
|
|
55
|
-
const r = th.replies.find((r) => r.id === ev.commentId);
|
|
56
|
-
if (r) {
|
|
57
|
-
r.body = ev.body;
|
|
58
|
-
break;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
break;
|
|
62
|
-
}
|
|
63
|
-
case "resolve": {
|
|
64
|
-
const t = ev.commentId ? threads.get(ev.commentId) : void 0;
|
|
65
|
-
if (t) {
|
|
66
|
-
t.resolved = true;
|
|
67
|
-
if (ev.addressedIn) t.addressedIn = ev.addressedIn;
|
|
68
|
-
}
|
|
69
|
-
break;
|
|
70
|
-
}
|
|
71
|
-
case "reopen": {
|
|
72
|
-
const t = ev.commentId ? threads.get(ev.commentId) : void 0;
|
|
73
|
-
if (t) {
|
|
74
|
-
t.resolved = false;
|
|
75
|
-
t.addressedIn = void 0;
|
|
76
|
-
}
|
|
77
|
-
break;
|
|
78
|
-
}
|
|
79
|
-
case "reanchor": {
|
|
80
|
-
const t = ev.commentId ? threads.get(ev.commentId) : void 0;
|
|
81
|
-
if (t && ev.anchor != null) t.anchor = ev.anchor;
|
|
82
|
-
break;
|
|
83
|
-
}
|
|
84
|
-
case "react": {
|
|
85
|
-
const t = ev.commentId ? threads.get(ev.commentId) : void 0;
|
|
86
|
-
const who = ev.author?.email;
|
|
87
|
-
if (!t || !who || !ev.emoji) break;
|
|
88
|
-
const users = t.reactions[ev.emoji] ??= [];
|
|
89
|
-
const at = users.indexOf(who);
|
|
90
|
-
if (at === -1) users.push(who);
|
|
91
|
-
else {
|
|
92
|
-
users.splice(at, 1);
|
|
93
|
-
if (!users.length) delete t.reactions[ev.emoji];
|
|
94
|
-
}
|
|
95
|
-
break;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return [...threads.values()];
|
|
99
|
-
}
|
|
100
|
-
//#endregion
|
|
101
|
-
export { replay as n, diffEvents as t };
|
package/dist/sync-BJKKmy1n.mjs
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import { t as diffEvents } from "./events-BMtBvvgU.mjs";
|
|
2
|
-
import { appendEvents, listBoards, readLog } from "./comments-BZBKhKRO.mjs";
|
|
3
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
//#region src/server/sync.ts
|
|
6
|
-
/**
|
|
7
|
-
* Dev ↔ published comment sync - one merge rule, run from the dev side.
|
|
8
|
-
*
|
|
9
|
-
* The published serve is the canonical home; the repo's design/comments/ is the
|
|
10
|
-
* mirror the agent reads. Each exchange is: pull remote events → union into local
|
|
11
|
-
* files; push the local events the remote lacks (chunked - the server unions too).
|
|
12
|
-
* Idempotent and retry-safe by construction, so a dropped exchange costs nothing.
|
|
13
|
-
*
|
|
14
|
-
* Credentials: design/.local/collab.json {url, token} written by `comments connect`.
|
|
15
|
-
* The token is a server session held by the DEV PROCESS only - pages never see it.
|
|
16
|
-
*/
|
|
17
|
-
const collabFile = (root) => join(root, "design", ".local", "collab.json");
|
|
18
|
-
function loadCollab(root) {
|
|
19
|
-
try {
|
|
20
|
-
const c = JSON.parse(readFileSync(collabFile(root), "utf8"));
|
|
21
|
-
return typeof c?.url === "string" && typeof c?.token === "string" ? c : null;
|
|
22
|
-
} catch {
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
function saveCollab(root, collab) {
|
|
27
|
-
mkdirSync(join(root, "design", ".local"), { recursive: true });
|
|
28
|
-
writeFileSync(collabFile(root), JSON.stringify(collab, null, 2) + "\n", { mode: 384 });
|
|
29
|
-
}
|
|
30
|
-
/** One full exchange. Returns per-board counts, or throws on auth/network failure. */
|
|
31
|
-
async function syncOnce(root, collab) {
|
|
32
|
-
const dir = join(root, "design", "comments");
|
|
33
|
-
const auth = { authorization: `Bearer ${collab.token}` };
|
|
34
|
-
const base = collab.url.replace(/\/+$/, "");
|
|
35
|
-
const bres = await fetch(`${base}/__mv/api/boards`, { headers: auth });
|
|
36
|
-
if (bres.status === 401) throw new Error("the connect token was rejected - run `comments connect` again");
|
|
37
|
-
if (!bres.ok) throw new Error(`published canvas answered ${bres.status} - is it up?`);
|
|
38
|
-
const { rights } = await bres.json();
|
|
39
|
-
const boards = [.../* @__PURE__ */ new Set([...Object.keys(rights), ...listBoards(dir)])];
|
|
40
|
-
const out = {};
|
|
41
|
-
const failures = [];
|
|
42
|
-
for (const board of boards) {
|
|
43
|
-
if (!(board in rights)) continue;
|
|
44
|
-
const res = await fetch(`${base}/__mv/api/comments/${board}`, { headers: auth });
|
|
45
|
-
if (!res.ok) {
|
|
46
|
-
failures.push(`${board}: pull ${res.status}`);
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
const { events: remote } = await res.json();
|
|
50
|
-
const pulled = appendEvents(dir, board, remote).length;
|
|
51
|
-
let pushed = 0;
|
|
52
|
-
if (rights[board] === "comment") {
|
|
53
|
-
const missing = diffEvents(readLog(dir, board), remote.map((e) => e.id)).filter((e) => !e.agent);
|
|
54
|
-
for (let i = 0; i < missing.length; i += 100) {
|
|
55
|
-
const r = await fetch(`${base}/__mv/api/comments/${board}`, {
|
|
56
|
-
method: "POST",
|
|
57
|
-
headers: {
|
|
58
|
-
...auth,
|
|
59
|
-
"content-type": "application/json"
|
|
60
|
-
},
|
|
61
|
-
body: JSON.stringify({ events: missing.slice(i, i + 100) })
|
|
62
|
-
});
|
|
63
|
-
if (r.ok) pushed += (await r.json()).accepted ?? 0;
|
|
64
|
-
else failures.push(`${board}: push ${r.status} ${(await r.json().catch(() => null))?.error ?? ""}`.trim());
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
out[board] = {
|
|
68
|
-
pulled,
|
|
69
|
-
pushed
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
if (failures.length) throw new Error(`sync incomplete - ${failures.join(" · ")}`);
|
|
73
|
-
return out;
|
|
74
|
-
}
|
|
75
|
-
/** The account endpoints live BEHIND the canvas gate (the outer READ boundary), so
|
|
76
|
-
* connecting passes the gate first when a canvas password is set. */
|
|
77
|
-
async function gateCookie(base, canvasPassword) {
|
|
78
|
-
if (!canvasPassword) return "";
|
|
79
|
-
const cookie = (await fetch(`${base}/__mv/auth`, {
|
|
80
|
-
method: "POST",
|
|
81
|
-
redirect: "manual",
|
|
82
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
83
|
-
body: `password=${encodeURIComponent(canvasPassword)}&next=`
|
|
84
|
-
})).headers.getSetCookie?.().find((c) => c.startsWith("mv_a=")) ?? "";
|
|
85
|
-
const tok = /^mv_a=([^;]+)/.exec(cookie)?.[1];
|
|
86
|
-
if (!tok) throw new Error("the canvas password was not accepted");
|
|
87
|
-
return `mv_a=${tok}`;
|
|
88
|
-
}
|
|
89
|
-
async function sessionFrom(res, ctx) {
|
|
90
|
-
if (!res.ok) {
|
|
91
|
-
const err = (await res.json().catch(() => null))?.error;
|
|
92
|
-
throw new Error(err ?? `${ctx} failed (${res.status})`);
|
|
93
|
-
}
|
|
94
|
-
const cookie = res.headers.getSetCookie?.().find((c) => c.startsWith("mv_s=")) ?? "";
|
|
95
|
-
const token = /^mv_s=([\w-]+)/.exec(cookie)?.[1];
|
|
96
|
-
if (!token) throw new Error("the canvas did not issue a session - is collaboration enabled on it (MARVER_DATA_DIR)?");
|
|
97
|
-
return token;
|
|
98
|
-
}
|
|
99
|
-
/** Sign in against a published canvas and persist the device credential. The account's
|
|
100
|
-
* identity rides along: locally-born events carry it as their author snapshot (the
|
|
101
|
-
* server validates the claim against the session - it never rewrites events). */
|
|
102
|
-
async function connect(root, url, email, password, canvasPassword) {
|
|
103
|
-
const base = url.replace(/\/+$/, "");
|
|
104
|
-
const gate = await gateCookie(base, canvasPassword);
|
|
105
|
-
const res = await fetch(`${base}/__mv/api/auth/signin`, {
|
|
106
|
-
method: "POST",
|
|
107
|
-
headers: {
|
|
108
|
-
"content-type": "application/json",
|
|
109
|
-
...gate ? { cookie: gate } : {}
|
|
110
|
-
},
|
|
111
|
-
body: JSON.stringify({
|
|
112
|
-
email,
|
|
113
|
-
password
|
|
114
|
-
})
|
|
115
|
-
});
|
|
116
|
-
const token = await sessionFrom(res, "sign-in");
|
|
117
|
-
const user = (await res.clone().json().catch(() => null))?.user;
|
|
118
|
-
saveCollab(root, {
|
|
119
|
-
url: base,
|
|
120
|
-
token,
|
|
121
|
-
email: user?.email ?? email,
|
|
122
|
-
name: user?.name
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
/** Claim an invite from the CLI (the dev-first path - no published UI needed). */
|
|
126
|
-
async function connectClaim(root, url, invite, profile, canvasPassword) {
|
|
127
|
-
const base = url.replace(/\/+$/, "");
|
|
128
|
-
const gate = await gateCookie(base, canvasPassword);
|
|
129
|
-
const res = await fetch(`${base}/__mv/api/auth/claim`, {
|
|
130
|
-
method: "POST",
|
|
131
|
-
headers: {
|
|
132
|
-
"content-type": "application/json",
|
|
133
|
-
...gate ? { cookie: gate } : {}
|
|
134
|
-
},
|
|
135
|
-
body: JSON.stringify({
|
|
136
|
-
token: invite,
|
|
137
|
-
...profile
|
|
138
|
-
})
|
|
139
|
-
});
|
|
140
|
-
const token = await sessionFrom(res, "claim");
|
|
141
|
-
const user = (await res.clone().json().catch(() => null))?.user;
|
|
142
|
-
saveCollab(root, {
|
|
143
|
-
url: base,
|
|
144
|
-
token,
|
|
145
|
-
email: user?.email,
|
|
146
|
-
name: user?.name ?? profile.name
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
//#endregion
|
|
150
|
-
export { connect, connectClaim, loadCollab, syncOnce };
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { i as ROUTE } from "./cli.mjs";
|
|
2
2
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
5
4
|
import { tmpdir } from "node:os";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
6
|
//#region src/server/shot.ts
|
|
7
7
|
/**
|
|
8
8
|
* Frame screenshots without a dependency - the system's own Chrome, driven over CDP
|