@marver-design/marver 0.10.2 → 0.11.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 +143 -0
- package/dist/auth-B5yuwnOq.mjs +494 -0
- package/dist/{build-zUf_kwKp.mjs → build-Cr_ZLMdq.mjs} +3 -2
- package/dist/cli.mjs +6 -6
- package/dist/{collab-s3k5byM1.mjs → collab-C-n-uQdm.mjs} +67 -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-CLBjRs0J.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-vE7sxEeT.mjs} +12 -84
- package/dist/{profile-BkiWglVE.mjs → profile-DcsJyppw.mjs} +9 -6
- package/dist/{serve-CZqPnj19.mjs → serve-CZ1KEQxd.mjs} +194 -42
- 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 +84 -15
- 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
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
|