better-dsh 0.2.4-c → 0.2.5-a

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.
@@ -0,0 +1,562 @@
1
+ import { createHash, createHmac, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { readFile } from "node:fs/promises";
4
+ import { credentialKey } from "@deepseek-ai/dsh-credentials";
5
+
6
+ //#region src/web-password.ts
7
+ const DAY_MILLISECONDS = 1440 * 60 * 1e3;
8
+ /** Length of the native browser-session HMAC key. */
9
+ const SECRET_BYTES = 32;
10
+ /** Native cookie name prefix; the rest is the base64url SHA-256 of the authority. */
11
+ const COOKIE_PREFIX = "dsh-auth-";
12
+ /** Native cookie envelope version. */
13
+ const COOKIE_PAYLOAD_VERSION = 1;
14
+ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/;
15
+ const SALT_BYTES = 16;
16
+ const HASH_BYTES = 32;
17
+ const SCRYPT_N = 16384;
18
+ const SCRYPT_R = 8;
19
+ const SCRYPT_P = 1;
20
+ const PASSWORD_RECORD_VERSION = 1;
21
+ /** The seeded password a fresh home starts with (no setup step). */
22
+ const DEFAULT_PASSWORD = "admin";
23
+ const DEFAULT_COOKIE_MAX_AGE_DAYS = 30;
24
+ /**
25
+ * Native `browser-auth` rejects a cookie whose lifetime exceeds its own
26
+ * `cookieMaxAgeDays` (default 30), so a longer lifetime here would mint a
27
+ * cookie the verifier refuses. Cap at the upstream default.
28
+ */
29
+ const MAX_COOKIE_MAX_AGE_DAYS = 30;
30
+ /** A login form is a few hundred bytes; anything larger is not one. */
31
+ const MAX_FORM_BYTES = 4096;
32
+ /** Failed attempts tolerated per peer before the backoff starts. */
33
+ const FREE_FAILURES = 5;
34
+ const BASE_BACKOFF_MILLISECONDS = 2e3;
35
+ const MAX_BACKOFF_MILLISECONDS = 6e4;
36
+ /** The native browser-session signing secret (owned by `client-connection`). */
37
+ const SESSION_RECORD_KEY = credentialKey("client-connection", "browser-session");
38
+ /** This row's own password record. */
39
+ const PASSWORD_RECORD_KEY = credentialKey("dashr-web-password", "passphrase");
40
+ function isRecord(value) {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value);
42
+ }
43
+ /** base64url-encode bytes without padding (the native cookie's alphabet). */
44
+ function encodeBase64Url(value) {
45
+ return Buffer.from(value).toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
46
+ }
47
+ /**
48
+ * Decode a canonical base64url string; non-canonical input is rejected rather
49
+ * than silently normalized, which is what makes a tampered payload fail closed.
50
+ * @param value - the candidate string.
51
+ * @returns the bytes, or `undefined` when it is not canonical base64url.
52
+ */
53
+ function decodeBase64Url(value) {
54
+ if (!BASE64URL_PATTERN.test(value) || value.length % 4 === 1) return void 0;
55
+ const padding = "=".repeat((4 - value.length % 4) % 4);
56
+ const decoded = Buffer.from(value.replaceAll("-", "+").replaceAll("_", "/") + padding, "base64");
57
+ return encodeBase64Url(decoded) === value ? decoded : void 0;
58
+ }
59
+ /**
60
+ * The native cookie name for one request authority.
61
+ * @param authority - canonical `host` or `host:port`.
62
+ * @returns the cookie name.
63
+ */
64
+ function cookieName(authority) {
65
+ return COOKIE_PREFIX + encodeBase64Url(createHash("sha256").update(authority).digest());
66
+ }
67
+ function signature(secret, body) {
68
+ return createHmac("sha256", secret).update(body).digest();
69
+ }
70
+ /**
71
+ * Mint a native-format browser-session cookie value.
72
+ * @param payload - the session claims.
73
+ * @param secret - the native signing key.
74
+ * @returns `v1.<base64url payload>.<base64url HMAC>`.
75
+ */
76
+ function encodeSessionCookie(payload, secret) {
77
+ const body = encodeBase64Url(Buffer.from(JSON.stringify(payload), "utf8"));
78
+ return `v1.${body}.${encodeBase64Url(signature(secret, body))}`;
79
+ }
80
+ /**
81
+ * Verify and decode a native-format browser-session cookie value.
82
+ * @param value - the cookie value.
83
+ * @param secret - the native signing key.
84
+ * @returns the claims, or `undefined` when the envelope, signature, or shape fails.
85
+ */
86
+ function decodeSessionCookie(value, secret) {
87
+ const parts = value.split(".");
88
+ const [version, body, encodedSignature] = parts;
89
+ if (parts.length !== 3 || version !== "v1" || body === void 0 || encodedSignature === void 0) return;
90
+ const actualSignature = decodeBase64Url(encodedSignature);
91
+ if (actualSignature === void 0) return void 0;
92
+ const expectedSignature = signature(secret, body);
93
+ if (actualSignature.byteLength !== expectedSignature.byteLength || !timingSafeEqual(actualSignature, expectedSignature)) return void 0;
94
+ let decoded;
95
+ try {
96
+ const bodyBytes = decodeBase64Url(body);
97
+ if (bodyBytes === void 0) return void 0;
98
+ decoded = JSON.parse(bodyBytes.toString("utf8"));
99
+ } catch {
100
+ return;
101
+ }
102
+ if (!isRecord(decoded) || decoded.version !== COOKIE_PAYLOAD_VERSION || typeof decoded.authority !== "string" || !Number.isSafeInteger(decoded.issuedAt) || !Number.isSafeInteger(decoded.expiresAt)) return void 0;
103
+ return decoded;
104
+ }
105
+ function header(headers, name$1) {
106
+ const value = headers[name$1];
107
+ return typeof value === "string" ? value : void 0;
108
+ }
109
+ /**
110
+ * Canonical request authority: the native cookie name and its signed audience.
111
+ * @param headers - the incoming request headers.
112
+ * @returns `host` or `host:port`, lowercased, or `undefined` without a usable Host.
113
+ */
114
+ function requestAuthority(headers) {
115
+ const host = header(headers, "host");
116
+ if (host === void 0) return void 0;
117
+ try {
118
+ return new URL(`http://${host}`).host;
119
+ } catch {
120
+ return;
121
+ }
122
+ }
123
+ /** Read the exact generated cookie without implementing general Cookie decoding. */
124
+ function cookieValue(headerValue, name$1) {
125
+ for (const segment of headerValue.split(";")) {
126
+ const at = segment.indexOf("=");
127
+ if (at === -1 || segment.slice(0, at).trim() !== name$1) continue;
128
+ return segment.slice(at + 1).trim();
129
+ }
130
+ }
131
+ /**
132
+ * Serialize the fixed browser-session attributes; generated names and values
133
+ * are cookie-safe base64url. Mirrors the native attribute set exactly.
134
+ */
135
+ function sessionCookieHeader(name$1, value, expiresAt, maxAgeSeconds) {
136
+ return `${name$1}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict`;
137
+ }
138
+ /**
139
+ * Verify the native browser cookie on one request, with the native checks:
140
+ * authority binding, issued/expiry window, and a lifetime no longer than ours.
141
+ * @param headers - incoming request headers carrying Host and Cookie.
142
+ * @param secret - the native signing key.
143
+ * @param maxAgeMilliseconds - the longest lifetime this deployment accepts.
144
+ * @returns true only for a live cookie signed by the native key for this authority.
145
+ */
146
+ function sessionAuthenticated(headers, secret, maxAgeMilliseconds) {
147
+ const authority = requestAuthority(headers);
148
+ const rawCookie = header(headers, "cookie");
149
+ if (authority === void 0 || rawCookie === void 0) return false;
150
+ const value = cookieValue(rawCookie, cookieName(authority));
151
+ if (value === void 0) return false;
152
+ const payload = decodeSessionCookie(value, secret);
153
+ if (payload === void 0 || payload.authority !== authority) return false;
154
+ const now = Date.now();
155
+ return payload.issuedAt <= now && payload.expiresAt > now && payload.expiresAt > payload.issuedAt && payload.expiresAt - payload.issuedAt <= maxAgeMilliseconds;
156
+ }
157
+ /**
158
+ * Derive the scrypt key for one password.
159
+ * @param password - the submitted password.
160
+ * @param payload - salt and work factors.
161
+ * @returns the derived key.
162
+ */
163
+ function derivePasswordKey(password, payload) {
164
+ const salt = decodeBase64Url(payload.salt);
165
+ if (salt === void 0) return Promise.reject(/* @__PURE__ */ new Error("dashr-web-password: stored salt is not base64url"));
166
+ return new Promise((resolve, reject) => {
167
+ scrypt(password, salt, payload.keylen, {
168
+ N: payload.N,
169
+ r: payload.r,
170
+ p: payload.p
171
+ }, (error, key) => {
172
+ if (error !== null) reject(error);
173
+ else resolve(key);
174
+ });
175
+ });
176
+ }
177
+ /**
178
+ * Constant-time password check against a stored payload.
179
+ * @param password - the submitted password.
180
+ * @param payload - the stored record.
181
+ * @returns true only on an exact match.
182
+ */
183
+ async function verifyPassword(password, payload) {
184
+ const expected = decodeBase64Url(payload.hash);
185
+ if (expected === void 0) return false;
186
+ const actual = await derivePasswordKey(password, payload);
187
+ return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected);
188
+ }
189
+ /**
190
+ * Build a fresh stored payload for one password (random salt).
191
+ * @param password - the password to store.
192
+ * @returns the payload to write into the credential record.
193
+ */
194
+ async function seedPasswordPayload(password) {
195
+ const base = {
196
+ version: PASSWORD_RECORD_VERSION,
197
+ algorithm: "scrypt",
198
+ salt: encodeBase64Url(randomBytes(SALT_BYTES)),
199
+ hash: "",
200
+ N: SCRYPT_N,
201
+ r: SCRYPT_R,
202
+ p: SCRYPT_P,
203
+ keylen: HASH_BYTES
204
+ };
205
+ const key = await derivePasswordKey(password, base);
206
+ return {
207
+ ...base,
208
+ hash: encodeBase64Url(key)
209
+ };
210
+ }
211
+ /**
212
+ * Interpret one password record; a present-but-unsupported record is loud
213
+ * (fail closed) rather than silently treated as absent.
214
+ * @param record - the stored record, when any.
215
+ * @returns the payload, or `undefined` when no record is stored.
216
+ */
217
+ function parsePasswordPayload(record) {
218
+ if (record === void 0) return void 0;
219
+ if (record.kind !== "grant" || !isRecord(record.payload) || record.payload.version !== PASSWORD_RECORD_VERSION) throw new Error("dashr-web-password: password record has an unsupported format");
220
+ const payload = record.payload;
221
+ const integers = [
222
+ payload.N,
223
+ payload.r,
224
+ payload.p,
225
+ payload.keylen
226
+ ];
227
+ if (payload.algorithm !== "scrypt" || typeof payload.salt !== "string" || typeof payload.hash !== "string" || !integers.every((value) => Number.isSafeInteger(value) && value > 0)) throw new Error("dashr-web-password: password record has an invalid payload");
228
+ if (decodeBase64Url(payload.salt) === void 0 || decodeBase64Url(payload.hash) === void 0) throw new Error("dashr-web-password: password record has invalid encoding");
229
+ return payload;
230
+ }
231
+ /**
232
+ * Read the password record, seeding the well-known default when the home has
233
+ * none. Mirrors the native secret-initialization pattern: the create is
234
+ * read-decide-replace under the provider's lock, so two processes racing on a
235
+ * fresh home converge on one record.
236
+ * @param provider - the credential seam.
237
+ * @returns the stored password payload.
238
+ */
239
+ async function loadOrSeedPassword(provider) {
240
+ const existing = parsePasswordPayload(await provider.readRecord(PASSWORD_RECORD_KEY));
241
+ if (existing !== void 0) return existing;
242
+ const generated = await seedPasswordPayload(DEFAULT_PASSWORD);
243
+ const parsed = parsePasswordPayload(await provider.modifyRecord(PASSWORD_RECORD_KEY, (current) => {
244
+ if (current !== void 0) return Promise.resolve(void 0);
245
+ return Promise.resolve({
246
+ kind: "grant",
247
+ payload: generated
248
+ });
249
+ }));
250
+ if (parsed === void 0) throw new Error("dashr-web-password: password record was not created");
251
+ return parsed;
252
+ }
253
+ /**
254
+ * Read the native browser-session signing key that `client-connection` created
255
+ * during its own activation.
256
+ * @param provider - the credential seam.
257
+ * @returns the 32-byte key.
258
+ */
259
+ async function loadSessionSecret(provider) {
260
+ const record = await provider.readRecord(SESSION_RECORD_KEY);
261
+ if (record === void 0) throw new Error("dashr-web-password: no client-connection/browser-session record; nothing to mint with");
262
+ if (record.kind !== "grant" || !isRecord(record.payload) || record.payload.version !== 1) throw new Error("dashr-web-password: browser-session record has an unsupported format");
263
+ const secret = record.payload.secret;
264
+ if (typeof secret !== "string") throw new Error("dashr-web-password: browser-session record has no secret");
265
+ const decoded = decodeBase64Url(secret);
266
+ if (decoded === void 0 || decoded.byteLength !== SECRET_BYTES) throw new Error("dashr-web-password: browser-session record has an invalid secret");
267
+ return decoded;
268
+ }
269
+ function escapeHtml(value) {
270
+ return value.replace(/[&<>"']/gu, (character) => ({
271
+ "&": "&amp;",
272
+ "<": "&lt;",
273
+ ">": "&gt;",
274
+ "\"": "&quot;",
275
+ "'": "&#39;"
276
+ })[character] ?? character);
277
+ }
278
+ /**
279
+ * The sign-in page. Server-rendered, self-contained, no external resource: it
280
+ * is the only thing an unauthenticated request ever receives.
281
+ * @param notice - optional failure text.
282
+ * @returns the complete HTML document.
283
+ */
284
+ function loginPage(notice) {
285
+ return `<!doctype html>
286
+ <html lang="en">
287
+ <head>
288
+ <meta charset="utf-8">
289
+ <meta name="viewport" content="width=device-width, initial-scale=1">
290
+ <meta name="robots" content="noindex">
291
+ <title>dsh web</title>
292
+ <style>
293
+ :root{color-scheme:dark}
294
+ *{box-sizing:border-box}
295
+ body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0f1115;color:#e6e8ec;font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
296
+ form{width:min(22rem,calc(100vw - 3rem));padding:1.75rem;border:1px solid #262b36;border-radius:14px;background:#151922}
297
+ h1{margin:0 0 .25rem;font-size:1.05rem;letter-spacing:.02em}
298
+ p.sub{margin:0 0 1.25rem;font-size:.82rem;color:#8b93a5}
299
+ label{display:block;margin-bottom:.4rem;font-size:.78rem;letter-spacing:.08em;text-transform:uppercase;color:#8b93a5}
300
+ input{width:100%;padding:.65rem .75rem;border:1px solid #2c3341;border-radius:9px;background:#0f1115;color:inherit;font:inherit}
301
+ input:focus{outline:none;border-color:#4c8dff;box-shadow:0 0 0 3px #4c8dff26}
302
+ button{width:100%;margin-top:1rem;padding:.65rem;border:0;border-radius:9px;background:#4c8dff;color:#08101f;font:inherit;font-weight:600;cursor:pointer}
303
+ button:hover{background:#6aa2ff}
304
+ .notice{margin:0 0 1rem;padding:.55rem .7rem;border:1px solid #5c2b33;border-radius:9px;background:#2a161a;color:#ffb4bd;font-size:.85rem}
305
+ </style>
306
+ </head>
307
+ <body>
308
+ <form method="post" action="/">
309
+ <h1>dsh web</h1>
310
+ <p class="sub">Enter the session password to continue.</p>
311
+ ${notice === void 0 ? "" : ` <p class="notice">${escapeHtml(notice)}</p>\n`} <label for="password">Password</label>
312
+ <input id="password" name="password" type="password" autocomplete="current-password" autofocus required>
313
+ <button type="submit">Unlock</button>
314
+ </form>
315
+ </body>
316
+ </html>
317
+ `;
318
+ }
319
+ /**
320
+ * Resolve the dist index this composition serves, through the Web runtime's
321
+ * own exported resolver. That keeps the dist anchor an assembly fact of
322
+ * `@deepseek-ai/dsh-web-app` (which derives it from the installed frontend
323
+ * package) instead of a copy of the path here. Imported lazily: this row is
324
+ * dormant in any composition without a Web runtime, and must not require that
325
+ * package to load.
326
+ * @returns the absolute path of the served index.html.
327
+ */
328
+ async function resolveDistIndex() {
329
+ return (await import("@deepseek-ai/dsh-web-app")).internals.resolveDistIndex();
330
+ }
331
+ /**
332
+ * Insert the relative base the served dist needs, exactly as the native
333
+ * fallback owner does, so every asset reference resolves under the mount.
334
+ * @param html - the rendered index markup.
335
+ * @returns the markup with `<base href="./">` after the head open tag.
336
+ */
337
+ function withBaseHref(html) {
338
+ return html.replace(/<head(?:\s[^>]*)?>/i, (open) => `${open}<base href="./">`);
339
+ }
340
+ /**
341
+ * The root-route gate: one exact `/` handler owning the door, and the only
342
+ * place a browser session is minted.
343
+ */
344
+ var PasswordGate = class {
345
+ state;
346
+ distIndex;
347
+ attempts = /* @__PURE__ */ new Map();
348
+ log;
349
+ constructor(ctx, maxAgeDays) {
350
+ this.ctx = ctx;
351
+ this.maxAgeDays = maxAgeDays;
352
+ this.log = ctx.logger("dashr-web-password");
353
+ }
354
+ /** Start the durable reads so a misconfigured home is loud at activation. */
355
+ warm() {
356
+ this.gateState().then(() => {
357
+ this.log.info("password gate armed on GET/POST /");
358
+ }, (error) => {
359
+ this.log.warn(`password gate not armed: ${messageOf(error)}`);
360
+ });
361
+ }
362
+ /** Memoized durable reads; a rejection is retained so every request fails closed. */
363
+ gateState() {
364
+ return this.state ??= (async () => ({
365
+ password: await loadOrSeedPassword(this.ctx.credentials),
366
+ secret: await loadSessionSecret(this.ctx.credentials),
367
+ maxAgeMilliseconds: this.maxAgeDays * DAY_MILLISECONDS
368
+ }))();
369
+ }
370
+ /**
371
+ * Serve the SPA index at `/` itself. The Web runtime's own resolver supplies
372
+ * the dist anchor, so these are the bytes its fallback would have served for
373
+ * this exact path — reached without a redirect, which is what keeps the
374
+ * browser URL at `/` after sign-in.
375
+ * @param res - the response this handler owns.
376
+ * @param head - true for a HEAD request (headers only).
377
+ */
378
+ async serveIndex(res, head) {
379
+ const distIndex = await (this.distIndex ??= resolveDistIndex());
380
+ const body = withBaseHref(this.ctx.webServer.renderIndex(await readFile(distIndex, "utf8")));
381
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
382
+ res.end(head ? void 0 : body);
383
+ }
384
+ /**
385
+ * Serve one request on exact `/`.
386
+ * @param req - the incoming request.
387
+ * @param res - the response this handler owns.
388
+ */
389
+ async handle(req, res) {
390
+ try {
391
+ const state = await this.gateState();
392
+ if (req.method === "POST") {
393
+ await this.handleLogin(req, res, state);
394
+ return;
395
+ }
396
+ if (req.method !== "GET" && req.method !== "HEAD") {
397
+ res.writeHead(405, {
398
+ "allow": "GET, HEAD, POST",
399
+ "cache-control": "no-store"
400
+ });
401
+ res.end();
402
+ return;
403
+ }
404
+ if (sessionAuthenticated(req.headers, state.secret, state.maxAgeMilliseconds)) {
405
+ try {
406
+ await this.serveIndex(res, req.method === "HEAD");
407
+ } catch (error) {
408
+ this.log.warn(`serving the index at / failed, redirecting instead: ${messageOf(error)}`);
409
+ this.distIndex = void 0;
410
+ redirect(res, "/index.html");
411
+ }
412
+ return;
413
+ }
414
+ const head = req.method === "HEAD";
415
+ res.writeHead(200, {
416
+ "cache-control": "no-store",
417
+ "content-type": "text/html; charset=utf-8"
418
+ });
419
+ res.end(head ? void 0 : loginPage());
420
+ } catch (error) {
421
+ this.log.warn(`password gate unavailable: ${messageOf(error)}`);
422
+ res.writeHead(503, {
423
+ "cache-control": "no-store",
424
+ "content-type": "text/plain; charset=utf-8"
425
+ });
426
+ res.end("dsh web: password gate unavailable (the browser-session secret or the password record is unreadable).\n");
427
+ }
428
+ }
429
+ async handleLogin(req, res, state) {
430
+ const authority = requestAuthority(req.headers);
431
+ const peer = `${req.socket.remoteAddress ?? "unknown"}|${authority ?? "unknown"}`;
432
+ const now = Date.now();
433
+ const blocked = this.attempts.get(peer)?.blockedUntil ?? 0;
434
+ if (blocked > now) {
435
+ const retryAfter = Math.ceil((blocked - now) / 1e3);
436
+ this.respondForm(res, 429, loginPage(`Too many attempts. Try again in ${String(retryAfter)}s.`), { "retry-after": String(retryAfter) });
437
+ return;
438
+ }
439
+ let body;
440
+ try {
441
+ body = await readFormBody(req, MAX_FORM_BYTES);
442
+ } catch {
443
+ this.respondForm(res, 413, loginPage("Request too large."));
444
+ return;
445
+ }
446
+ if (authority === void 0) {
447
+ this.respondForm(res, 400, loginPage("Malformed request."));
448
+ return;
449
+ }
450
+ const password = new URLSearchParams(body).get("password") ?? "";
451
+ if (password.length === 0 || !await verifyPassword(password, state.password)) {
452
+ this.recordFailure(peer, now);
453
+ this.log.warn(`rejected password attempt from ${peer}`);
454
+ this.respondForm(res, 401, loginPage("Wrong password."));
455
+ return;
456
+ }
457
+ this.attempts.delete(peer);
458
+ const issuedAt = Date.now();
459
+ const expiresAt = issuedAt + state.maxAgeMilliseconds;
460
+ const value = encodeSessionCookie({
461
+ version: COOKIE_PAYLOAD_VERSION,
462
+ authority,
463
+ issuedAt,
464
+ expiresAt
465
+ }, state.secret);
466
+ res.writeHead(303, {
467
+ "cache-control": "no-store",
468
+ "location": "/",
469
+ "referrer-policy": "no-referrer",
470
+ "set-cookie": sessionCookieHeader(cookieName(authority), value, expiresAt, Math.floor(state.maxAgeMilliseconds / 1e3))
471
+ });
472
+ res.end();
473
+ this.log.info(`minted a browser session for ${authority}`);
474
+ }
475
+ respondForm(res, status, html, extra = {}) {
476
+ res.writeHead(status, {
477
+ "cache-control": "no-store",
478
+ "content-type": "text/html; charset=utf-8",
479
+ ...extra
480
+ });
481
+ res.end(html);
482
+ }
483
+ recordFailure(peer, now) {
484
+ const failures = (this.attempts.get(peer)?.failures ?? 0) + 1;
485
+ const over = failures - FREE_FAILURES;
486
+ const blockedUntil = over <= 0 ? 0 : now + Math.min(BASE_BACKOFF_MILLISECONDS * 2 ** (over - 1), MAX_BACKOFF_MILLISECONDS);
487
+ this.attempts.set(peer, {
488
+ failures,
489
+ blockedUntil
490
+ });
491
+ }
492
+ };
493
+ function redirect(res, location) {
494
+ res.writeHead(303, {
495
+ "cache-control": "no-store",
496
+ "location": location,
497
+ "referrer-policy": "no-referrer"
498
+ });
499
+ res.end();
500
+ }
501
+ /** Read one urlencoded form body, refusing anything past the limit. */
502
+ function readFormBody(req, limit) {
503
+ return new Promise((resolve, reject) => {
504
+ const chunks = [];
505
+ let size = 0;
506
+ req.on("data", (chunk) => {
507
+ size += chunk.length;
508
+ if (size > limit) {
509
+ reject(/* @__PURE__ */ new Error("form body too large"));
510
+ req.destroy();
511
+ return;
512
+ }
513
+ chunks.push(chunk);
514
+ });
515
+ req.on("end", () => {
516
+ resolve(Buffer.concat(chunks).toString("utf8"));
517
+ });
518
+ req.on("error", reject);
519
+ });
520
+ }
521
+ function messageOf(error) {
522
+ return error instanceof Error ? error.message : String(error);
523
+ }
524
+ const Config = z.object({ cookieMaxAgeDays: z.natural().min(1).max(MAX_COOKIE_MAX_AGE_DAYS).default(DEFAULT_COOKIE_MAX_AGE_DAYS) });
525
+ /** Cordis plugin name; also the scope of this row's credential record. */
526
+ const name = "dashr-web-password";
527
+ /**
528
+ * No static injects: the gate is composed around `webServer`, `connection`
529
+ * (which owns the session secret), and `credentials`, so a composition without
530
+ * a browser surface loads this row dormant.
531
+ */
532
+ const inject = [];
533
+ /**
534
+ * Claim exact `/` and put the password in front of it.
535
+ * @param ctx - host plugin context.
536
+ * @param config - the row config.
537
+ */
538
+ function apply(ctx, config) {
539
+ const maxAgeDays = config?.cookieMaxAgeDays ?? DEFAULT_COOKIE_MAX_AGE_DAYS;
540
+ ctx.inject([
541
+ "webServer",
542
+ "connection",
543
+ "credentials"
544
+ ], (scoped) => {
545
+ const gate = new PasswordGate(scoped, maxAgeDays);
546
+ gate.warm();
547
+ scoped.effect(() => scoped.webServer.register({
548
+ kind: "exact",
549
+ path: "/",
550
+ handler: (req, res) => gate.handle(req, res)
551
+ }), "dashr-web-password: root gate");
552
+ });
553
+ }
554
+ var web_password_default = {
555
+ name,
556
+ inject,
557
+ Config,
558
+ apply
559
+ };
560
+
561
+ //#endregion
562
+ export { Config, apply, cookieName, decodeBase64Url, decodeSessionCookie, web_password_default as default, derivePasswordKey, encodeBase64Url, encodeSessionCookie, inject, loginPage, name, parsePasswordPayload, requestAuthority, seedPasswordPayload, sessionAuthenticated, sessionCookieHeader, verifyPassword, withBaseHref };
@@ -744,4 +744,4 @@ function wrapFsWithSchemes(fs, services) {
744
744
  }
745
745
 
746
746
  //#endregion
747
- export { createHttpHandler as a, listDvcDevices as c, UrlResolver as d, UrlSchemesError as f, HTTP_SCHEMES as i, registerDvcDevice as l, SCHEME_NAMES as m, wrapFsWithSchemes as n, createDvcHandler as o, parseUrl as p, resolveDocsDir as r, dispatchDvcWrite as s, buildFsLayerResolver as t, createDshHandler as u };
747
+ export { createHttpHandler as a, listDvcDevices as c, UrlResolver as d, UrlSchemesError as f, SCHEME_NAMES as h, HTTP_SCHEMES as i, registerDvcDevice as l, parseUrl as m, wrapFsWithSchemes as n, createDvcHandler as o, applySelector as p, resolveDocsDir as r, dispatchDvcWrite as s, buildFsLayerResolver as t, createDshHandler as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-dsh",
3
- "version": "0.2.4-c",
3
+ "version": "0.2.5-a",
4
4
  "description": "Make your dsh ready for serious coding tasks. (Tools x Schemas)^REPL. `skill://`, `ctx://`, `agent://`, `dvc://`, `dsh://`, IPython REPL, Context as Variables, cross compactions recallable, full context revive. `hash-edit`, `dvc://browser`, subagent as function, workflow as function.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -42,6 +42,10 @@
42
42
  "types": "./lib/fs-aware/sandbox-plugin.d.ts",
43
43
  "default": "./lib/fs-aware/sandbox-plugin.js"
44
44
  },
45
+ "./web-password": {
46
+ "types": "./lib/web-password.d.ts",
47
+ "default": "./lib/web-password.js"
48
+ },
45
49
  "./cordis.patch.yml": "./cordis.patch.yml",
46
50
  "./package.json": "./package.json",
47
51
  "./client": {
@@ -132,7 +136,7 @@
132
136
  "@deepseek-ai/dsh-tools": ">=0.1.5-0 <0.3.0-0",
133
137
  "@deepseek-ai/dsh-user-approval": ">=0.1.5-0 <0.3.0-0",
134
138
  "@deepseek-ai/dsh-workflow-worker-thread": ">=0.1.5-0 <0.3.0-0",
135
- "@deepseek-ai/schemastery": "^3.18.1",
139
+ "@deepseek-ai/schemastery": "^3.18.3",
136
140
  "@deepseek-ai/dsh-api-gateway": ">=0.1.5-0 <0.3.0-0",
137
141
  "@deepseek-ai/dsh-api-remotes": ">=0.1.5-0 <0.3.0-0",
138
142
  "@deepseek-ai/dsh-client-connection": ">=0.1.5-0 <0.3.0-0",
@@ -143,7 +147,10 @@
143
147
  "@deepseek-ai/dsh-client-ui-settings-plugins": ">=0.1.5-0 <0.3.0-0",
144
148
  "@deepseek-ai/dsh-client-ui-slots": ">=0.1.5-0 <0.3.0-0",
145
149
  "@deepseek-ai/dsh-commands": ">=0.1.5-0 <0.3.0-0",
150
+ "@deepseek-ai/dsh-credentials": ">=0.1.5-0 <0.3.0-0",
151
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.5-0 <0.3.0-0",
146
152
  "@deepseek-ai/dsh-typert-protocol": ">=0.1.5-0 <0.3.0-0",
153
+ "@deepseek-ai/dsh-web-app": ">=0.1.5-0 <0.3.0-0",
147
154
  "@deepseek-ai/dsh-typert-registry": ">=0.1.5-0 <0.3.0-0",
148
155
  "react": "^18.2.0"
149
156
  },
@@ -310,6 +317,15 @@
310
317
  "@deepseek-ai/dsh-commands": {
311
318
  "optional": true
312
319
  },
320
+ "@deepseek-ai/dsh-credentials": {
321
+ "optional": true
322
+ },
323
+ "@deepseek-ai/dsh-host-webserver": {
324
+ "optional": true
325
+ },
326
+ "@deepseek-ai/dsh-web-app": {
327
+ "optional": true
328
+ },
313
329
  "@deepseek-ai/dsh-typert-protocol": {
314
330
  "optional": true
315
331
  },
@@ -321,7 +337,6 @@
321
337
  }
322
338
  },
323
339
  "dependencies": {
324
- "@deepseek-ai/schemastery": "^3.18.1",
325
340
  "diff": "^8.0.2",
326
341
  "file-type": "^21.3.0",
327
342
  "puppeteer-core": "25.3.0",