@marver-design/marver 0.10.2 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +161 -0
- package/README.md +16 -3
- package/dist/auth-B5yuwnOq.mjs +494 -0
- package/dist/{build-zUf_kwKp.mjs → build-DI31MFTB.mjs} +3 -2
- package/dist/cli.mjs +6 -6
- package/dist/{collab-s3k5byM1.mjs → collab-CWaG3Q4w.mjs} +43 -11
- package/dist/{comments-J06jqCVV.mjs → comments-CRqaO2MH.mjs} +26 -11
- package/dist/{comments-BZBKhKRO.mjs → comments-DZyobpxG.mjs} +8 -2
- package/dist/{manifest-DIsp3ldB.mjs → config-t9coJ-Pq.mjs} +3 -233
- package/dist/{daemon-BpKBJZee.mjs → daemon-DSmaS453.mjs} +23 -5
- package/dist/{dev-D2PG6igI.mjs → dev-BWIL2ewj.mjs} +117 -13
- package/dist/{init-DwV0_d9G.mjs → init-BSclDg4I.mjs} +2 -1
- package/dist/manifest-C2tzkNaC.mjs +233 -0
- package/dist/marver-id-B8-3WiHk.mjs +411 -0
- package/dist/marver-id-gate-BDzW6ahN.mjs +728 -0
- package/dist/{plugin-C1sDewZ3.mjs → plugin-DjBFxEQ7.mjs} +12 -84
- package/dist/{profile-BkiWglVE.mjs → profile-DcsJyppw.mjs} +9 -6
- package/dist/secure-cookie-_K1Hsx8H.mjs +26 -0
- package/dist/{serve-CZqPnj19.mjs → serve-3APqaNSB.mjs} +196 -43
- package/dist/sync-DELGomPk.mjs +397 -0
- package/dist/update-DuWDj5nR.mjs +77 -0
- package/dist/utm-CxC3QN5X.mjs +20 -0
- package/package.json +1 -1
- package/src/shared/utm.ts +2 -2
- package/templates/instructions/configure.md +4 -0
- package/templates/instructions/publish.md +99 -16
- package/dist/auth-KQ9Aj-nB.mjs +0 -245
- package/dist/events-BMtBvvgU.mjs +0 -101
- package/dist/sync-BJKKmy1n.mjs +0 -150
- package/dist/{shot-D7I0MCLu.mjs → shot-Cyv3GN79.mjs} +1 -1
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { r as PKG, t as CONTENT_WIDTH } from "./cli.mjs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, relative, sep } from "node:path";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
//#region src/server/manifest.ts
|
|
6
|
+
const FRAME_EXT = /\.(tsx|jsx|html)$/;
|
|
7
|
+
const RESERVED_SCENES = /* @__PURE__ */ new Set(["components", "screens"]);
|
|
8
|
+
/** Extract `export const meta = {...}` with literal string values only. Anything else is silently omitted. */
|
|
9
|
+
function extractMeta(src) {
|
|
10
|
+
const m = /export\s+const\s+meta\s*=\s*\{([\s\S]*?)\}/.exec(src);
|
|
11
|
+
if (!m) return {};
|
|
12
|
+
const body = m[1];
|
|
13
|
+
const pick = (key) => {
|
|
14
|
+
const r = new RegExp(`(?:^|[{,])\\s*${key}\\s*:\\s*(['"\`])([^'"\`]*)\\1`).exec(body);
|
|
15
|
+
return r ? r[2] : void 0;
|
|
16
|
+
};
|
|
17
|
+
const out = {};
|
|
18
|
+
const title = pick("title");
|
|
19
|
+
if (title) out.title = title;
|
|
20
|
+
const viewport = pick("viewport");
|
|
21
|
+
if (viewport) out.viewport = viewport;
|
|
22
|
+
const theme = pick("theme");
|
|
23
|
+
if (theme) out.theme = theme;
|
|
24
|
+
const of = pick("of");
|
|
25
|
+
if (of) out.of = of;
|
|
26
|
+
const variant = pick("variant");
|
|
27
|
+
if (variant) out.variant = variant;
|
|
28
|
+
const intent = pick("intent");
|
|
29
|
+
if (intent) out.intent = intent;
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/** Content-frame detection: LEXICAL by convention - the frame file
|
|
33
|
+
* itself imports PKG/content (an import specifier scan, so "<Diagram" inside a
|
|
34
|
+
* string in a UI frame can never misbadge it; barrels/re-exports are not
|
|
35
|
+
* detected - meta.intent is the taught path and always works). Returns the
|
|
36
|
+
* inferred intent + natural width, or null for UI frames. */
|
|
37
|
+
const CONTENT_IMPORT = new RegExp(`from\\s+['"]${PKG}/content['"]`);
|
|
38
|
+
const WIDE_DOC = /<Doc\b[^>]*\blayout\s*=\s*["']wide["']/;
|
|
39
|
+
const contentWidthOf = (src) => WIDE_DOC.test(src) ? CONTENT_WIDTH.wide : CONTENT_WIDTH.document;
|
|
40
|
+
function contentScan(src) {
|
|
41
|
+
if (!CONTENT_IMPORT.test(src)) return null;
|
|
42
|
+
const count = (re) => (src.match(re) ?? []).length;
|
|
43
|
+
const diagrams = count(/<Diagram[\s>/]/g);
|
|
44
|
+
const imgs = count(/<Img[\s>/]/g);
|
|
45
|
+
const mds = count(/<Md[\s>/]/g);
|
|
46
|
+
return {
|
|
47
|
+
intent: diagrams > 0 ? "diagram" : imgs > mds ? "moodboard" : "spec",
|
|
48
|
+
width: contentWidthOf(src)
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** id = path relative to design/, extension dropped, `scenes/` prefix dropped. Always `/`-separated. */
|
|
52
|
+
function toFrameId(designRelPath) {
|
|
53
|
+
const noExt = designRelPath.split(sep).join("/").replace(FRAME_EXT, "");
|
|
54
|
+
return noExt.startsWith("scenes/") ? noExt.slice(7) : noExt;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A7: which frames does a changed `design/**` file affect? Manifest + directory conventions,
|
|
58
|
+
* NOT a module-graph walk (deterministic, cheap, may over-reload but never misses a
|
|
59
|
+
* conventionally-affected frame). Returns:
|
|
60
|
+
* null -> uncontrolled: leave to default Vite HMR (src/** deps, theme.css, config, assets)
|
|
61
|
+
* [] -> controlled but affects no current frame (e.g. a layout in an empty dir)
|
|
62
|
+
* [ids] -> controlled: the shell drives a rev-stamped reload of these frames
|
|
63
|
+
*/
|
|
64
|
+
function affectedFrameIds(absFile, root, manifest) {
|
|
65
|
+
const design = join(root, "design");
|
|
66
|
+
if (absFile !== design && !absFile.startsWith(design + sep)) return null;
|
|
67
|
+
const rel = relative(design, absFile).split(sep).join("/");
|
|
68
|
+
const name = rel.split("/").pop() ?? rel;
|
|
69
|
+
const designRel = `design/${rel}`;
|
|
70
|
+
const tsxFrames = manifest.frames.filter((f) => f.kind === "tsx");
|
|
71
|
+
if (name === "theme.css") return null;
|
|
72
|
+
if (rel === "providers.tsx" || rel === "providers.jsx") return tsxFrames.map((f) => f.id);
|
|
73
|
+
if (/(^|\/)(_layout\.(tsx|jsx)|_fixtures\.(ts|tsx|js|jsx|json))$/.test(rel)) {
|
|
74
|
+
const prefix = `design/${rel.slice(0, rel.length - name.length)}`;
|
|
75
|
+
return tsxFrames.filter((f) => f.file.startsWith(prefix)).map((f) => f.id);
|
|
76
|
+
}
|
|
77
|
+
const direct = manifest.frames.find((f) => f.file === designRel);
|
|
78
|
+
if (direct) return [direct.id];
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
function walk(dir, out = []) {
|
|
82
|
+
if (!existsSync(dir)) return out;
|
|
83
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
84
|
+
if (e.name.startsWith(".")) continue;
|
|
85
|
+
const p = join(dir, e.name);
|
|
86
|
+
if (e.isDirectory()) walk(p, out);
|
|
87
|
+
else out.push(p);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
function scanFrames(root) {
|
|
92
|
+
const design = join(root, "design");
|
|
93
|
+
const frames = [];
|
|
94
|
+
for (const base of ["scenes", "components"]) for (const abs of walk(join(design, base))) {
|
|
95
|
+
const name = abs.split(sep).pop();
|
|
96
|
+
if (name.startsWith("_") || !FRAME_EXT.test(name)) continue;
|
|
97
|
+
const rel = relative(design, abs).split(sep).join("/");
|
|
98
|
+
const id = toFrameId(rel);
|
|
99
|
+
const scene = id.includes("/") ? id.split("/")[0] : "";
|
|
100
|
+
if (base === "scenes" && RESERVED_SCENES.has(scene)) {
|
|
101
|
+
console.error(`[marver] scene "${scene}" is a reserved name - "${rel}" skipped. Rename the scene.`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const kind = name.endsWith(".html") ? "html" : "tsx";
|
|
105
|
+
const entry = {
|
|
106
|
+
id,
|
|
107
|
+
file: `design/${rel}`,
|
|
108
|
+
kind,
|
|
109
|
+
scene
|
|
110
|
+
};
|
|
111
|
+
if (kind === "tsx") {
|
|
112
|
+
const src = readFileSync(abs, "utf8");
|
|
113
|
+
const meta = extractMeta(src);
|
|
114
|
+
if (meta.title) entry.title = meta.title;
|
|
115
|
+
if (meta.viewport) entry.viewport = meta.viewport;
|
|
116
|
+
if (meta.theme) entry.theme = meta.theme;
|
|
117
|
+
if (meta.of) entry.variantGroup = meta.of;
|
|
118
|
+
if (meta.variant) entry.variant = meta.variant;
|
|
119
|
+
const content = contentScan(src);
|
|
120
|
+
if (content || meta.intent) {
|
|
121
|
+
entry.intent = meta.intent ?? content.intent;
|
|
122
|
+
entry.contentWidth = content?.width ?? contentWidthOf(src);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
frames.push(entry);
|
|
126
|
+
}
|
|
127
|
+
const extRank = (file) => file.endsWith(".tsx") ? 0 : file.endsWith(".jsx") ? 1 : 2;
|
|
128
|
+
frames.sort((a, b) => a.id.localeCompare(b.id) || extRank(a.file) - extRank(b.file) || a.file.localeCompare(b.file));
|
|
129
|
+
const seen = /* @__PURE__ */ new Map();
|
|
130
|
+
const deduped = frames.filter((f) => {
|
|
131
|
+
const prev = seen.get(f.id);
|
|
132
|
+
if (prev) {
|
|
133
|
+
console.error(`[marver] duplicate frame id "${f.id}" (${prev} vs ${f.file}) - keeping ${prev}.`);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
seen.set(f.id, f.file);
|
|
137
|
+
return true;
|
|
138
|
+
});
|
|
139
|
+
frames.length = 0;
|
|
140
|
+
frames.push(...deduped);
|
|
141
|
+
inferVariantGroups(frames);
|
|
142
|
+
const sceneCounts = /* @__PURE__ */ new Map();
|
|
143
|
+
for (const f of frames) sceneCounts.set(f.scene, (sceneCounts.get(f.scene) ?? 0) + 1);
|
|
144
|
+
return {
|
|
145
|
+
frames,
|
|
146
|
+
scenes: [...sceneCounts.entries()].map(([name, n]) => ({
|
|
147
|
+
name,
|
|
148
|
+
frames: n
|
|
149
|
+
})).sort((a, b) => a.name.localeCompare(b.name))
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** Variant groups. A group = 2+ frames in one DIRECTORY whose basenames
|
|
153
|
+
* are letter-prefixed (`a-terminal`), or frames declaring `meta.of`. Group id = the
|
|
154
|
+
* directory's id prefix (or meta.of); variant key = the letter (or meta.variant).
|
|
155
|
+
* Nested directories scope alternatives inside a busy scene (checkout/payment/a-card).
|
|
156
|
+
* States (empty.tsx, error.tsx) never letter-prefix, so they never misgroup.
|
|
157
|
+
* Mutates entries in place: only frames whose group materializes keep the fields. */
|
|
158
|
+
function inferVariantGroups(frames) {
|
|
159
|
+
const dirOf = (id) => {
|
|
160
|
+
const i = id.lastIndexOf("/");
|
|
161
|
+
return i >= 0 ? id.slice(0, i) : "";
|
|
162
|
+
};
|
|
163
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
164
|
+
for (const f of frames) {
|
|
165
|
+
if (f.kind !== "tsx") {
|
|
166
|
+
delete f.variantGroup;
|
|
167
|
+
delete f.variant;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
let group = f.variantGroup;
|
|
171
|
+
let key = f.variant;
|
|
172
|
+
if (!group) {
|
|
173
|
+
const base = f.id.slice(f.id.lastIndexOf("/") + 1);
|
|
174
|
+
const m = /^([a-z])-.+$/.exec(base);
|
|
175
|
+
if (!m) continue;
|
|
176
|
+
group = dirOf(f.id);
|
|
177
|
+
if (!group) continue;
|
|
178
|
+
key = key ?? m[1];
|
|
179
|
+
} else if (!key) {
|
|
180
|
+
const m = /^([a-z])-.+$/.exec(f.id.slice(f.id.lastIndexOf("/") + 1));
|
|
181
|
+
if (!m) {
|
|
182
|
+
console.warn(`[marver] ${f.file} declares of:"${group}" but no variant key (add meta.variant or a letter prefix) - not grouped.`);
|
|
183
|
+
delete f.variantGroup;
|
|
184
|
+
delete f.variant;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
key = m[1];
|
|
188
|
+
}
|
|
189
|
+
f.variantGroup = group;
|
|
190
|
+
f.variant = key;
|
|
191
|
+
candidates.set(group, [...candidates.get(group) ?? [], f]);
|
|
192
|
+
}
|
|
193
|
+
for (const [group, members] of candidates) {
|
|
194
|
+
if (new Set(members.map((m) => dirOf(m.id))).size > 1) {
|
|
195
|
+
console.warn(`[marver] group "${group}" spans directories - variants must be siblings; not grouped.`);
|
|
196
|
+
for (const m of members) {
|
|
197
|
+
delete m.variantGroup;
|
|
198
|
+
delete m.variant;
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const seen = /* @__PURE__ */ new Set();
|
|
203
|
+
const kept = [];
|
|
204
|
+
for (const m of members.sort((a, b) => (a.variant ?? "").localeCompare(b.variant ?? ""))) {
|
|
205
|
+
if (seen.has(m.variant)) {
|
|
206
|
+
console.warn(`[marver] duplicate variant "${m.variant}" in group "${group}" (${m.file}) - not grouped.`);
|
|
207
|
+
delete m.variantGroup;
|
|
208
|
+
delete m.variant;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
seen.add(m.variant);
|
|
212
|
+
kept.push(m);
|
|
213
|
+
}
|
|
214
|
+
if (kept.length < 2) for (const m of kept) {
|
|
215
|
+
delete m.variantGroup;
|
|
216
|
+
delete m.variant;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const f of frames) if (f.variant && !f.variantGroup) delete f.variant;
|
|
220
|
+
}
|
|
221
|
+
/** Write design/manifest.json only when content changed. Returns the manifest either way. */
|
|
222
|
+
function writeManifest(root, manifest) {
|
|
223
|
+
const file = join(root, "design", "manifest.json");
|
|
224
|
+
const next = JSON.stringify(manifest, null, 2) + "\n";
|
|
225
|
+
const prev = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
226
|
+
if (hash(prev) === hash(next)) return false;
|
|
227
|
+
mkdirSync(join(root, "design"), { recursive: true });
|
|
228
|
+
writeFileSync(file, next);
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
const hash = (s) => createHash("sha256").update(s).digest("hex");
|
|
232
|
+
//#endregion
|
|
233
|
+
export { writeManifest as a, toFrameId as i, hash as n, scanFrames as r, affectedFrameIds as t };
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { createHash, createVerify, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
//#region src/server/marver-id.ts
|
|
3
|
+
/**
|
|
4
|
+
* Marver ID - the canvas half of the protocol.
|
|
5
|
+
*
|
|
6
|
+
* A canvas never trusts a token because it looks well-formed. The sequence is:
|
|
7
|
+
*
|
|
8
|
+
* 1. mint() - the canvas invents a nonce and remembers it, bound
|
|
9
|
+
* to this browser and this exact origin.
|
|
10
|
+
* 2. the TAB goes to id.marver.design/authorize and comes back to
|
|
11
|
+
* /__mv/id/finish with the assertion in the URL fragment. One tab, one
|
|
12
|
+
* redirect each way - no popup and no postMessage, because Google serves
|
|
13
|
+
* COOP: same-origin and severs window.opener permanently. See
|
|
14
|
+
* marver-id-gate.ts for the full reasoning.
|
|
15
|
+
* 3. verifyAssertion() - the canvas SERVER checks every claim against the
|
|
16
|
+
* published keys, then consumes the transaction.
|
|
17
|
+
*
|
|
18
|
+
* Step 3 is the one that matters. `aud` must equal this canvas's own origin,
|
|
19
|
+
* so an assertion captured on an attacker's site is inert here; the nonce must
|
|
20
|
+
* match a transaction we actually issued, so a token cannot be injected from
|
|
21
|
+
* nowhere; and the transaction is single-use, so it cannot be replayed even
|
|
22
|
+
* within its short life.
|
|
23
|
+
*
|
|
24
|
+
* Everything here is dependency-free and runs in the OSS canvas, because every
|
|
25
|
+
* self-hosted deployment needs to do this for itself. Nothing phones home
|
|
26
|
+
* except the periodic fetch of public keys.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* How long a sign-in may take, start to finish.
|
|
30
|
+
*
|
|
31
|
+
* Was five minutes, which quietly assumed the identity service answers
|
|
32
|
+
* instantly. It does not: a sign-in there can mean waiting for an emailed code,
|
|
33
|
+
* and mail is store-and-forward - a first-time sender can be greylisted and
|
|
34
|
+
* deferred for minutes before the message is even accepted. Somebody who went to
|
|
35
|
+
* find their code and came back to "unknown or spent nonce" did nothing wrong.
|
|
36
|
+
*
|
|
37
|
+
* Fifteen minutes covers a slow round trip with room to spare, and costs little:
|
|
38
|
+
* the nonce is single-use, bound to one browser and one origin, and useless
|
|
39
|
+
* without a signature from the issuer. The browser handle below is set to match,
|
|
40
|
+
* so the two halves of the same deadline expire together rather than leaving a
|
|
41
|
+
* window where the transaction lives but the handle that owns it is gone.
|
|
42
|
+
*/
|
|
43
|
+
const TRANSACTION_TTL_MS = 9e5;
|
|
44
|
+
/** Servers drift; allow a little, but not enough to matter. */
|
|
45
|
+
const CLOCK_TOLERANCE_S = 30;
|
|
46
|
+
/** Public keys are cached; rotation publishes a key well before it signs. */
|
|
47
|
+
const JWKS_TTL_MS = 6e5;
|
|
48
|
+
/** A ceiling on token size - nothing legitimate is close to this. */
|
|
49
|
+
const MAX_TOKEN_BYTES = 8192;
|
|
50
|
+
/**
|
|
51
|
+
* The transaction store.
|
|
52
|
+
*
|
|
53
|
+
* In memory on purpose: a transaction is short-lived, and a canvas that
|
|
54
|
+
* restarts mid-sign-in should simply ask the person to try again rather than
|
|
55
|
+
* persist half-finished authentications to disk.
|
|
56
|
+
*/
|
|
57
|
+
/** A ceiling on live transactions - /start is unauthenticated by necessity. */
|
|
58
|
+
const MAX_TRANSACTIONS = 1e4;
|
|
59
|
+
/** How often the expiry sweep is allowed to walk the whole map. */
|
|
60
|
+
const SWEEP_INTERVAL_MS = 1e4;
|
|
61
|
+
var TransactionStore = class {
|
|
62
|
+
items = /* @__PURE__ */ new Map();
|
|
63
|
+
/** nonce currently held by each browser, so one browser holds one slot. */
|
|
64
|
+
byBrowser = /* @__PURE__ */ new Map();
|
|
65
|
+
lastSweep = 0;
|
|
66
|
+
/** Invent a nonce for a sign-in attempt from this browser to this origin. */
|
|
67
|
+
mint(origin, browser, next) {
|
|
68
|
+
this.sweep();
|
|
69
|
+
const held = this.byBrowser.get(browser);
|
|
70
|
+
if (held) {
|
|
71
|
+
const live = this.items.get(held);
|
|
72
|
+
if (live && live.exp >= Date.now() && live.origin === origin) {
|
|
73
|
+
if (next) live.next = next;
|
|
74
|
+
live.exp = Date.now() + TRANSACTION_TTL_MS;
|
|
75
|
+
return live;
|
|
76
|
+
}
|
|
77
|
+
if (held) this.items.delete(held);
|
|
78
|
+
}
|
|
79
|
+
if (this.items.size >= MAX_TRANSACTIONS) {
|
|
80
|
+
const oldest = this.items.keys().next().value;
|
|
81
|
+
if (oldest) {
|
|
82
|
+
const victim = this.items.get(oldest);
|
|
83
|
+
this.items.delete(oldest);
|
|
84
|
+
if (victim && this.byBrowser.get(victim.browser) === oldest) this.byBrowser.delete(victim.browser);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const tx = {
|
|
88
|
+
nonce: randomBytes(32).toString("base64url"),
|
|
89
|
+
origin,
|
|
90
|
+
browser,
|
|
91
|
+
exp: Date.now() + TRANSACTION_TTL_MS,
|
|
92
|
+
...next ? { next } : {}
|
|
93
|
+
};
|
|
94
|
+
this.items.set(tx.nonce, tx);
|
|
95
|
+
this.byBrowser.set(browser, tx.nonce);
|
|
96
|
+
return tx;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Take a transaction, if it is real, unexpired, and belongs to this browser.
|
|
100
|
+
*
|
|
101
|
+
* The browser check happens BEFORE the delete, and that ordering is the whole
|
|
102
|
+
* point: a stranger who somehow learns a nonce must not be able to destroy
|
|
103
|
+
* somebody else's live sign-in by presenting it. Only the browser that owns a
|
|
104
|
+
* transaction can spend it - for anyone else it is inert, and left alone.
|
|
105
|
+
*
|
|
106
|
+
* Once ownership is proved the transaction is removed unconditionally, so the
|
|
107
|
+
* legitimate browser gets exactly one attempt and a forged signature cannot be
|
|
108
|
+
* ground against a live nonce.
|
|
109
|
+
*/
|
|
110
|
+
consume(nonce, browser) {
|
|
111
|
+
const tx = this.items.get(nonce);
|
|
112
|
+
if (!tx) return null;
|
|
113
|
+
if (!constantTimeEqual(tx.browser, browser)) return null;
|
|
114
|
+
this.items.delete(nonce);
|
|
115
|
+
if (this.byBrowser.get(tx.browser) === nonce) this.byBrowser.delete(tx.browser);
|
|
116
|
+
if (tx.exp < Date.now()) return null;
|
|
117
|
+
return tx;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Drop what has expired.
|
|
121
|
+
*
|
|
122
|
+
* Throttled, because this walks the whole map and it is reached from an
|
|
123
|
+
* unauthenticated endpoint - running it on every mint turns /start into a
|
|
124
|
+
* quadratic cost the caller controls. Expiry is still enforced exactly, in
|
|
125
|
+
* consume(); this only reclaims memory, and ten seconds late is fine.
|
|
126
|
+
*/
|
|
127
|
+
sweep() {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
if (now - this.lastSweep < SWEEP_INTERVAL_MS) return;
|
|
130
|
+
this.lastSweep = now;
|
|
131
|
+
for (const [k, v] of this.items) if (v.exp < now) {
|
|
132
|
+
this.items.delete(k);
|
|
133
|
+
if (this.byBrowser.get(v.browser) === k) this.byBrowser.delete(v.browser);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
get size() {
|
|
137
|
+
return this.items.size;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Reduce a configured issuer to a bare origin, or refuse it.
|
|
142
|
+
*
|
|
143
|
+
* The issuer is the trust root: its published keys decide who may open this
|
|
144
|
+
* canvas. Taken as a raw string it is far too easy to configure into something
|
|
145
|
+
* that is not a trust root at all - `http://` invites anyone on the path
|
|
146
|
+
* between here and there to serve their own keys, and a value carrying a path,
|
|
147
|
+
* a query or embedded credentials means the URL the operator read is not the
|
|
148
|
+
* URL that gets fetched.
|
|
149
|
+
*
|
|
150
|
+
* Loopback over http is allowed because that is how the protocol is developed
|
|
151
|
+
* against a local identity service, and there is no network to sit on.
|
|
152
|
+
*
|
|
153
|
+
* Returns null when the value cannot be trusted, so the caller can refuse to
|
|
154
|
+
* start rather than run with a trust root nobody vetted.
|
|
155
|
+
*/
|
|
156
|
+
function normalizeIssuer(raw) {
|
|
157
|
+
if (!raw) return null;
|
|
158
|
+
let url;
|
|
159
|
+
try {
|
|
160
|
+
url = new URL(raw.trim());
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
165
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) return null;
|
|
166
|
+
if (url.username || url.password) return null;
|
|
167
|
+
if (url.pathname !== "/" || url.search || url.hash) return null;
|
|
168
|
+
return url.origin;
|
|
169
|
+
}
|
|
170
|
+
let jwksCache = null;
|
|
171
|
+
/**
|
|
172
|
+
* The last time an unknown kid forced a refetch, and how rarely that may happen.
|
|
173
|
+
*
|
|
174
|
+
* The token chooses the kid, so without a floor here a caller manufactures one
|
|
175
|
+
* outbound request per attempt.
|
|
176
|
+
*/
|
|
177
|
+
let lastForcedFetch = 0;
|
|
178
|
+
const FORCED_FETCH_COOLDOWN_MS = 3e4;
|
|
179
|
+
/**
|
|
180
|
+
* The fetch currently in flight, if there is one.
|
|
181
|
+
*
|
|
182
|
+
* The cooldown above bounds refetches for an UNKNOWN kid, but says nothing about
|
|
183
|
+
* a cold cache: at boot, or the moment the cache ages out, every callback that
|
|
184
|
+
* arrives at once sees no entry and starts its own request. That is a thundering
|
|
185
|
+
* herd pointed at the identity service, and a caller holding transactions can
|
|
186
|
+
* time it deliberately. One request per URL, shared by everyone waiting for it.
|
|
187
|
+
*/
|
|
188
|
+
let inFlight = null;
|
|
189
|
+
async function fetchJwks(issuer, force = false) {
|
|
190
|
+
const url = `${issuer.replace(/\/$/, "")}/.well-known/jwks.json`;
|
|
191
|
+
if (!force && jwksCache && jwksCache.url === url && Date.now() - jwksCache.at < JWKS_TTL_MS) return jwksCache.jwks;
|
|
192
|
+
if (inFlight && inFlight.url === url) return inFlight.p;
|
|
193
|
+
const p = fetchJwksNow(url);
|
|
194
|
+
inFlight = {
|
|
195
|
+
url,
|
|
196
|
+
p
|
|
197
|
+
};
|
|
198
|
+
try {
|
|
199
|
+
return await p;
|
|
200
|
+
} finally {
|
|
201
|
+
if (inFlight?.p === p) inFlight = null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function fetchJwksNow(url) {
|
|
205
|
+
const res = await fetch(url, {
|
|
206
|
+
signal: AbortSignal.timeout(5e3),
|
|
207
|
+
redirect: "error"
|
|
208
|
+
});
|
|
209
|
+
if (!res.ok) throw new Error(`jwks fetch failed (${res.status})`);
|
|
210
|
+
const jwks = await res.json();
|
|
211
|
+
if (!Array.isArray(jwks.keys)) throw new Error("jwks malformed");
|
|
212
|
+
jwksCache = {
|
|
213
|
+
at: Date.now(),
|
|
214
|
+
url,
|
|
215
|
+
jwks
|
|
216
|
+
};
|
|
217
|
+
return jwks;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Verify an assertion against everything it claims to be.
|
|
221
|
+
*
|
|
222
|
+
* Deliberately returns a reason string rather than throwing: the caller logs it
|
|
223
|
+
* and shows the person a single generic failure. Distinguishing "expired" from
|
|
224
|
+
* "wrong audience" to the browser would be handing an attacker a debugger.
|
|
225
|
+
*/
|
|
226
|
+
async function verifyAssertion(opts) {
|
|
227
|
+
const { token, origin, issuer, store, browser } = opts;
|
|
228
|
+
if (!token || token.length > MAX_TOKEN_BYTES) return {
|
|
229
|
+
ok: false,
|
|
230
|
+
reason: "token size"
|
|
231
|
+
};
|
|
232
|
+
const parts = token.split(".");
|
|
233
|
+
if (parts.length !== 3) return {
|
|
234
|
+
ok: false,
|
|
235
|
+
reason: "not a jws"
|
|
236
|
+
};
|
|
237
|
+
let header;
|
|
238
|
+
let claims;
|
|
239
|
+
try {
|
|
240
|
+
const h = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"));
|
|
241
|
+
const c = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
242
|
+
if (!isPlainObject(h) || !isPlainObject(c)) return {
|
|
243
|
+
ok: false,
|
|
244
|
+
reason: "malformed json"
|
|
245
|
+
};
|
|
246
|
+
header = h;
|
|
247
|
+
claims = c;
|
|
248
|
+
} catch {
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
reason: "malformed json"
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
if (header.alg !== "ES256") return {
|
|
255
|
+
ok: false,
|
|
256
|
+
reason: "alg"
|
|
257
|
+
};
|
|
258
|
+
if (header.typ !== "marver-assertion+jwt") return {
|
|
259
|
+
ok: false,
|
|
260
|
+
reason: "typ"
|
|
261
|
+
};
|
|
262
|
+
if ("jku" in header || "x5u" in header || "jwk" in header) return {
|
|
263
|
+
ok: false,
|
|
264
|
+
reason: "header injection"
|
|
265
|
+
};
|
|
266
|
+
const kid = typeof header.kid === "string" ? header.kid : null;
|
|
267
|
+
if (!kid || kid.length > 128) return {
|
|
268
|
+
ok: false,
|
|
269
|
+
reason: "kid"
|
|
270
|
+
};
|
|
271
|
+
if (claims.iss !== issuer) return {
|
|
272
|
+
ok: false,
|
|
273
|
+
reason: "iss"
|
|
274
|
+
};
|
|
275
|
+
if (claims.aud !== origin) return {
|
|
276
|
+
ok: false,
|
|
277
|
+
reason: "aud"
|
|
278
|
+
};
|
|
279
|
+
if (claims.email_verified !== true) return {
|
|
280
|
+
ok: false,
|
|
281
|
+
reason: "email unverified"
|
|
282
|
+
};
|
|
283
|
+
const sub = typeof claims.sub === "string" ? claims.sub : "";
|
|
284
|
+
const email = typeof claims.email === "string" ? claims.email : "";
|
|
285
|
+
const nonce = typeof claims.nonce === "string" ? claims.nonce : "";
|
|
286
|
+
if (!sub || !email || !nonce) return {
|
|
287
|
+
ok: false,
|
|
288
|
+
reason: "missing claims"
|
|
289
|
+
};
|
|
290
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
291
|
+
const exp = typeof claims.exp === "number" ? claims.exp : 0;
|
|
292
|
+
const nbf = typeof claims.nbf === "number" ? claims.nbf : 0;
|
|
293
|
+
const iat = typeof claims.iat === "number" ? claims.iat : 0;
|
|
294
|
+
if (!exp || exp + CLOCK_TOLERANCE_S < now) return {
|
|
295
|
+
ok: false,
|
|
296
|
+
reason: "expired"
|
|
297
|
+
};
|
|
298
|
+
if (nbf && nbf - CLOCK_TOLERANCE_S > now) return {
|
|
299
|
+
ok: false,
|
|
300
|
+
reason: "not yet valid"
|
|
301
|
+
};
|
|
302
|
+
if (!iat || iat - CLOCK_TOLERANCE_S > now) return {
|
|
303
|
+
ok: false,
|
|
304
|
+
reason: "issued in the future"
|
|
305
|
+
};
|
|
306
|
+
const tx = store.consume(nonce, browser);
|
|
307
|
+
if (!tx) return {
|
|
308
|
+
ok: false,
|
|
309
|
+
reason: "unknown or spent nonce"
|
|
310
|
+
};
|
|
311
|
+
if (tx.origin !== origin) return {
|
|
312
|
+
ok: false,
|
|
313
|
+
reason: "origin mismatch"
|
|
314
|
+
};
|
|
315
|
+
opts.onConsumed?.(tx);
|
|
316
|
+
let verified = false;
|
|
317
|
+
try {
|
|
318
|
+
verified = await verifySignature(parts, kid, issuer);
|
|
319
|
+
} catch {
|
|
320
|
+
return {
|
|
321
|
+
ok: false,
|
|
322
|
+
reason: "key fetch failed"
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
if (!verified) return {
|
|
326
|
+
ok: false,
|
|
327
|
+
reason: "signature"
|
|
328
|
+
};
|
|
329
|
+
return {
|
|
330
|
+
ok: true,
|
|
331
|
+
identity: {
|
|
332
|
+
subject: sub,
|
|
333
|
+
email,
|
|
334
|
+
...displayClaims(claims)
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
async function verifySignature(parts, kid, issuer) {
|
|
339
|
+
let jwks = await fetchJwks(issuer);
|
|
340
|
+
let jwk = jwks.keys.find((k) => k.kid === kid);
|
|
341
|
+
if (!jwk && Date.now() - lastForcedFetch >= FORCED_FETCH_COOLDOWN_MS) {
|
|
342
|
+
lastForcedFetch = Date.now();
|
|
343
|
+
jwks = await fetchJwks(issuer, true);
|
|
344
|
+
jwk = jwks.keys.find((k) => k.kid === kid);
|
|
345
|
+
}
|
|
346
|
+
if (!jwk) return false;
|
|
347
|
+
const k = jwk;
|
|
348
|
+
if (k.kty !== "EC" || k.crv !== "P-256") return false;
|
|
349
|
+
if (k.alg && k.alg !== "ES256") return false;
|
|
350
|
+
if (k.use && k.use !== "sig") return false;
|
|
351
|
+
if (Array.isArray(k.key_ops) && !k.key_ops.includes("verify")) return false;
|
|
352
|
+
const { createPublicKey } = await import("node:crypto");
|
|
353
|
+
const key = createPublicKey({
|
|
354
|
+
key: jwk,
|
|
355
|
+
format: "jwk"
|
|
356
|
+
});
|
|
357
|
+
const signature = Buffer.from(parts[2], "base64url");
|
|
358
|
+
if (signature.length !== 64) return false;
|
|
359
|
+
const v = createVerify("SHA256");
|
|
360
|
+
v.update(`${parts[0]}.${parts[1]}`);
|
|
361
|
+
v.end();
|
|
362
|
+
return v.verify({
|
|
363
|
+
key,
|
|
364
|
+
dsaEncoding: "ieee-p1363"
|
|
365
|
+
}, signature);
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* The optional display claims, taken only if they are the right shape.
|
|
369
|
+
*
|
|
370
|
+
* Deliberately forgiving: a malformed name or picture drops that ONE field and
|
|
371
|
+
* lets the sign-in through, because neither decides anything. Refusing a valid
|
|
372
|
+
* assertion over a bad avatar URL would trade a cosmetic problem for a lockout.
|
|
373
|
+
*
|
|
374
|
+
* The bounds are what stop this being a hole. A name is capped so it cannot be
|
|
375
|
+
* used to write a paragraph into somebody's comment sidebar, and control
|
|
376
|
+
* characters go because that is how a value gets smuggled into a log line. A
|
|
377
|
+
* picture must be https and bounded - the canvas will make a server-side request
|
|
378
|
+
* to whatever survives this, so http, data:, file: and friends never get that
|
|
379
|
+
* far.
|
|
380
|
+
*/
|
|
381
|
+
function displayClaims(claims) {
|
|
382
|
+
const out = {};
|
|
383
|
+
const name = (typeof claims.name === "string" ? claims.name : "").replace(/[\u0000-\u001f\u007f]/g, "").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
384
|
+
if (name) out.name = name;
|
|
385
|
+
if (typeof claims.picture === "string" && claims.picture.length <= 2048) try {
|
|
386
|
+
const u = new URL(claims.picture);
|
|
387
|
+
if (u.protocol === "https:" && !u.username && !u.password) out.picture = u.toString();
|
|
388
|
+
} catch {}
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
/** A JSON object, and not an array or null - both of which pass `typeof === 'object'`. */
|
|
392
|
+
function isPlainObject(v) {
|
|
393
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
394
|
+
}
|
|
395
|
+
/** Length-safe string compare, so a mismatch leaks nothing through timing. */
|
|
396
|
+
function constantTimeEqual(a, b) {
|
|
397
|
+
const ha = createHash("sha256").update(a).digest();
|
|
398
|
+
const hb = createHash("sha256").update(b).digest();
|
|
399
|
+
return timingSafeEqual(ha, hb);
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* A stable, opaque handle for "this browser", used to bind a transaction to the
|
|
403
|
+
* client that started it. Derived from a random cookie value the canvas already
|
|
404
|
+
* sets - never from IP or user agent, both of which change legitimately
|
|
405
|
+
* mid-flow (mobile networks, upgrades) and would lock people out.
|
|
406
|
+
*/
|
|
407
|
+
function browserBinding(cookieValue) {
|
|
408
|
+
return createHash("sha256").update(cookieValue).digest("base64url");
|
|
409
|
+
}
|
|
410
|
+
//#endregion
|
|
411
|
+
export { TransactionStore, browserBinding, normalizeIssuer, verifyAssertion };
|