@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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,149 @@
|
|
|
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.11.0 - 2026-08-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Marver Sign In: a canvas can ask people who they are, instead of asking for a shared
|
|
10
|
+
password.** Set `MARVER_ID_ISSUER=https://id.marver.design` (plus `MARVER_PUBLIC_ORIGIN`
|
|
11
|
+
and `MARVER_DATA_DIR`) and the gate becomes an identity gate. People sign in once - with
|
|
12
|
+
Google, or a code emailed to them - and every canvas gated this way opens without another
|
|
13
|
+
password. Nobody types a canvas password, so there is none to leak, rotate, or forget, and
|
|
14
|
+
revoking one person revokes exactly them. This is the better default for a team, and the
|
|
15
|
+
one we run ourselves.
|
|
16
|
+
|
|
17
|
+
**Your guest list stays yours.** The identity service proves *who somebody is* and has no
|
|
18
|
+
say in *where they may go*: an address gets in if it already has an account on your canvas,
|
|
19
|
+
holds an unexpired invite, or is `MARVER_OWNER_EMAIL` on a canvas with no accounts yet.
|
|
20
|
+
That decision is made on your disk, and the identity service is never told the answer.
|
|
21
|
+
Somebody with a perfectly valid Marver account who is not on your list gets nothing.
|
|
22
|
+
|
|
23
|
+
**The sovereign path is unchanged and is not going anywhere.** `MARVER_PASSWORD` still
|
|
24
|
+
gates a canvas with accounts that live entirely in your `MARVER_DATA_DIR`, and a served
|
|
25
|
+
canvas with no `MARVER_ID_ISSUER` set makes no outbound request at all - nothing to depend
|
|
26
|
+
on, nothing to phone home to. (Stated precisely, because it is the kind of promise that
|
|
27
|
+
should be: `marver serve` is silent. `marver dev` still makes the one anonymous registry
|
|
28
|
+
request it always has, once a day, to notice a new version - `MARVER_NO_UPDATE_CHECK=1`
|
|
29
|
+
turns it off - and a workspace you have connected to a published canvas still syncs its
|
|
30
|
+
comments with that canvas.) The two are alternatives rather than layers: running both
|
|
31
|
+
would weaken your invite list to "an account OR whoever has the password", so the identity
|
|
32
|
+
gate replaces the password gate rather than sitting beside it.
|
|
33
|
+
|
|
34
|
+
How it works, for anyone who wants to check rather than trust: the canvas mints a
|
|
35
|
+
single-use nonce bound to the browser that started the sign-in, the tab visits the identity
|
|
36
|
+
service and comes back with a short-lived ES256 assertion in the URL fragment - the one
|
|
37
|
+
part of a link no server ever receives - and the canvas verifies it against published JWKS
|
|
38
|
+
before issuing its own ordinary session. The assertion's audience is the canvas's exact
|
|
39
|
+
origin, port included, so one minted for one canvas is inert at another. The verifying half
|
|
40
|
+
is dependency-free and lives in this repo (`src/server/marver-id.ts`), which is why
|
|
41
|
+
pointing a canvas at a different issuer is a configuration change rather than a fork.
|
|
42
|
+
|
|
43
|
+
`MARVER_PUBLIC_ORIGIN` is required and the canvas will not start without it, in
|
|
44
|
+
development too. The audience cannot be inferred safely: nginx's documented
|
|
45
|
+
`proxy_pass http://localhost:PORT` rewrites `Host` and adds no `X-Forwarded-*`, so a
|
|
46
|
+
request from the internet is indistinguishable from a local one - and a canvas that
|
|
47
|
+
guessed would hand its caller a `http://localhost` audience and a cookie with no Secure
|
|
48
|
+
flag.
|
|
49
|
+
|
|
50
|
+
Sign-in fails closed: if the identity service is unreachable, existing sessions keep
|
|
51
|
+
working and new ones are refused. Nothing falls back to open.
|
|
52
|
+
|
|
53
|
+
**Managing people from the repo needs `MARVER_CLI_TOKEN`.** `marver comments invite`,
|
|
54
|
+
`revoke` and `sync` authenticate the CLI with a password, and an identity account has none -
|
|
55
|
+
so set `MARVER_CLI_TOKEN` on the canvas to a *generated* secret of 32 characters or more
|
|
56
|
+
(`openssl rand -hex 24`) and pass the same value back:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
MARVER_CLI_TOKEN='<that same value>' marver comments connect https://canvas.example.com
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
(`--token` works too, but a secret on the command line is visible to anything that can
|
|
63
|
+
list processes.)
|
|
64
|
+
|
|
65
|
+
It acts as whoever owns the canvas, so it does nothing until somebody has signed in and
|
|
66
|
+
claimed it. Generate it rather than choosing one: nothing rate-limits this credential and
|
|
67
|
+
nothing slows a guess down, so its entropy is the whole defence, and a length floor is the
|
|
68
|
+
only part of entropy a program can check. The canvas refuses to start on a value that is too
|
|
69
|
+
short, and on one containing characters an `Authorization` header cannot carry - hex rather
|
|
70
|
+
than base64, so that a canvas never boots holding a secret it would go on to reject.
|
|
71
|
+
|
|
72
|
+
`connect` trades that secret for an ordinary session and stores THAT in
|
|
73
|
+
`~/.marver/canvases/`, so neither the secret nor the session lands in your repo. **Rotate
|
|
74
|
+
`MARVER_CLI_TOKEN` to revoke it**: every session it minted stops working the moment the
|
|
75
|
+
variable changes, and sessions people hold in their browsers are untouched. That is the
|
|
76
|
+
lever rather than `comments revoke`, because the session acts as the owner and a canvas
|
|
77
|
+
refuses to remove its last owner.
|
|
78
|
+
|
|
79
|
+
It is an environment variable, and not a page that hands out tokens to whoever is signed in,
|
|
80
|
+
for a reason worth stating plainly: authored frames run same-origin in a canvas. That is
|
|
81
|
+
deliberate and documented, and it means frame JavaScript can read `mv_c` and ride the
|
|
82
|
+
viewer's session - so any browser-reachable way to mint a durable credential is a way for a
|
|
83
|
+
frame to mint one silently and carry it off, an owner's if an owner is the one looking. A
|
|
84
|
+
browser-approved device flow was built for exactly this job and pulled before release for
|
|
85
|
+
exactly that reason; requiring a real top-level navigation narrowed it and did not close it,
|
|
86
|
+
because `Sec-Fetch-Site: same-origin` proves where a request came from and never that a
|
|
87
|
+
person meant it. An environment variable is on the other side of that line: it is never
|
|
88
|
+
sent to a page, and reaching it already means reaching the deployment.
|
|
89
|
+
|
|
90
|
+
The cost is honest. It is a static secret that rotates by redeploying, and it acts as the
|
|
91
|
+
owner rather than as a person, so it is an operator's credential and belongs with your other
|
|
92
|
+
deployment secrets. Per-member CLI credentials still want the frame isolation this release
|
|
93
|
+
does not have.
|
|
94
|
+
|
|
95
|
+
### Changed
|
|
96
|
+
|
|
97
|
+
- **The gate refuses to be framed** - every gate response now carries
|
|
98
|
+
`Content-Security-Policy: frame-ancestors 'none'` and `X-Frame-Options: DENY`. A
|
|
99
|
+
credential form inside somebody else's page, under a transparent button, is the classic
|
|
100
|
+
clickjack, and `SameSite=Lax` does not help because a framed page on the same site still
|
|
101
|
+
carries its cookies. Called out because it is a behaviour change rather than an addition:
|
|
102
|
+
if you were embedding a password-gated canvas in an iframe, that stops working. Embedding
|
|
103
|
+
a canvas people have already signed into is unaffected - it is the gate itself that
|
|
104
|
+
refuses.
|
|
105
|
+
|
|
106
|
+
### Fixed
|
|
107
|
+
|
|
108
|
+
- **`Secure` on the session cookie no longer depends on a header your proxy may not send.**
|
|
109
|
+
A canvas decided whether it was on https by reading `X-Forwarded-Proto`, and nginx's own
|
|
110
|
+
documented `proxy_pass http://localhost:PORT` sets no `X-Forwarded-*` at all. A canvas
|
|
111
|
+
served over https behind that configuration saw no header, concluded "not secure", and
|
|
112
|
+
issued a thirty-day session cookie the browser would happily send over plain http. Where
|
|
113
|
+
`MARVER_PUBLIC_ORIGIN` is set it now decides - a deliberate statement by whoever deployed
|
|
114
|
+
the canvas, rather than a guess about a proxy that may not be speaking. The header remains
|
|
115
|
+
the fallback only where there is nothing better, which is development on loopback.
|
|
116
|
+
|
|
117
|
+
- **The collaboration credential has moved out of your repository, because `marver dev` was
|
|
118
|
+
serving it.** It lived at `design/.local/collab.json`, and the dev server puts the repository
|
|
119
|
+
on the web so frames can import from it. Authored frames run same-origin, so any frame could
|
|
120
|
+
`fetch('/design/.local/collab.json')` and carry off a live session for your published canvas.
|
|
121
|
+
|
|
122
|
+
It now lives in `~/.marver/canvases/`, keyed by the project's path - one file per canvas per
|
|
123
|
+
machine, outside anything that is served. `connect` writes there, `dev` moves an older
|
|
124
|
+
credential out of the repo the first time it runs, and nothing needs doing by hand.
|
|
125
|
+
|
|
126
|
+
Guarding the old location was tried first and is the reason for the move: a deny rule matched
|
|
127
|
+
the path asked for rather than the file served, so a repository containing
|
|
128
|
+
`leak.json -> design/.local/collab.json` walked straight past it - and after that was fixed,
|
|
129
|
+
`public/` and Vite's derived `index.html` candidates each did the same. Enumerating a
|
|
130
|
+
bundler's resolution rules is not a thing anyone finishes. Those guards stayed as depth,
|
|
131
|
+
`design/.local` is still never served whatever route a request takes, and Vite's own default
|
|
132
|
+
deny list is no longer replaced by ours.
|
|
133
|
+
|
|
134
|
+
If you have ever run `marver dev` on a repository you did not write, treat that credential as
|
|
135
|
+
exposed: change `MARVER_CLI_TOKEN` on the canvas, which ends every session it minted, or on a
|
|
136
|
+
password-account canvas `marver comments revoke` and reconnect.
|
|
137
|
+
|
|
138
|
+
- **A gated canvas is no longer publicly cacheable in identity mode.** The `Cache-Control`
|
|
139
|
+
decision keyed off the password verifier alone, so an identity-gated canvas marked its
|
|
140
|
+
assets `public, immutable` - an invitation for a shared CDN to serve somebody's private
|
|
141
|
+
frames to anybody who asked. Both modes are now `private, no-store`.
|
|
142
|
+
|
|
143
|
+
- **A missing cosmetic file no longer falls through to the bundle.** Favicon and logo paths
|
|
144
|
+
are allowed past the gate on the promise that they are favicons and logos; when no such
|
|
145
|
+
file existed, the hash-routing fallback handed an unauthenticated caller `index.html`
|
|
146
|
+
instead. For those paths a miss is now a miss.
|
|
147
|
+
|
|
5
148
|
## 0.10.2 - 2026-08-24
|
|
6
149
|
|
|
7
150
|
### Fixed
|
|
@@ -0,0 +1,494 @@
|
|
|
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 };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { i as ROUTE, n as NAME } from "./cli.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { i as detectHost, n as loadConfig } from "./config-t9coJ-Pq.mjs";
|
|
3
|
+
import { r as scanFrames } from "./manifest-C2tzkNaC.mjs";
|
|
4
|
+
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-vE7sxEeT.mjs";
|
|
4
5
|
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
5
6
|
import { basename, dirname, join, sep } from "node:path";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|