@wizzlethorpe/vaults 0.1.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/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/api.js +42 -0
- package/dist/api.js.map +1 -0
- package/dist/auth.js +62 -0
- package/dist/auth.js.map +1 -0
- package/dist/build.js +758 -0
- package/dist/build.js.map +1 -0
- package/dist/commands/build.js +23 -0
- package/dist/commands/build.js.map +1 -0
- package/dist/commands/init.js +67 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/password.js +74 -0
- package/dist/commands/password.js.map +1 -0
- package/dist/commands/preview.js +60 -0
- package/dist/commands/preview.js.map +1 -0
- package/dist/commands/push.js +191 -0
- package/dist/commands/push.js.map +1 -0
- package/dist/commands/role.js +122 -0
- package/dist/commands/role.js.map +1 -0
- package/dist/config.js +79 -0
- package/dist/config.js.map +1 -0
- package/dist/favicon.js +91 -0
- package/dist/favicon.js.map +1 -0
- package/dist/images.js +47 -0
- package/dist/images.js.map +1 -0
- package/dist/index.js +154 -0
- package/dist/index.js.map +1 -0
- package/dist/obsidian.js +47 -0
- package/dist/obsidian.js.map +1 -0
- package/dist/render/auth-template.js +677 -0
- package/dist/render/auth-template.js.map +1 -0
- package/dist/render/callouts.js +65 -0
- package/dist/render/callouts.js.map +1 -0
- package/dist/render/embed.js +190 -0
- package/dist/render/embed.js.map +1 -0
- package/dist/render/layout.js +414 -0
- package/dist/render/layout.js.map +1 -0
- package/dist/render/mcp-template.js +239 -0
- package/dist/render/mcp-template.js.map +1 -0
- package/dist/render/pipeline.js +59 -0
- package/dist/render/pipeline.js.map +1 -0
- package/dist/render/preview.js +81 -0
- package/dist/render/preview.js.map +1 -0
- package/dist/render/slug.js +12 -0
- package/dist/render/slug.js.map +1 -0
- package/dist/render/styles.js +383 -0
- package/dist/render/styles.js.map +1 -0
- package/dist/render/types.js +2 -0
- package/dist/render/types.js.map +1 -0
- package/dist/render/wikilink.js +55 -0
- package/dist/render/wikilink.js.map +1 -0
- package/dist/scan.js +45 -0
- package/dist/scan.js.map +1 -0
- package/dist/settings.js +157 -0
- package/dist/settings.js.map +1 -0
- package/dist/util.js +60 -0
- package/dist/util.js.map +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
// The Pages Function shipped to Cloudflare. Generated at build time with the
|
|
2
|
+
// role list and password hashes baked in. Validates session cookies, redirects
|
|
3
|
+
// to /login on a missing/expired cookie, and rewrites every request to the
|
|
4
|
+
// matching `_variants/<role>/` path before letting Pages serve it.
|
|
5
|
+
export function renderAuthMiddleware(cfg) {
|
|
6
|
+
const rolesLiteral = JSON.stringify(cfg.roles);
|
|
7
|
+
const passwordsLiteral = JSON.stringify(cfg.rolePasswords);
|
|
8
|
+
return `// Auto-generated by the vaults CLI. Do not edit by hand.
|
|
9
|
+
// Roles, password hashes, and routing live here so the deployed Function
|
|
10
|
+
// is fully self-contained and doesn't need any other binding besides
|
|
11
|
+
// SESSION_SECRET (set via wrangler secret).
|
|
12
|
+
|
|
13
|
+
const ROLES = ${rolesLiteral};
|
|
14
|
+
const PASSWORDS = ${passwordsLiteral};
|
|
15
|
+
const COOKIE_NAME = "vault_role";
|
|
16
|
+
// Non-HttpOnly companion cookie carrying the role name only — the auth check
|
|
17
|
+
// uses COOKIE_NAME (which is signed and HttpOnly), this one is purely for UI.
|
|
18
|
+
const DISPLAY_COOKIE_NAME = "vault_role_display";
|
|
19
|
+
const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
|
|
20
|
+
// Bearer tokens (used by Foundry, MCP clients) get a much longer lifetime
|
|
21
|
+
// since refreshing means reopening a browser-based approval flow.
|
|
22
|
+
const BEARER_MAX_AGE = 60 * 60 * 24 * 90; // 90 days
|
|
23
|
+
const PBKDF2_DEFAULT_ITERATIONS = 100000;
|
|
24
|
+
|
|
25
|
+
// ── Public middleware entry ────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export const onRequest = async (ctx) => {
|
|
28
|
+
const { request, env, next } = ctx;
|
|
29
|
+
const url = new URL(request.url);
|
|
30
|
+
|
|
31
|
+
// CORS preflight — Foundry, the MCP server, and AI tooling fetch the
|
|
32
|
+
// manifest / source / search endpoints from a different origin with an
|
|
33
|
+
// 'Authorization: Bearer' header, which triggers a preflight OPTIONS.
|
|
34
|
+
// Allow * because the resource is gated by the bearer token, not by
|
|
35
|
+
// the origin (and we don't want to maintain an allowlist).
|
|
36
|
+
if (request.method === "OPTIONS") {
|
|
37
|
+
return new Response(null, { status: 204, headers: corsHeaders() });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Block direct access to /_variants/<role>/* — those paths exist in storage
|
|
41
|
+
// for the rewrite below, but exposing them would let anyone fetch any
|
|
42
|
+
// variant's manifest, page, or markdown source by guessing the role name.
|
|
43
|
+
if (url.pathname.startsWith("/_variants/")) {
|
|
44
|
+
return withCors(new Response("Not found", { status: 404 }), request);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// /login — POST validates a password and sets the session cookie. GET
|
|
48
|
+
// serves the static login page from the deploy root; we have to pass it
|
|
49
|
+
// through explicitly because the variant-rewrite below would otherwise
|
|
50
|
+
// try to fetch /_variants/<role>/login (which doesn't exist).
|
|
51
|
+
if (url.pathname === "/login") {
|
|
52
|
+
if (request.method === "POST") return handleLogin(request, env);
|
|
53
|
+
return next();
|
|
54
|
+
}
|
|
55
|
+
if (url.pathname === "/logout") {
|
|
56
|
+
const headers = new Headers({ Location: safeNext(url.searchParams.get("next")) });
|
|
57
|
+
// Emit the cookie-clear in two variants so we delete the cookie
|
|
58
|
+
// regardless of which form was set: the new SameSite=None+Partitioned
|
|
59
|
+
// form (used by current iframes / direct browsing), and the old
|
|
60
|
+
// SameSite=Lax form (in case the cookie predates the partition switch).
|
|
61
|
+
for (const name of [COOKIE_NAME, DISPLAY_COOKIE_NAME]) {
|
|
62
|
+
for (const variant of clearCookieVariants(name)) {
|
|
63
|
+
headers.append("Set-Cookie", variant);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return new Response(null, { status: 302, headers });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// /connect — OAuth-style approval flow for Foundry / MCP clients to obtain
|
|
70
|
+
// a long-lived bearer token. GET shows the approval page; POST signs the
|
|
71
|
+
// token and redirects back to the requesting app.
|
|
72
|
+
if (url.pathname === "/connect" && request.method === "GET") {
|
|
73
|
+
return handleConnectGet(request, env);
|
|
74
|
+
}
|
|
75
|
+
if (url.pathname === "/connect/approve" && request.method === "POST") {
|
|
76
|
+
return handleConnectApprove(request, env);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// /_batch — bulk source fetch for sync clients (Foundry). Body is
|
|
80
|
+
// newline-separated paths under text/plain so the request stays CORS-
|
|
81
|
+
// simple (no preflight per file → no OPTIONS rate-limit). Response is
|
|
82
|
+
// JSON: { files: { path: content }, missing: [path, ...] }.
|
|
83
|
+
if (url.pathname === "/_batch" && request.method === "POST") {
|
|
84
|
+
return withCors(await handleBatch(request, env), request);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// /_batch-images — bulk *binary* fetch (images, etc). Same input shape as
|
|
88
|
+
// /_batch but each file is base64-encoded so it can ride in JSON. Used by
|
|
89
|
+
// the Foundry image cache so a 300-image sync is a handful of HTTP calls
|
|
90
|
+
// instead of 300 GETs that hit Cloudflare's per-IP rate limit.
|
|
91
|
+
if (url.pathname === "/_batch-images" && request.method === "POST") {
|
|
92
|
+
return withCors(await handleBatchBinary(request, env), request);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Skip rewriting for static-asset paths that don't have a per-role variant.
|
|
96
|
+
// Anything outside the variant tree (images, css, login page, search index
|
|
97
|
+
// for the default role on root, etc.) is served as-is by Pages.
|
|
98
|
+
if (isSharedAsset(url.pathname)) return next();
|
|
99
|
+
|
|
100
|
+
// Determine the user's role from the session cookie (default = lowest).
|
|
101
|
+
const role = await readRole(request, env);
|
|
102
|
+
|
|
103
|
+
// env.ASSETS canonicalizes URLs (strips .html, strips index.html, redirects
|
|
104
|
+
// with 308s) — passing those redirects through to the browser would expose
|
|
105
|
+
// the /_variants/<role>/ path, which the guard at the top of this function
|
|
106
|
+
// explicitly blocks. So: rewrite to a clean URL, and if ASSETS still returns
|
|
107
|
+
// a redirect, follow it server-side instead of leaking it to the client.
|
|
108
|
+
const target = "/_variants/" + role + (url.pathname === "/" ? "/" : url.pathname);
|
|
109
|
+
let rewritten = new Request(new URL(target, url.origin).toString(), request);
|
|
110
|
+
let response = await env.ASSETS.fetch(rewritten);
|
|
111
|
+
if (response.status >= 300 && response.status < 400) {
|
|
112
|
+
const location = response.headers.get("Location");
|
|
113
|
+
if (location && location.startsWith("/_variants/")) {
|
|
114
|
+
rewritten = new Request(new URL(location, url.origin).toString(), request);
|
|
115
|
+
response = await env.ASSETS.fetch(rewritten);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Replace bare 404s with the variant's styled 404 page so the reader stays
|
|
119
|
+
// inside the site (sidebar, search, sitemap intact). Only HTML navigation
|
|
120
|
+
// requests get the page; asset/API requests get the bare 404 unchanged.
|
|
121
|
+
if (response.status === 404 && wantsHtml(request)) {
|
|
122
|
+
const fallback = await env.ASSETS.fetch(new URL("/_variants/" + role + "/404.html", url.origin).toString());
|
|
123
|
+
if (fallback.ok) {
|
|
124
|
+
return withCors(new Response(await fallback.text(), {
|
|
125
|
+
status: 404,
|
|
126
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
127
|
+
}), request);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return withCors(response, request);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Headers added for cross-origin clients (Foundry module, MCP). Origin: *
|
|
134
|
+
// is safe because the resources are gated by bearer token, not by origin.
|
|
135
|
+
function corsHeaders() {
|
|
136
|
+
return {
|
|
137
|
+
"Access-Control-Allow-Origin": "*",
|
|
138
|
+
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
|
|
139
|
+
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
|
140
|
+
"Access-Control-Max-Age": "86400",
|
|
141
|
+
"Vary": "Origin",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function withCors(response, request) {
|
|
146
|
+
if (!request.headers.get("Origin")) return response;
|
|
147
|
+
// Response from env.ASSETS.fetch is immutable — clone before mutating.
|
|
148
|
+
const headers = new Headers(response.headers);
|
|
149
|
+
for (const [k, v] of Object.entries(corsHeaders())) headers.set(k, v);
|
|
150
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function wantsHtml(request) {
|
|
154
|
+
const accept = request.headers.get("Accept") || "";
|
|
155
|
+
return accept.includes("text/html") || accept === "*/*" || accept === "";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Login ─────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
async function handleLogin(request, env) {
|
|
161
|
+
const form = await request.formData();
|
|
162
|
+
const role = String(form.get("role") || "");
|
|
163
|
+
const password = String(form.get("password") || "");
|
|
164
|
+
const next = safeNext(form.get("next"));
|
|
165
|
+
|
|
166
|
+
if (!ROLES.includes(role) || !PASSWORDS[role]) {
|
|
167
|
+
return loginRedirect(next, "invalid_role");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const ok = await verifyPassword(password, PASSWORDS[role]);
|
|
171
|
+
if (!ok) return loginRedirect(next, "wrong_password");
|
|
172
|
+
|
|
173
|
+
const cookie = await signSessionCookie(role, env.SESSION_SECRET);
|
|
174
|
+
const headers = new Headers({ Location: next });
|
|
175
|
+
headers.append("Set-Cookie", cookie);
|
|
176
|
+
headers.append("Set-Cookie", DISPLAY_COOKIE_NAME + "=" + encodeURIComponent(role)
|
|
177
|
+
+ "; Path=/; Secure; SameSite=None; Partitioned; Max-Age=" + COOKIE_MAX_AGE);
|
|
178
|
+
return new Response(null, { status: 302, headers });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Sanitise a 'next' redirect target. Only same-origin relative paths are
|
|
182
|
+
// allowed — anything else (absolute URLs, protocol-relative '//evil.com',
|
|
183
|
+
// the protected /_variants/ tree) is replaced with '/'. Prevents the login
|
|
184
|
+
// and logout endpoints from being weaponised as open redirects.
|
|
185
|
+
function safeNext(value) {
|
|
186
|
+
const s = typeof value === "string" ? value : "";
|
|
187
|
+
if (!s.startsWith("/")) return "/";
|
|
188
|
+
if (s.startsWith("//")) return "/";
|
|
189
|
+
if (s.startsWith("/_variants/")) return "/";
|
|
190
|
+
return s;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function loginRedirect(next, error) {
|
|
194
|
+
const url = "/login.html?error=" + encodeURIComponent(error) + "&next=" + encodeURIComponent(next);
|
|
195
|
+
return new Response(null, { status: 302, headers: { Location: url } });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Batch source fetch ────────────────────────────────────────────────────
|
|
199
|
+
//
|
|
200
|
+
// Bulk-read endpoint used by sync clients to avoid making one HTTP request
|
|
201
|
+
// per .md file. The body is newline-separated paths (text/plain, so the
|
|
202
|
+
// request stays CORS-simple — no preflight). The handler resolves each
|
|
203
|
+
// path against the caller's role variant and bundles the results into a
|
|
204
|
+
// single JSON response.
|
|
205
|
+
//
|
|
206
|
+
// Caps:
|
|
207
|
+
// - max 200 paths per call (cap concurrent ASSETS reads inside the worker)
|
|
208
|
+
// - paths must not contain '..' or query/fragment, must not start with '_'
|
|
209
|
+
// (would escape the variant or hit metadata files)
|
|
210
|
+
|
|
211
|
+
const BATCH_MAX_PATHS = 200;
|
|
212
|
+
// Smaller cap for binary — base64 inflates ~4/3x and we don't want to
|
|
213
|
+
// blow the worker response budget. ~30 images at 200KB avg ≈ 8MB JSON.
|
|
214
|
+
const BATCH_BINARY_MAX_PATHS = 30;
|
|
215
|
+
|
|
216
|
+
async function handleBatch(request, env) {
|
|
217
|
+
return handleBatchInner(request, env, BATCH_MAX_PATHS, async (res) => res.text());
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function handleBatchBinary(request, env) {
|
|
221
|
+
return handleBatchInner(request, env, BATCH_BINARY_MAX_PATHS, async (res) => {
|
|
222
|
+
return base64Encode(new Uint8Array(await res.arrayBuffer()));
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function handleBatchInner(request, env, maxPaths, encode) {
|
|
227
|
+
const role = await readRole(request, env);
|
|
228
|
+
if (!ROLES.includes(role)) return batchError(401, "Unauthorized");
|
|
229
|
+
|
|
230
|
+
let body;
|
|
231
|
+
try { body = await request.text(); }
|
|
232
|
+
catch { return batchError(400, "Could not read request body."); }
|
|
233
|
+
|
|
234
|
+
const paths = body.split(/\\r?\\n/).map((s) => s.trim()).filter(Boolean);
|
|
235
|
+
if (paths.length === 0) return batchError(400, "No paths in request body.");
|
|
236
|
+
if (paths.length > maxPaths) return batchError(400, "Too many paths (max " + maxPaths + ").");
|
|
237
|
+
for (const p of paths) {
|
|
238
|
+
if (!isSafePath(p)) return batchError(400, "Invalid path: " + p);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ASSETS.fetch is internal to the worker, so fan-out is cheap.
|
|
242
|
+
const url = new URL(request.url);
|
|
243
|
+
const entries = await Promise.all(paths.map(async (p) => {
|
|
244
|
+
const target = new URL("/_variants/" + role + "/" + encodeVariantPath(p), url.origin).toString();
|
|
245
|
+
const res = await env.ASSETS.fetch(target);
|
|
246
|
+
if (!res.ok) return [p, null];
|
|
247
|
+
return [p, await encode(res)];
|
|
248
|
+
}));
|
|
249
|
+
|
|
250
|
+
const files = {};
|
|
251
|
+
const missing = [];
|
|
252
|
+
for (const [p, content] of entries) {
|
|
253
|
+
if (content == null) missing.push(p);
|
|
254
|
+
else files[p] = content;
|
|
255
|
+
}
|
|
256
|
+
return new Response(JSON.stringify({ files, missing }), {
|
|
257
|
+
headers: { "Content-Type": "application/json" },
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function batchError(status, message) {
|
|
262
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
263
|
+
status, headers: { "Content-Type": "application/json" },
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function base64Encode(bytes) {
|
|
268
|
+
// btoa wants a binary string. Build it in chunks so we don't blow the
|
|
269
|
+
// call-stack on String.fromCharCode for big payloads.
|
|
270
|
+
let binary = "";
|
|
271
|
+
const CHUNK = 0x8000;
|
|
272
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
273
|
+
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
|
|
274
|
+
}
|
|
275
|
+
return btoa(binary);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function isSafePath(p) {
|
|
279
|
+
if (!p || p.length > 1024) return false;
|
|
280
|
+
if (p.includes("..") || p.includes("?") || p.includes("#")) return false;
|
|
281
|
+
if (p.startsWith("/") || p.startsWith("_")) return false;
|
|
282
|
+
// Reject control chars / bare backslashes.
|
|
283
|
+
if (/[\\x00-\\x1f]/.test(p) || p.includes("\\\\")) return false;
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function encodeVariantPath(p) {
|
|
288
|
+
// Each segment is URI-encoded so spaces / unicode round-trip cleanly.
|
|
289
|
+
return p.split("/").map(encodeURIComponent).join("/");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── Connect (OAuth-style bearer token issuance) ──────────────────────────
|
|
293
|
+
|
|
294
|
+
async function handleConnectGet(request, env) {
|
|
295
|
+
const url = new URL(request.url);
|
|
296
|
+
const returnTo = url.searchParams.get("return_to") || "";
|
|
297
|
+
const app = url.searchParams.get("app") || "an external app";
|
|
298
|
+
const state = url.searchParams.get("state") || "";
|
|
299
|
+
|
|
300
|
+
if (!returnTo || !isValidReturnTo(returnTo)) {
|
|
301
|
+
return new Response("Invalid or missing return_to. Must be an http(s) URL.", { status: 400 });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Require login first — the user's role is what we're authorising.
|
|
305
|
+
const role = await readRole(request, env);
|
|
306
|
+
const isLoggedIn = role !== ROLES[0] || PASSWORDS[role] != null;
|
|
307
|
+
if (!isLoggedIn || role === ROLES[0]) {
|
|
308
|
+
// Default role; redirect to login first, come back here on success.
|
|
309
|
+
const next = url.pathname + url.search;
|
|
310
|
+
return new Response(null, {
|
|
311
|
+
status: 302,
|
|
312
|
+
headers: { Location: "/login.html?next=" + encodeURIComponent(next) },
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const returnHost = (() => {
|
|
317
|
+
try { return new URL(returnTo).host; } catch { return returnTo; }
|
|
318
|
+
})();
|
|
319
|
+
const html = renderApprovePage({ app, role, returnTo, returnHost, state });
|
|
320
|
+
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function handleConnectApprove(request, env) {
|
|
324
|
+
const form = await request.formData();
|
|
325
|
+
const returnTo = String(form.get("return_to") || "");
|
|
326
|
+
const state = String(form.get("state") || "");
|
|
327
|
+
|
|
328
|
+
if (!returnTo || !isValidReturnTo(returnTo)) {
|
|
329
|
+
return new Response("Invalid return_to.", { status: 400 });
|
|
330
|
+
}
|
|
331
|
+
const role = await readRole(request, env);
|
|
332
|
+
if (role === ROLES[0]) {
|
|
333
|
+
// User is anonymous; reject (shouldn't reach here via normal UI flow).
|
|
334
|
+
return new Response("Not signed in.", { status: 401 });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const token = await signToken(role, env.SESSION_SECRET, BEARER_MAX_AGE);
|
|
338
|
+
// Render a page that delivers the token via postMessage when running
|
|
339
|
+
// inside an iframe or popup — that avoids a cross-site top-level
|
|
340
|
+
// navigation back to the host app, which can blow away SPA sessions
|
|
341
|
+
// (Foundry logs the user out on full reloads). Falls back to a top-
|
|
342
|
+
// level redirect with the token in the query string for CLI / direct
|
|
343
|
+
// browser flows that have neither a parent nor an opener.
|
|
344
|
+
const html = renderConnectDeliveryPage({ token, state, returnTo });
|
|
345
|
+
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function renderConnectDeliveryPage({ token, state, returnTo }) {
|
|
349
|
+
const returnOrigin = (() => { try { return new URL(returnTo).origin; } catch { return ""; } })();
|
|
350
|
+
const tokenAttr = escAttr(token);
|
|
351
|
+
const stateAttr = escAttr(state);
|
|
352
|
+
const originAttr = escAttr(returnOrigin);
|
|
353
|
+
const returnAttr = escAttr(returnTo);
|
|
354
|
+
return \`<!doctype html>
|
|
355
|
+
<html lang="en">
|
|
356
|
+
<head>
|
|
357
|
+
<meta charset="utf-8">
|
|
358
|
+
<title>Connecting…</title>
|
|
359
|
+
<link rel="stylesheet" href="/styles.css">
|
|
360
|
+
<style>
|
|
361
|
+
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; font-family: inherit; }
|
|
362
|
+
.card { max-width: 24rem; padding: 2rem; text-align: center; color: var(--muted); }
|
|
363
|
+
.card h1 { margin: 0 0 0.5rem; font-size: 1.1rem; color: var(--fg); }
|
|
364
|
+
.card a { color: var(--accent); }
|
|
365
|
+
</style>
|
|
366
|
+
</head>
|
|
367
|
+
<body>
|
|
368
|
+
<div class="card">
|
|
369
|
+
<h1>Authorising…</h1>
|
|
370
|
+
<p id="status">You can close this window.</p>
|
|
371
|
+
<p><a id="manual" href="#">Continue manually</a> if it doesn't close on its own.</p>
|
|
372
|
+
</div>
|
|
373
|
+
<script>
|
|
374
|
+
(function () {
|
|
375
|
+
var token = "\${tokenAttr}";
|
|
376
|
+
var state = "\${stateAttr}";
|
|
377
|
+
var origin = "\${originAttr}";
|
|
378
|
+
var returnTo = "\${returnAttr}";
|
|
379
|
+
|
|
380
|
+
// Iframe flow: parent window is the host (Foundry inside DialogV2).
|
|
381
|
+
// Popup flow: opener is the host. In both cases we postMessage to the
|
|
382
|
+
// host's origin (not '*') so the token can't be intercepted by anything
|
|
383
|
+
// else with a window reference.
|
|
384
|
+
var target = null;
|
|
385
|
+
if (window.parent && window.parent !== window) target = window.parent;
|
|
386
|
+
else if (window.opener && !window.opener.closed) target = window.opener;
|
|
387
|
+
if (target && origin) {
|
|
388
|
+
try {
|
|
389
|
+
target.postMessage({ type: "vaults-connect", token: token, state: state }, origin);
|
|
390
|
+
// Popup self-closes; iframe is dismissed by the host on receipt.
|
|
391
|
+
if (target === window.opener) { try { window.close(); } catch (e) {} }
|
|
392
|
+
return;
|
|
393
|
+
} catch (e) { /* fall through to redirect */ }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// No parent or opener — fall back to the original redirect flow.
|
|
397
|
+
var sep = returnTo.indexOf("?") === -1 ? "?" : "&";
|
|
398
|
+
var target = returnTo + sep + "token=" + encodeURIComponent(token)
|
|
399
|
+
+ (state ? "&state=" + encodeURIComponent(state) : "");
|
|
400
|
+
document.getElementById("manual").href = target;
|
|
401
|
+
document.getElementById("status").textContent = "Redirecting back…";
|
|
402
|
+
window.location.replace(target);
|
|
403
|
+
})();
|
|
404
|
+
</script>
|
|
405
|
+
</body>
|
|
406
|
+
</html>\`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function isValidReturnTo(s) {
|
|
410
|
+
try {
|
|
411
|
+
const u = new URL(s);
|
|
412
|
+
return u.protocol === "http:" || u.protocol === "https:";
|
|
413
|
+
} catch { return false; }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function renderApprovePage({ app, role, returnTo, returnHost, state }) {
|
|
417
|
+
const escapedApp = escHtml(app);
|
|
418
|
+
const escapedRole = escHtml(role);
|
|
419
|
+
const escapedHost = escHtml(returnHost);
|
|
420
|
+
const escapedReturnTo = escAttr(returnTo);
|
|
421
|
+
const escapedState = escAttr(state);
|
|
422
|
+
return \`<!doctype html>
|
|
423
|
+
<html lang="en">
|
|
424
|
+
<head>
|
|
425
|
+
<meta charset="utf-8">
|
|
426
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
427
|
+
<title>Authorize \${escapedApp}</title>
|
|
428
|
+
<link rel="stylesheet" href="/styles.css">
|
|
429
|
+
<style>
|
|
430
|
+
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
|
431
|
+
.approve-card {
|
|
432
|
+
max-width: 28rem; width: 90%; padding: 2rem;
|
|
433
|
+
border: 1px solid var(--rule); border-radius: 6px; background: var(--bg);
|
|
434
|
+
}
|
|
435
|
+
.approve-card h1 { margin: 0 0 1rem; font-size: 1.4rem; }
|
|
436
|
+
.approve-card .info { color: var(--muted); font-size: 0.9rem; margin: 0 0 0.75rem; }
|
|
437
|
+
.approve-card .info strong { color: var(--accent); }
|
|
438
|
+
.approve-card .return-host {
|
|
439
|
+
display: block; margin: 0.75rem 0 1.25rem;
|
|
440
|
+
padding: 0.5rem 0.75rem; background: var(--wikilink-bg);
|
|
441
|
+
border: 1px solid var(--rule); border-radius: 4px;
|
|
442
|
+
font-family: ui-monospace, monospace; font-size: 0.85rem;
|
|
443
|
+
word-break: break-all;
|
|
444
|
+
}
|
|
445
|
+
.approve-card .actions { display: flex; gap: 0.75rem; margin-top: 1.25rem; }
|
|
446
|
+
.approve-card button, .approve-card .deny {
|
|
447
|
+
flex: 1; padding: 0.55rem 1rem; font: inherit; font-size: 0.95rem;
|
|
448
|
+
border: 1px solid var(--rule); border-radius: 4px; cursor: pointer;
|
|
449
|
+
text-align: center; text-decoration: none;
|
|
450
|
+
}
|
|
451
|
+
.approve-card button { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
|
452
|
+
.approve-card .deny { background: var(--bg); color: var(--muted); }
|
|
453
|
+
.approve-card .warning { color: #b94a3a; font-size: 0.78rem; margin-top: 1rem; }
|
|
454
|
+
</style>
|
|
455
|
+
</head>
|
|
456
|
+
<body>
|
|
457
|
+
<form class="approve-card" method="POST" action="/connect/approve">
|
|
458
|
+
<h1>Authorize \${escapedApp}</h1>
|
|
459
|
+
<p class="info">
|
|
460
|
+
<strong>\${escapedApp}</strong> wants access to your vault as
|
|
461
|
+
<strong>\${escapedRole}</strong>.
|
|
462
|
+
</p>
|
|
463
|
+
<p class="info">After approval, you'll be redirected to:</p>
|
|
464
|
+
<code class="return-host">\${escapedHost}</code>
|
|
465
|
+
<p class="warning">
|
|
466
|
+
⚠ Verify the destination above looks right. If you didn't initiate
|
|
467
|
+
this request, click Deny.
|
|
468
|
+
</p>
|
|
469
|
+
<input type="hidden" name="return_to" value="\${escapedReturnTo}">
|
|
470
|
+
<input type="hidden" name="state" value="\${escapedState}">
|
|
471
|
+
<div class="actions">
|
|
472
|
+
<a class="deny" href="/">Deny</a>
|
|
473
|
+
<button type="submit">Approve</button>
|
|
474
|
+
</div>
|
|
475
|
+
</form>
|
|
476
|
+
</body>
|
|
477
|
+
</html>\`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function escHtml(s) { return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); }
|
|
481
|
+
function escAttr(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); }
|
|
482
|
+
|
|
483
|
+
// ── Cookie + role lookup ──────────────────────────────────────────────────
|
|
484
|
+
|
|
485
|
+
async function readRole(request, env) {
|
|
486
|
+
const fallback = ROLES[0];
|
|
487
|
+
if (!env.SESSION_SECRET) return fallback;
|
|
488
|
+
|
|
489
|
+
// Authorization: Bearer <token> — used by curl / the MCP server / any
|
|
490
|
+
// client that can set request headers freely. Same signed-token format
|
|
491
|
+
// as the cookie, so verification is shared.
|
|
492
|
+
const auth = request.headers.get("Authorization") || "";
|
|
493
|
+
const bearerMatch = /^Bearer\\s+(.+)$/i.exec(auth);
|
|
494
|
+
if (bearerMatch) {
|
|
495
|
+
const role = await verifyToken(bearerMatch[1], env.SESSION_SECRET);
|
|
496
|
+
if (role && ROLES.includes(role)) return role;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ?_token=<token> — used by the Foundry module so cross-origin GETs stay
|
|
500
|
+
// CORS-simple and don't trigger a preflight per file (Cloudflare rate-
|
|
501
|
+
// limits OPTIONS bursts and a sync is hundreds of unique URLs).
|
|
502
|
+
const queryToken = new URL(request.url).searchParams.get("_token");
|
|
503
|
+
if (queryToken) {
|
|
504
|
+
const role = await verifyToken(queryToken, env.SESSION_SECRET);
|
|
505
|
+
if (role && ROLES.includes(role)) return role;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const cookie = parseCookie(request.headers.get("Cookie") || "")[COOKIE_NAME];
|
|
509
|
+
if (!cookie) return fallback;
|
|
510
|
+
const role = await verifyToken(cookie, env.SESSION_SECRET);
|
|
511
|
+
return role && ROLES.includes(role) ? role : fallback;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Format: <role>.<expiryUnix>.<hmacHex>
|
|
515
|
+
async function signToken(role, secret, maxAgeSeconds) {
|
|
516
|
+
const exp = Math.floor(Date.now() / 1000) + maxAgeSeconds;
|
|
517
|
+
const payload = role + "." + exp;
|
|
518
|
+
const sig = await hmac(payload, secret);
|
|
519
|
+
return payload + "." + sig;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function signSessionCookie(role, secret) {
|
|
523
|
+
const value = await signToken(role, secret, COOKIE_MAX_AGE);
|
|
524
|
+
// SameSite=None + Partitioned (CHIPS) — required so the cookie persists
|
|
525
|
+
// when the vault is loaded inside a cross-origin iframe (the Foundry
|
|
526
|
+
// connect dialog). Partitioned scopes the cookie per parent origin, so
|
|
527
|
+
// it isn't a general third-party tracking cookie. Top-level browsing
|
|
528
|
+
// still works normally.
|
|
529
|
+
return COOKIE_NAME + "=" + value
|
|
530
|
+
+ "; Path=/; HttpOnly; Secure; SameSite=None; Partitioned; Max-Age=" + COOKIE_MAX_AGE;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function verifyToken(token, secret) {
|
|
534
|
+
const parts = token.split(".");
|
|
535
|
+
if (parts.length !== 3) return null;
|
|
536
|
+
const [role, expStr, sig] = parts;
|
|
537
|
+
const exp = Number(expStr);
|
|
538
|
+
if (!Number.isFinite(exp) || exp < Math.floor(Date.now() / 1000)) return null;
|
|
539
|
+
const expected = await hmac(role + "." + expStr, secret);
|
|
540
|
+
return constantTimeEqual(sig, expected) ? role : null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function clearCookieVariants(name) {
|
|
544
|
+
// Browsers match cookies for deletion on (Path, Domain, Secure, SameSite,
|
|
545
|
+
// Partitioned). A single Set-Cookie attempt only matches one configuration,
|
|
546
|
+
// so we emit two — one that matches cookies set by the current
|
|
547
|
+
// (SameSite=None+Partitioned) signSessionCookie, and one that matches the
|
|
548
|
+
// older Lax form. Both are safe to send; the unmatched one is a no-op.
|
|
549
|
+
const httpOnly = name === COOKIE_NAME ? "HttpOnly; " : "";
|
|
550
|
+
return [
|
|
551
|
+
name + "=; Path=/; " + httpOnly + "Secure; SameSite=None; Partitioned; Max-Age=0",
|
|
552
|
+
name + "=; Path=/; " + httpOnly + "Secure; SameSite=Lax; Max-Age=0",
|
|
553
|
+
];
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ── Crypto primitives (Web Crypto API) ────────────────────────────────────
|
|
557
|
+
|
|
558
|
+
async function hmac(message, secretHex) {
|
|
559
|
+
const keyBytes = fromHex(secretHex);
|
|
560
|
+
const key = await crypto.subtle.importKey(
|
|
561
|
+
"raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
|
|
562
|
+
);
|
|
563
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
|
|
564
|
+
return toHex(new Uint8Array(sig));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function verifyPassword(password, encoded) {
|
|
568
|
+
const parts = encoded.split(":");
|
|
569
|
+
if (parts.length !== 3) return false;
|
|
570
|
+
const iterations = Number(parts[0]) || PBKDF2_DEFAULT_ITERATIONS;
|
|
571
|
+
const salt = fromHex(parts[1]);
|
|
572
|
+
const expected = fromHex(parts[2]);
|
|
573
|
+
const key = await crypto.subtle.importKey(
|
|
574
|
+
"raw", new TextEncoder().encode(password), { name: "PBKDF2" }, false, ["deriveBits"],
|
|
575
|
+
);
|
|
576
|
+
const bits = await crypto.subtle.deriveBits(
|
|
577
|
+
{ name: "PBKDF2", salt, hash: "SHA-256", iterations },
|
|
578
|
+
key, expected.length * 8,
|
|
579
|
+
);
|
|
580
|
+
return constantTimeEqual(toHex(new Uint8Array(bits)), toHex(expected));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function constantTimeEqual(a, b) {
|
|
584
|
+
if (a.length !== b.length) return false;
|
|
585
|
+
let diff = 0;
|
|
586
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
587
|
+
return diff === 0;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function toHex(bytes) {
|
|
591
|
+
let s = "";
|
|
592
|
+
for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, "0");
|
|
593
|
+
return s;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function fromHex(hex) {
|
|
597
|
+
const out = new Uint8Array(hex.length / 2);
|
|
598
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
599
|
+
return out;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function parseCookie(header) {
|
|
603
|
+
const out = {};
|
|
604
|
+
for (const part of header.split(/;\\s*/)) {
|
|
605
|
+
const eq = part.indexOf("=");
|
|
606
|
+
if (eq > 0) out[part.slice(0, eq)] = part.slice(eq + 1);
|
|
607
|
+
}
|
|
608
|
+
return out;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function isSharedAsset(pathname) {
|
|
612
|
+
// Allowlist of root-served files that are intentionally public to every
|
|
613
|
+
// visitor (no role gate). Everything else — including images — goes
|
|
614
|
+
// through the variant rewrite so role-restricted content is structurally
|
|
615
|
+
// unreachable on under-tier deploys.
|
|
616
|
+
if (pathname === "/styles.css") return true;
|
|
617
|
+
if (pathname === "/user.css") return true;
|
|
618
|
+
if (pathname === "/login.html") return true;
|
|
619
|
+
if (pathname === "/favicon.ico" || pathname === "/favicon.svg") return true;
|
|
620
|
+
if (pathname === "/robots.txt") return true;
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
`;
|
|
624
|
+
}
|
|
625
|
+
export const LOGIN_HTML = `<!doctype html>
|
|
626
|
+
<html lang="en">
|
|
627
|
+
<head>
|
|
628
|
+
<meta charset="utf-8">
|
|
629
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
630
|
+
<title>Sign in</title>
|
|
631
|
+
<link rel="stylesheet" href="/styles.css">
|
|
632
|
+
<style>
|
|
633
|
+
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
|
634
|
+
.login-card {
|
|
635
|
+
max-width: 24rem; width: 90%; padding: 2rem;
|
|
636
|
+
border: 1px solid var(--rule); border-radius: 6px; background: var(--bg);
|
|
637
|
+
}
|
|
638
|
+
.login-card h1 { margin: 0 0 1rem; font-size: 1.4rem; }
|
|
639
|
+
.login-card label { display: block; font-size: 0.85rem; color: var(--muted); margin: 0.75rem 0 0.25rem; }
|
|
640
|
+
.login-card select, .login-card input[type=password] {
|
|
641
|
+
width: 100%; padding: 0.5rem 0.65rem; font: inherit; font-size: 0.95rem;
|
|
642
|
+
background: var(--bg); color: var(--fg);
|
|
643
|
+
border: 1px solid var(--rule); border-radius: 4px; outline: none;
|
|
644
|
+
}
|
|
645
|
+
.login-card select:focus, .login-card input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--wikilink-bg); }
|
|
646
|
+
.login-card button {
|
|
647
|
+
width: 100%; padding: 0.55rem 1rem; margin-top: 1rem; font: inherit;
|
|
648
|
+
background: var(--accent); color: var(--accent-fg); border: 0; border-radius: 4px; cursor: pointer;
|
|
649
|
+
}
|
|
650
|
+
.login-error { color: #b94a3a; font-size: 0.85rem; margin: 0.75rem 0 0; }
|
|
651
|
+
</style>
|
|
652
|
+
</head>
|
|
653
|
+
<body>
|
|
654
|
+
<form class="login-card" method="POST" action="/login">
|
|
655
|
+
<h1>Sign in</h1>
|
|
656
|
+
<p id="err" class="login-error" hidden></p>
|
|
657
|
+
<label for="role">Role</label>
|
|
658
|
+
<select id="role" name="role">__ROLE_OPTIONS__</select>
|
|
659
|
+
<label for="password">Password</label>
|
|
660
|
+
<input id="password" type="password" name="password" autocomplete="current-password" required autofocus>
|
|
661
|
+
<input type="hidden" name="next" id="next">
|
|
662
|
+
<button type="submit">Sign in</button>
|
|
663
|
+
</form>
|
|
664
|
+
<script>
|
|
665
|
+
const params = new URLSearchParams(location.search);
|
|
666
|
+
const next = params.get("next") || "/";
|
|
667
|
+
document.getElementById("next").value = next;
|
|
668
|
+
const err = params.get("error");
|
|
669
|
+
if (err) {
|
|
670
|
+
const el = document.getElementById("err");
|
|
671
|
+
el.textContent = err === "wrong_password" ? "Wrong password." : "Invalid role.";
|
|
672
|
+
el.hidden = false;
|
|
673
|
+
}
|
|
674
|
+
</script>
|
|
675
|
+
</body>
|
|
676
|
+
</html>`;
|
|
677
|
+
//# sourceMappingURL=auth-template.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-template.js","sourceRoot":"","sources":["../../src/render/auth-template.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,+EAA+E;AAC/E,2EAA2E;AAC3E,mEAAmE;AASnE,MAAM,UAAU,oBAAoB,CAAC,GAAuB;IAC1D,MAAM,YAAY,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IAC/C,MAAM,gBAAgB,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;IAE3D,OAAO;;;;;gBAKO,YAAY;oBACR,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAimBnC,CAAC;AACF,CAAC;AAED,MAAM,CAAC,MAAM,UAAU,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAmDlB,CAAC"}
|