@marver-design/marver 0.11.0 → 0.12.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +83 -5
  2. package/README.md +17 -4
  3. package/dist/auth-DdUKeVKt.mjs +1150 -0
  4. package/dist/{build-Cr_ZLMdq.mjs → build-DmfxJc6L.mjs} +188 -33
  5. package/dist/cli.mjs +16 -7
  6. package/dist/collab-BmaOjwP1.mjs +781 -0
  7. package/dist/{comments-CRqaO2MH.mjs → comments-pNxjTrGN.mjs} +3 -2
  8. package/dist/{daemon-DSmaS453.mjs → daemon-WjonKlPz.mjs} +3 -3
  9. package/dist/{dev-CLBjRs0J.mjs → dev-BXq7yE1p.mjs} +5 -5
  10. package/dist/events-C9k-ceOj.mjs +101 -0
  11. package/dist/{init-BSclDg4I.mjs → init-BpitOqRQ.mjs} +2 -3
  12. package/dist/{config-t9coJ-Pq.mjs → manifest-CS6krOTe.mjs} +236 -4
  13. package/dist/{marver-id-B8-3WiHk.mjs → marver-id-CVCoaMGI.mjs} +106 -1
  14. package/dist/{marver-id-gate-BDzW6ahN.mjs → marver-id-gate-DOjnZakC.mjs} +94 -6
  15. package/dist/{plugin-vE7sxEeT.mjs → plugin-Boy7I_JH.mjs} +82 -9
  16. package/dist/{profile-DcsJyppw.mjs → profile-ChpTPd-X.mjs} +1 -1
  17. package/dist/secure-cookie-_K1Hsx8H.mjs +26 -0
  18. package/dist/{serve-CZ1KEQxd.mjs → serve-BeLLizNg.mjs} +72 -18
  19. package/dist/share-C64b1tdN.mjs +159 -0
  20. package/dist/summary-Bf9tiGrE.mjs +211 -0
  21. package/dist/{sync-DELGomPk.mjs → sync-WYxDN9IT.mjs} +3 -102
  22. package/package.json +1 -1
  23. package/src/client/shell/App.tsx +43 -8
  24. package/src/client/shell/Comments.tsx +9 -5
  25. package/src/client/shell/LockedApp.tsx +113 -0
  26. package/src/client/shell/Play.tsx +97 -18
  27. package/src/client/shell/Toolbar.tsx +26 -2
  28. package/src/client/shell/canvas/Canvas.tsx +5 -15
  29. package/src/client/shell/canvas/ctl.ts +26 -0
  30. package/src/client/shell/comments-store.ts +39 -9
  31. package/src/client/shell/hash.ts +19 -0
  32. package/src/client/shell/icons.tsx +1 -0
  33. package/src/client/shell/store.ts +54 -3
  34. package/src/client/shell/styles.css +27 -0
  35. package/src/shared/events.ts +8 -3
  36. package/templates/instructions/publish.md +15 -1
  37. package/dist/auth-B5yuwnOq.mjs +0 -494
  38. package/dist/collab-C-n-uQdm.mjs +0 -364
  39. package/dist/manifest-C2tzkNaC.mjs +0 -233
  40. package/dist/update-DuWDj5nR.mjs +0 -77
  41. package/dist/utm-CxC3QN5X.mjs +0 -20
@@ -152,6 +152,16 @@ somebody owns it there is nobody for the token to be. `connect` trades it for an
152
152
  ordinary session and stores THAT, so the secret never lands in your repo.
153
153
  Rotating `MARVER_CLI_TOKEN` ends every session it minted.
154
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
+
155
165
  It is a deployment variable and not something a page hands out, because authored
156
166
  frames run same-origin in a canvas: frame code can read `mv_c` and ride the
157
167
  viewer's session, so anything a browser can mint, a frame can mint silently. A
@@ -192,7 +202,11 @@ through sign-in.
192
202
  - `<MARVER_DATA_DIR>/comments/<board>.jsonl` - the live event log, on the volume.
193
203
  - `<MARVER_DATA_DIR>/auth.json` - accounts (scrypt), sessions, invites, on the volume.
194
204
  - `design/comments/<board>.jsonl` - the dev-side mirror, git-tracked: feedback
195
- has history, and the volume has an off-site replica for free.
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.
196
210
  - `~/.marver/canvases/<project-hash>.json` - THIS machine's device credential, kept
197
211
  OUTSIDE the repo because `marver dev` serves the repo;
198
212
  never commit it.
@@ -1,494 +0,0 @@
1
- import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
- import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
5
- //#region src/server/auth.ts
6
- /**
7
- * Accounts, invites, sessions - the minimal credible implementation,
8
- * extending the gate's own idiom. No framework: per-user salted scrypt verifiers,
9
- * opaque session tokens stored hashed, single-use invite links as the identity
10
- * bootstrap (no email infrastructure anywhere).
11
- *
12
- * Password = read, account = comment: everything here concerns accounts only; the
13
- * shared gate password (serve.ts) remains the outer READ boundary.
14
- *
15
- * State lives in MARVER_DATA_DIR as two small JSON files rewritten atomically -
16
- * users change rarely; the event-log treatment is reserved for comments.
17
- */
18
- var auth_exports = /* @__PURE__ */ __exportAll({
19
- CLI_TOKEN_CHARS: () => CLI_TOKEN_CHARS,
20
- MIN_CLI_TOKEN: () => 32,
21
- attachAvatar: () => attachAvatar,
22
- avatarSourceFor: () => avatarSourceFor,
23
- claimInvite: () => claimInvite,
24
- cliTokenProblem: () => cliTokenProblem,
25
- createInvite: () => createInvite,
26
- inviteInfo: () => inviteInfo,
27
- issueDeviceSession: () => issueDeviceSession,
28
- loadStore: () => loadStore,
29
- normEmail: () => normEmail,
30
- operatorUser: () => operatorUser,
31
- ownerName: () => ownerName,
32
- provisionFromMarverId: () => provisionFromMarverId,
33
- publicUser: () => publicUser,
34
- revokeUser: () => revokeUser,
35
- sessionUser: () => sessionUser,
36
- signIn: () => signIn,
37
- signOut: () => signOut,
38
- updateProfile: () => updateProfile
39
- });
40
- const SCRYPT = {
41
- N: 2 ** 15,
42
- r: 8,
43
- p: 1,
44
- keylen: 32,
45
- maxmem: 67108864
46
- };
47
- const INVITE_TTL = 6048e5;
48
- const SESSION_TTL = 2592e6;
49
- const normEmail = (e) => e.trim().toLowerCase();
50
- const sha256 = (s) => createHash("sha256").update(s).digest("hex");
51
- const token = () => randomBytes(32).toString("base64url");
52
- const storeFile = (dir) => join(dir, "auth.json");
53
- /** Only a MISSING file is an empty store. A present-but-unreadable/corrupt auth.json
54
- * must fail CLOSED - treating it as empty would let the owner bootstrap re-run and
55
- * a later save overwrite every account. */
56
- function loadStore(dir) {
57
- let raw;
58
- try {
59
- raw = readFileSync(storeFile(dir), "utf8");
60
- } catch (err) {
61
- if (err.code === "ENOENT") return {
62
- users: [],
63
- invites: [],
64
- sessions: []
65
- };
66
- throw new Error(`auth store unreadable (${err.message}) - refusing to treat it as empty`);
67
- }
68
- let parsed;
69
- try {
70
- parsed = JSON.parse(raw);
71
- } catch {
72
- throw new Error("auth store is corrupt JSON - refusing to treat it as empty. Restore it or delete it deliberately.");
73
- }
74
- 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");
75
- return parsed;
76
- }
77
- /** Atomic rewrite (tmp + rename) - a crash mid-write must never lose every account.
78
- * 0600 throughout: the store holds emails and password verifiers. */
79
- function saveStore(dir, store) {
80
- const file = storeFile(dir);
81
- mkdirSync(dirname(file), { recursive: true });
82
- const now = Date.now();
83
- store.invites = store.invites.filter((i) => i.exp > now);
84
- store.sessions = store.sessions.filter((s) => s.exp > now);
85
- const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
86
- const fd = openSync(tmp, "wx", 384);
87
- try {
88
- writeSync(fd, JSON.stringify(store, null, 2));
89
- fsyncSync(fd);
90
- } finally {
91
- closeSync(fd);
92
- }
93
- renameSync(tmp, file);
94
- }
95
- /** Cross-process mutex over auth.json's read-modify-write. Within one Node process
96
- * the store mutations are already synchronous and atomic; this covers deploy overlap
97
- * and any accidental multi-instance run (the supported setup is single-instance) -
98
- * without it, a sign-in that loaded a pre-revoke snapshot could rename it back over a
99
- * successful revoke. A crashed holder's lock is stolen after 10s; waiting past 5s
100
- * fails loudly rather than hanging. */
101
- function withLock(dir, fn) {
102
- mkdirSync(dir, { recursive: true });
103
- const lock = join(dir, ".auth.lock");
104
- const nap = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
105
- const deadline = Date.now() + 5e3;
106
- for (;;) try {
107
- closeSync(openSync(lock, "wx"));
108
- break;
109
- } catch (e) {
110
- if (e.code !== "EEXIST") throw e;
111
- try {
112
- if (Date.now() - statSync(lock).mtimeMs > 1e4) {
113
- unlinkSync(lock);
114
- continue;
115
- }
116
- } catch {
117
- continue;
118
- }
119
- if (Date.now() > deadline) throw new Error("auth store is busy - please retry");
120
- nap(25);
121
- }
122
- try {
123
- return fn();
124
- } finally {
125
- try {
126
- unlinkSync(lock);
127
- } catch {}
128
- }
129
- }
130
- const findUser = (store, email) => store.users.find((u) => normEmail(u.email) === normEmail(email));
131
- /** Mint a single-use invite for an email (this IS the allowlist entry - inviting an
132
- * address authorizes it). Re-inviting an email replaces its pending invite. The raw
133
- * token is returned exactly once; only its hash is stored. */
134
- /** The owner's display name - public-safe (never the email). Null before claim. */
135
- function ownerName(dir) {
136
- return loadStore(dir).users.find((u) => u.role === "owner")?.name?.trim() || null;
137
- }
138
- /** Peek at a live invite: the claim screens show WHO the invite is for. The raw
139
- * token is the proof - holding it means the owner sent it to you. */
140
- function inviteInfo(dir, rawToken) {
141
- const hash = sha256(rawToken);
142
- const invite = loadStore(dir).invites.find((i) => i.tokenHash === hash && i.exp > Date.now());
143
- return invite ? { email: invite.emailNorm } : null;
144
- }
145
- function createInvite(dir, email) {
146
- return withLock(dir, () => {
147
- const store = loadStore(dir);
148
- if (findUser(store, email)) throw new Error(`${normEmail(email)} already has an account`);
149
- const raw = token();
150
- const exp = Date.now() + INVITE_TTL;
151
- store.invites = store.invites.filter((i) => i.emailNorm !== normEmail(email));
152
- store.invites.push({
153
- emailNorm: normEmail(email),
154
- tokenHash: sha256(raw),
155
- exp
156
- });
157
- saveStore(dir, store);
158
- return {
159
- token: raw,
160
- exp
161
- };
162
- });
163
- }
164
- /** Claim an invite: burns it, creates the account, opens the first session. */
165
- function claimInvite(dir, rawToken, profile) {
166
- if (profile.password.length < 8) throw new Error("password must be at least 8 characters");
167
- if (!profile.name.trim()) throw new Error("a display name is required");
168
- return withLock(dir, () => {
169
- const store = loadStore(dir);
170
- const hash = sha256(rawToken);
171
- const invite = store.invites.find((i) => i.tokenHash === hash && i.exp > Date.now());
172
- if (!invite) throw new Error("this invite link is invalid, expired, or already used");
173
- if (findUser(store, invite.emailNorm)) {
174
- store.invites = store.invites.filter((i) => i !== invite);
175
- saveStore(dir, store);
176
- throw new Error("an account already exists for this address - sign in instead");
177
- }
178
- store.invites = store.invites.filter((i) => i !== invite);
179
- const salt = randomBytes(16).toString("hex");
180
- const user = {
181
- email: invite.emailNorm,
182
- name: profile.name.trim(),
183
- avatar: profile.avatar,
184
- role: store.users.length ? "member" : "owner",
185
- salt,
186
- hash: scryptSync(profile.password, Buffer.from(salt, "hex"), SCRYPT.keylen, SCRYPT).toString("hex"),
187
- params: SCRYPT,
188
- createdAt: Date.now()
189
- };
190
- store.users.push(user);
191
- const session = pushSession(store, user);
192
- saveStore(dir, store);
193
- return {
194
- user,
195
- session
196
- };
197
- });
198
- }
199
- /** Password sign-in. One generic failure - never reveal whether the email exists. */
200
- function signIn(dir, email, password) {
201
- return withLock(dir, () => {
202
- const store = loadStore(dir);
203
- const found = findUser(store, email);
204
- const user = found && found.auth !== "marver-id" && found.salt && found.hash ? found : null;
205
- const salt = user ? Buffer.from(user.salt, "hex") : randomBytes(16);
206
- const params = user?.params ?? SCRYPT;
207
- const got = scryptSync(password, salt, params.keylen, params);
208
- const want = user ? Buffer.from(user.hash, "hex") : randomBytes(SCRYPT.keylen);
209
- if (!user || got.length !== want.length || !timingSafeEqual(got, want)) return null;
210
- const session = pushSession(store, user);
211
- saveStore(dir, store);
212
- return {
213
- user,
214
- session
215
- };
216
- });
217
- }
218
- /**
219
- * Turn a verified Marver ID identity into a local session.
220
- *
221
- * The identity service has already proved who this person is; this function
222
- * decides whether they may in - and that decision is LOCAL, which is the whole
223
- * shape of L1a. The identity service knows nothing about who is allowed where.
224
- *
225
- * `allowed` is the owner's allowlist. An email that is not on it gets no account
226
- * and no session: being able to prove you are someone is not the same as being
227
- * invited. The first allowed account to arrive owns the canvas, matching the
228
- * invite flow's rule.
229
- *
230
- * No password is fabricated. A marver-id account carries no salt or hash at all,
231
- * so there is no second, weaker door into it.
232
- */
233
- function provisionFromMarverId(dir, identity, opts = {}) {
234
- const emailNorm = normEmail(identity.email);
235
- if (!emailNorm) return null;
236
- return withLock(dir, () => {
237
- const store = loadStore(dir);
238
- const qualified = `${identity.issuer}#${identity.subject}`;
239
- const bound = store.users.find((u) => u.idSubject === qualified);
240
- const existing = bound ?? findUser(store, emailNorm);
241
- if (bound && normEmail(bound.email) !== emailNorm) {
242
- const clash = findUser(store, emailNorm);
243
- if (clash && clash !== bound) return null;
244
- const vacated = normEmail(bound.email);
245
- bound.email = emailNorm;
246
- store.sessions = store.sessions.filter((s) => s.emailNorm !== vacated);
247
- }
248
- const invite = store.invites.find((i) => i.emailNorm === emailNorm && i.exp > Date.now());
249
- const ownerNorm = opts.ownerEmail ? normEmail(opts.ownerEmail) : "";
250
- const reservedForOwner = !!ownerNorm && !store.users.length && ownerNorm !== emailNorm;
251
- const isBootstrapOwner = !!ownerNorm && !store.users.length && ownerNorm === emailNorm;
252
- if (reservedForOwner) return null;
253
- if (!existing && !invite && !isBootstrapOwner) return null;
254
- if (invite) store.invites = store.invites.filter((i) => i !== invite);
255
- let user = existing;
256
- if (user) {
257
- if (!user.idSubject) user.idSubject = qualified;
258
- else if (user.idSubject === identity.subject) user.idSubject = qualified;
259
- else if (user.idSubject !== qualified) return null;
260
- } else {
261
- user = {
262
- email: emailNorm,
263
- name: identity.name || emailNorm.split("@")[0] || emailNorm,
264
- role: store.users.length ? "member" : "owner",
265
- auth: "marver-id",
266
- idSubject: `${identity.issuer}#${identity.subject}`,
267
- createdAt: Date.now()
268
- };
269
- store.users.push(user);
270
- }
271
- if (identity.name && (!user.name || user.name === emailNorm.split("@")[0])) user.name = identity.name;
272
- const session = pushSession(store, user);
273
- saveStore(dir, store);
274
- return {
275
- user,
276
- session
277
- };
278
- });
279
- }
280
- /**
281
- * Attach a picture the identity service supplied.
282
- *
283
- * Separate from provisioning because the fetch happens after admission and
284
- * outside the lock, so by the time there are bytes the account already exists.
285
- *
286
- * `avatarSource` is what makes this safe to repeat: its presence means "this
287
- * picture came from the identity service", and only such a picture may be
288
- * replaced. An avatar with no source was chosen HERE, by the person, and the
289
- * identity service does not get to overwrite it - which is the same rule the
290
- * name follows.
291
- *
292
- * Replacing a rotated one is the point. Refusing to, as an earlier version did,
293
- * meant the stored source never caught up with the assertion, so every single
294
- * sign-in fetched the new picture and then threw it away.
295
- */
296
- function attachAvatar(dir, subjectQualified, avatar, source, expected) {
297
- withLock(dir, () => {
298
- const store = loadStore(dir);
299
- const user = store.users.find((u) => u.idSubject === subjectQualified);
300
- if (!user) return;
301
- if (user.avatar && !user.avatarSource) return;
302
- if (user.avatarSource !== expected) return;
303
- user.avatar = avatar;
304
- user.avatarSource = source;
305
- saveStore(dir, store);
306
- });
307
- }
308
- /**
309
- * Would a picture from the identity service actually be used?
310
- *
311
- * Read-only and outside the lock, so the gate can decide whether a network
312
- * fetch is worth making before it commits to one. Worst case it is wrong and we
313
- * fetch a picture that then gets discarded - which costs one request, versus
314
- * fetching an avatar on every single sign-in forever.
315
- */
316
- function avatarSourceFor(dir, subjectQualified, email, pictureUrl) {
317
- const store = loadStore(dir);
318
- const user = store.users.find((u) => u.idSubject === subjectQualified) ?? findUser(store, normEmail(email));
319
- if (!user) return {
320
- wanted: true,
321
- source: void 0
322
- };
323
- if (!user.avatar) return {
324
- wanted: true,
325
- source: user.avatarSource
326
- };
327
- if (!user.avatarSource) return {
328
- wanted: false,
329
- source: void 0
330
- };
331
- return {
332
- wanted: user.avatarSource !== pictureUrl,
333
- source: user.avatarSource
334
- };
335
- }
336
- function pushSession(store, user) {
337
- const raw = token();
338
- store.sessions.push({
339
- tokenHash: sha256(raw),
340
- emailNorm: normEmail(user.email),
341
- exp: Date.now() + SESSION_TTL
342
- });
343
- return raw;
344
- }
345
- function sessionUser(dir, rawToken) {
346
- const store = loadStore(dir);
347
- const hash = sha256(rawToken);
348
- const s = store.sessions.find((s) => s.tokenHash === hash && s.exp > Date.now());
349
- if (!s) return null;
350
- if (s.via && s.via !== operatorFingerprint()) return null;
351
- return store.users.find((u) => normEmail(u.email) === s.emailNorm) ?? null;
352
- }
353
- /** The alphabet a bearer token can actually travel in: both the gate and the API
354
- * parse `Authorization` with `[\w-]+`, so a secret containing anything else is
355
- * accepted at boot and then silently unusable. Checked HERE as well as at boot so
356
- * the two can never drift apart. */
357
- const CLI_TOKEN_CHARS = /^[\w-]+$/;
358
- /**
359
- * The operator's own credential, read from the deployment environment.
360
- *
361
- * `comments connect` authenticates with a password, and an identity account has
362
- * none by design - so on an identity-gated canvas the whole CLI surface (invite,
363
- * revoke, and the comment sync the agent loop runs on) had no reachable
364
- * credential. This is the door, and where it lives is the entire point.
365
- *
366
- * The obvious alternative - a page that mints a token for whoever is signed in -
367
- * is the device flow this project already built and pulled (2d0850c). Authored
368
- * frames run same-origin in a canvas: frame JavaScript reads `mv_c`, every
369
- * request it makes carries the viewer's session, so any browser-reachable way to
370
- * mint a durable token is a way for a frame to mint one silently and carry it
371
- * off. There is no header that separates a frame from its own origin.
372
- *
373
- * An environment variable is on the other side of that line. It is never sent to
374
- * a page, no frame can read it, and reaching it already means reaching the
375
- * deployment - at which point the canvas was never the weakest thing in the room.
376
- * The cost is honest: it is a static secret that rotates by redeploying, and it
377
- * acts as the owner, so it is an operator credential rather than a person's.
378
- */
379
- function operatorUser(dir, presented) {
380
- return operatorMatch(presented) ? ownerOf(dir) : null;
381
- }
382
- /** The owner's account, or null on a canvas nobody has claimed yet. Chosen by
383
- * stored ROLE, never by array order or by whatever MARVER_OWNER_EMAIL currently
384
- * says - that variable only ever nominated a bootstrap account, and honouring it
385
- * afterwards would let a changed environment repoint this at a different person. */
386
- const ownerOf = (dir) => loadStore(dir).users.find((u) => u.role === "owner") ?? null;
387
- /**
388
- * Why a configured `MARVER_CLI_TOKEN` is unusable, or null when it is fine.
389
- *
390
- * One function so that boot and the matcher can never disagree. They did: boot
391
- * trimmed the value before checking it while `comments connect` sent the shell's
392
- * value as-is, so `" abcd... "` started a canvas that then refused the operator's
393
- * own token with no explanation anywhere. Surrounding whitespace is now a refusal
394
- * rather than something quietly repaired on one side of the wire.
395
- */
396
- function cliTokenProblem(raw) {
397
- if (!raw) return null;
398
- if (raw !== raw.trim()) return "MARVER_CLI_TOKEN has whitespace around it - quote it, or drop the quotes that put it there";
399
- if (raw.length < 32) return `MARVER_CLI_TOKEN is too short to be a bearer credential (${raw.length} chars, needs 32)`;
400
- if (!CLI_TOKEN_CHARS.test(raw)) return "MARVER_CLI_TOKEN contains characters that cannot travel in an Authorization header";
401
- return null;
402
- }
403
- /** The configured secret, or '' when there is none worth honouring. Never trimmed
404
- * into shape: a value that needed trimming was refused at boot. */
405
- const operatorSecret = () => {
406
- const raw = process.env.MARVER_CLI_TOKEN ?? "";
407
- return raw && !cliTokenProblem(raw) ? raw : "";
408
- };
409
- /** A fingerprint of the current secret, so a session can record WHICH one minted
410
- * it. The hash, never the value: this is written to auth.json, and a store that
411
- * quietly contains the operator's credential is the thing being avoided. */
412
- const operatorFingerprint = () => {
413
- const secret = operatorSecret();
414
- return secret ? sha256(`cli-generation:${secret}`) : "";
415
- };
416
- function operatorMatch(presented) {
417
- const secret = operatorSecret();
418
- if (!secret || !presented) return false;
419
- return timingSafeEqual(Buffer.from(sha256(presented), "hex"), Buffer.from(sha256(secret), "hex"));
420
- }
421
- /**
422
- * Trade the operator's secret for an ordinary session, once, from the terminal.
423
- *
424
- * The secret itself must not become the thing a repo carries. `connect` persists
425
- * whatever it is given, and that file sits on a developer's disk for as long as
426
- * the project lasts - a non-expiring master key is the wrong shape for it. What comes back here expires, dies with
427
- * `comments revoke`, and can be replaced without touching the deployment.
428
- *
429
- * Only the operator secret opens this. Deliberately NOT any signed-in session:
430
- * authored frames run same-origin and ride the viewer's cookies, so a route that
431
- * minted sessions for whoever was signed in would be the pulled device flow
432
- * (2d0850c) with a different name. A frame cannot present this Bearer, because
433
- * the value it needs was never in the browser.
434
- */
435
- function issueDeviceSession(dir, presented) {
436
- if (!operatorMatch(presented)) return null;
437
- return withLock(dir, () => {
438
- const store = loadStore(dir);
439
- const user = store.users.find((u) => u.role === "owner");
440
- if (!user) return null;
441
- const token = pushSession(store, user);
442
- store.sessions[store.sessions.length - 1].via = operatorFingerprint();
443
- saveStore(dir, store);
444
- return {
445
- token,
446
- exp: Date.now() + SESSION_TTL,
447
- user
448
- };
449
- });
450
- }
451
- function signOut(dir, rawToken) {
452
- withLock(dir, () => {
453
- const store = loadStore(dir);
454
- const hash = sha256(rawToken);
455
- store.sessions = store.sessions.filter((s) => s.tokenHash !== hash);
456
- saveStore(dir, store);
457
- });
458
- }
459
- /** Update name/avatar on an existing account. */
460
- function updateProfile(dir, email, patch) {
461
- return withLock(dir, () => {
462
- const store = loadStore(dir);
463
- const user = findUser(store, email);
464
- if (!user) throw new Error("no such account");
465
- if (patch.name?.trim()) user.name = patch.name.trim();
466
- if (patch.avatar !== void 0) {
467
- user.avatar = patch.avatar || void 0;
468
- user.avatarSource = void 0;
469
- }
470
- saveStore(dir, store);
471
- return user;
472
- });
473
- }
474
- /** Remove an account and all its sessions (owner action). The LAST owner cannot be
475
- * removed - a store with members but no owner has no one left to administer it,
476
- * and bootstrap will not re-run while any user exists. */
477
- function revokeUser(dir, email) {
478
- withLock(dir, () => {
479
- const store = loadStore(dir);
480
- 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");
481
- store.users = store.users.filter((u) => normEmail(u.email) !== normEmail(email));
482
- store.sessions = store.sessions.filter((s) => s.emailNorm !== normEmail(email));
483
- store.invites = store.invites.filter((i) => i.emailNorm !== normEmail(email));
484
- saveStore(dir, store);
485
- });
486
- }
487
- /** The public shape of a user - what other viewers (and events) may see. */
488
- const publicUser = (u) => ({
489
- email: u.email,
490
- name: u.name,
491
- avatar: u.avatar
492
- });
493
- //#endregion
494
- export { claimInvite as a, issueDeviceSession as c, publicUser as d, revokeUser as f, updateProfile as g, signOut as h, avatarSourceFor as i, ownerName as l, signIn as m, attachAvatar as n, createInvite as o, sessionUser as p, auth_exports as r, inviteInfo as s, CLI_TOKEN_CHARS as t, provisionFromMarverId as u };