cursedbelt-server 4.21.0 → 4.23.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/dist/server/bench/budget.d.ts +50 -0
- package/dist/server/bench/budget.js +49 -1
- package/dist/server/bench/index.d.ts +1 -1
- package/dist/server/bench/index.js +1 -1
- package/dist/server/d1/localBackup.d.ts +1 -1
- package/dist/server/d1/localBackup.js +1 -1
- package/dist/server/d1/pullD1.d.ts +24 -0
- package/dist/server/d1/pullD1.js +114 -40
- package/dist/server/master-lock/accountsPage.js +7 -3
- package/dist/server/master-lock/lockPage.d.ts +20 -1
- package/dist/server/master-lock/lockPage.js +53 -2
- package/dist/server/metrics/metricsBuffer.d.ts +10 -0
- package/dist/server/metrics/metricsBuffer.js +11 -3
- package/dist/server/metrics/requestPlace.d.ts +48 -0
- package/dist/server/metrics/requestPlace.js +77 -0
- package/dist/server/metrics/telemetrySink.d.ts +6 -1
- package/dist/server/metrics/telemetrySink.js +14 -2
- package/dist/server/middleware/requestLogger.d.ts +8 -0
- package/dist/server/middleware/requestLogger.js +4 -1
- package/dist/server/requestLog/requestMetrics.d.ts +11 -2
- package/dist/server/requestLog/requestMetrics.js +22 -4
- package/dist/server/telemetry.d.ts +1 -0
- package/dist/server/telemetry.js +1 -0
- package/package.json +3 -3
- package/src/server/bench/budget.spec.ts +38 -0
- package/src/server/bench/budget.ts +58 -1
- package/src/server/bench/cpuBudget.spec.ts +15 -3
- package/src/server/bench/index.ts +1 -0
- package/src/server/d1/localBackup.ts +4 -0
- package/src/server/d1/pullD1.spec.ts +62 -2
- package/src/server/d1/pullD1.ts +111 -42
- package/src/server/master-lock/accountsPage.ts +7 -3
- package/src/server/master-lock/lockPage.spec.ts +62 -2
- package/src/server/master-lock/lockPage.ts +58 -2
- package/src/server/metrics/metricsBuffer.ts +22 -2
- package/src/server/metrics/requestPlace.spec.ts +142 -0
- package/src/server/metrics/requestPlace.ts +92 -0
- package/src/server/metrics/telemetrySink.spec.ts +2 -1
- package/src/server/metrics/telemetrySink.ts +14 -2
- package/src/server/middleware/requestLogger.ts +12 -1
- package/src/server/requestLog/requestMetrics.spec.ts +73 -0
- package/src/server/requestLog/requestMetrics.ts +37 -4
- package/src/server/telemetry.ts +5 -0
- package/src/server/telemetryIsWorkerSafe.spec.ts +18 -0
|
@@ -74,6 +74,47 @@ export declare const DAYS_PER_MONTH = 30.4375;
|
|
|
74
74
|
* the default may never drift ABOVE the measured allowance without a red build.
|
|
75
75
|
*/
|
|
76
76
|
export declare const MEASURED_FLEET_REQUESTS_PER_DAY = 42971;
|
|
77
|
+
/**
|
|
78
|
+
* 🔴 The WORKER re-derivation — 2026-09-23 (task 083, bullet 3). Cloudflare's own numbers,
|
|
79
|
+
* not a proxy: the account's `workersInvocationsAdaptive` dataset (GraphQL Analytics API,
|
|
80
|
+
* `sum { requests cpuTimeUs }` by `scriptName`) over the seven whole days
|
|
81
|
+
* 2026-09-16T00:00Z → 2026-09-23T00:00Z, while `patterns` (since 09-18) and `collections`
|
|
82
|
+
* (since 09-22) served from Workers:
|
|
83
|
+
*
|
|
84
|
+
* ```
|
|
85
|
+
* script requests billed CPU-ms mean
|
|
86
|
+
* binary-server-edge-gate 754,974 1,805,750 2.39
|
|
87
|
+
* collections 350 171,228 489.2 (09-22: p99 15.2 s — one-off imports)
|
|
88
|
+
* collections-stage 61 96,081 1575 (stage: seeding and gate runs)
|
|
89
|
+
* patterns 424 12,718 30.0 (09-18: p99 2.9 s, first boot)
|
|
90
|
+
* 3 others 196 126
|
|
91
|
+
* ACCOUNT 756,005 2,085,903 2.76 → 108,001 req/day, 9.07 M CPU-ms/month (30 %)
|
|
92
|
+
* ```
|
|
93
|
+
*
|
|
94
|
+
* **What the call says.** `deriveCpuBudgetMs({ requestsPerDay: 108_001 })` = **9.1**, i.e.
|
|
95
|
+
* BELOW the shipped 9.9 — the account already carries more requests than the owner's
|
|
96
|
+
* 100,000/day projection. **The default did not move, and this is why:** 99.9 % of those
|
|
97
|
+
* requests are the binary server's edge gate, a fixed-cost auth check at 2.39 CPU-ms mean
|
|
98
|
+
* that no app route budget governs. Averaging them in hands the app routes an allowance
|
|
99
|
+
* those requests never use. The question the default answers is what an APP request may
|
|
100
|
+
* spend once the measured non-app spend is set aside:
|
|
101
|
+
* `deriveCpuBudgetMs({ requestsPerDay: MEASURED_FLEET_REQUESTS_PER_DAY, reservedCpuMsPerMonth: edge gate })`
|
|
102
|
+
* = **16.9** CPU-ms — the fleet's whole app traffic (42,971/day, above) on Workers, with the
|
|
103
|
+
* edge gate still running beside it. 9.9 stays under that with 1.7× headroom, and the
|
|
104
|
+
* account is at 30 % of its CPU allowance, so there is no evidence for tightening it either.
|
|
105
|
+
* App-Worker traffic itself (~111/day, mostly agent deploy checks) is too small to derive
|
|
106
|
+
* anything from yet. `budget.spec.ts` holds both orderings as assertions.
|
|
107
|
+
*/
|
|
108
|
+
export declare const MEASURED_WORKER_TRAFFIC: {
|
|
109
|
+
readonly from: "2026-09-16T00:00:00Z";
|
|
110
|
+
readonly until: "2026-09-23T00:00:00Z";
|
|
111
|
+
readonly days: 7;
|
|
112
|
+
readonly requests: 756005;
|
|
113
|
+
readonly cpuMs: 2085903;
|
|
114
|
+
/** The binary server's edge gate — fixed-cost traffic no app route budget governs. */
|
|
115
|
+
readonly edgeGateRequests: 754974;
|
|
116
|
+
readonly edgeGateCpuMs: 1805750;
|
|
117
|
+
};
|
|
77
118
|
/**
|
|
78
119
|
* Derive the average CPU-ms a single request may spend before the fleet exceeds its
|
|
79
120
|
* included CPU allowance.
|
|
@@ -90,6 +131,12 @@ export declare function deriveCpuBudgetMs(opts?: {
|
|
|
90
131
|
requestsPerMonth?: number;
|
|
91
132
|
/** Measured (or projected) requests per day — converted with {@link DAYS_PER_MONTH}. */
|
|
92
133
|
requestsPerDay?: number;
|
|
134
|
+
/**
|
|
135
|
+
* CPU-ms per month already spent by traffic OUTSIDE the budgeted routes (measured —
|
|
136
|
+
* the edge gate in {@link MEASURED_WORKER_TRAFFIC}), taken off the allowance first.
|
|
137
|
+
* Default: 0.
|
|
138
|
+
*/
|
|
139
|
+
reservedCpuMsPerMonth?: number;
|
|
93
140
|
}): number;
|
|
94
141
|
/**
|
|
95
142
|
* What a month costs in overage once the included allowances are spent. Used by the
|
|
@@ -112,6 +159,9 @@ export declare function monthlyOverageUsd(opts: {
|
|
|
112
159
|
*
|
|
113
160
|
* 🔴 Deliberately kept BELOW the ~22.9 ms that {@link MEASURED_FLEET_REQUESTS_PER_DAY}
|
|
114
161
|
* derives; that constant says why, and `budget.spec.ts` reddens if the two ever swap.
|
|
162
|
+
*
|
|
163
|
+
* Re-derived from WORKER traffic on 2026-09-23 and kept: {@link MEASURED_WORKER_TRAFFIC}
|
|
164
|
+
* has the numbers (16.9 ms per app request after the edge gate's measured spend).
|
|
115
165
|
*/
|
|
116
166
|
export declare const DEFAULT_ROUTE_CPU_BUDGET_MS: number;
|
|
117
167
|
/** A route that is measured against a number. */
|
|
@@ -74,6 +74,47 @@ export const DAYS_PER_MONTH = 30.4375;
|
|
|
74
74
|
* the default may never drift ABOVE the measured allowance without a red build.
|
|
75
75
|
*/
|
|
76
76
|
export const MEASURED_FLEET_REQUESTS_PER_DAY = 42_971;
|
|
77
|
+
/**
|
|
78
|
+
* 🔴 The WORKER re-derivation — 2026-09-23 (task 083, bullet 3). Cloudflare's own numbers,
|
|
79
|
+
* not a proxy: the account's `workersInvocationsAdaptive` dataset (GraphQL Analytics API,
|
|
80
|
+
* `sum { requests cpuTimeUs }` by `scriptName`) over the seven whole days
|
|
81
|
+
* 2026-09-16T00:00Z → 2026-09-23T00:00Z, while `patterns` (since 09-18) and `collections`
|
|
82
|
+
* (since 09-22) served from Workers:
|
|
83
|
+
*
|
|
84
|
+
* ```
|
|
85
|
+
* script requests billed CPU-ms mean
|
|
86
|
+
* binary-server-edge-gate 754,974 1,805,750 2.39
|
|
87
|
+
* collections 350 171,228 489.2 (09-22: p99 15.2 s — one-off imports)
|
|
88
|
+
* collections-stage 61 96,081 1575 (stage: seeding and gate runs)
|
|
89
|
+
* patterns 424 12,718 30.0 (09-18: p99 2.9 s, first boot)
|
|
90
|
+
* 3 others 196 126
|
|
91
|
+
* ACCOUNT 756,005 2,085,903 2.76 → 108,001 req/day, 9.07 M CPU-ms/month (30 %)
|
|
92
|
+
* ```
|
|
93
|
+
*
|
|
94
|
+
* **What the call says.** `deriveCpuBudgetMs({ requestsPerDay: 108_001 })` = **9.1**, i.e.
|
|
95
|
+
* BELOW the shipped 9.9 — the account already carries more requests than the owner's
|
|
96
|
+
* 100,000/day projection. **The default did not move, and this is why:** 99.9 % of those
|
|
97
|
+
* requests are the binary server's edge gate, a fixed-cost auth check at 2.39 CPU-ms mean
|
|
98
|
+
* that no app route budget governs. Averaging them in hands the app routes an allowance
|
|
99
|
+
* those requests never use. The question the default answers is what an APP request may
|
|
100
|
+
* spend once the measured non-app spend is set aside:
|
|
101
|
+
* `deriveCpuBudgetMs({ requestsPerDay: MEASURED_FLEET_REQUESTS_PER_DAY, reservedCpuMsPerMonth: edge gate })`
|
|
102
|
+
* = **16.9** CPU-ms — the fleet's whole app traffic (42,971/day, above) on Workers, with the
|
|
103
|
+
* edge gate still running beside it. 9.9 stays under that with 1.7× headroom, and the
|
|
104
|
+
* account is at 30 % of its CPU allowance, so there is no evidence for tightening it either.
|
|
105
|
+
* App-Worker traffic itself (~111/day, mostly agent deploy checks) is too small to derive
|
|
106
|
+
* anything from yet. `budget.spec.ts` holds both orderings as assertions.
|
|
107
|
+
*/
|
|
108
|
+
export const MEASURED_WORKER_TRAFFIC = {
|
|
109
|
+
from: '2026-09-16T00:00:00Z',
|
|
110
|
+
until: '2026-09-23T00:00:00Z',
|
|
111
|
+
days: 7,
|
|
112
|
+
requests: 756_005,
|
|
113
|
+
cpuMs: 2_085_903,
|
|
114
|
+
/** The binary server's edge gate — fixed-cost traffic no app route budget governs. */
|
|
115
|
+
edgeGateRequests: 754_974,
|
|
116
|
+
edgeGateCpuMs: 1_805_750,
|
|
117
|
+
};
|
|
77
118
|
/**
|
|
78
119
|
* Derive the average CPU-ms a single request may spend before the fleet exceeds its
|
|
79
120
|
* included CPU allowance.
|
|
@@ -84,7 +125,11 @@ export const MEASURED_FLEET_REQUESTS_PER_DAY = 42_971;
|
|
|
84
125
|
* allowed to become the common case.
|
|
85
126
|
*/
|
|
86
127
|
export function deriveCpuBudgetMs(opts = {}) {
|
|
87
|
-
const
|
|
128
|
+
const reserved = opts.reservedCpuMsPerMonth ?? 0;
|
|
129
|
+
const includedCpuMs = (opts.includedCpuMs ?? WORKERS_PAID_INCLUDED_CPU_MS) - reserved;
|
|
130
|
+
if (!(reserved >= 0) || !(includedCpuMs > 0)) {
|
|
131
|
+
throw new Error(`deriveCpuBudgetMs: reservedCpuMsPerMonth (${reserved}) must be ≥ 0 and leave some allowance`);
|
|
132
|
+
}
|
|
88
133
|
const requestsPerMonth = opts.requestsPerMonth ??
|
|
89
134
|
(opts.requestsPerDay ?? OWNER_PROJECTED_REQUESTS_PER_DAY) * DAYS_PER_MONTH;
|
|
90
135
|
if (!(requestsPerMonth > 0)) {
|
|
@@ -113,6 +158,9 @@ export function monthlyOverageUsd(opts) {
|
|
|
113
158
|
*
|
|
114
159
|
* 🔴 Deliberately kept BELOW the ~22.9 ms that {@link MEASURED_FLEET_REQUESTS_PER_DAY}
|
|
115
160
|
* derives; that constant says why, and `budget.spec.ts` reddens if the two ever swap.
|
|
161
|
+
*
|
|
162
|
+
* Re-derived from WORKER traffic on 2026-09-23 and kept: {@link MEASURED_WORKER_TRAFFIC}
|
|
163
|
+
* has the numbers (16.9 ms per app request after the edge gate's measured spend).
|
|
116
164
|
*/
|
|
117
165
|
export const DEFAULT_ROUTE_CPU_BUDGET_MS = Math.round(deriveCpuBudgetMs() * 10) / 10;
|
|
118
166
|
export function isExemption(b) {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* alternative is a number that gets quoted as a billing fact.
|
|
34
34
|
*/
|
|
35
35
|
export { type AssertCpuBudgetsOpts, assertCpuBudgets, checkCpuBudgets, type CpuViolation, type CpuViolationKind, formatCpuBudgetReport, } from './assert.js';
|
|
36
|
-
export { type CpuBudgetConfig, type CpuBudgetExemption, type CpuBudgetLimit, DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, type ResolvedBudget, resolveBudget, type RouteBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget.js';
|
|
36
|
+
export { type CpuBudgetConfig, type CpuBudgetExemption, type CpuBudgetLimit, DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, MEASURED_WORKER_TRAFFIC, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, type ResolvedBudget, resolveBudget, type RouteBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget.js';
|
|
37
37
|
export { type CpuBudgetOpts, cpuBudget } from './cpuBudget.js';
|
|
38
38
|
export { type CpuClock, type CpuSpan, fixedCpuClock, processCpuClock, resolveCpuClock, unavailableCpuClock, workerCpuClock, } from './cpuClock.js';
|
|
39
39
|
export { type CpuBudgetReport, type CpuRecorder, type CpuRecorderOpts, createCpuRecorder, percentile, type RouteCpuStats, } from './recorder.js';
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* alternative is a number that gets quoted as a billing fact.
|
|
34
34
|
*/
|
|
35
35
|
export { assertCpuBudgets, checkCpuBudgets, formatCpuBudgetReport, } from './assert.js';
|
|
36
|
-
export { DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, resolveBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget.js';
|
|
36
|
+
export { DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, MEASURED_WORKER_TRAFFIC, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, resolveBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget.js';
|
|
37
37
|
export { cpuBudget } from './cpuBudget.js';
|
|
38
38
|
export { fixedCpuClock, processCpuClock, resolveCpuClock, unavailableCpuClock, workerCpuClock, } from './cpuClock.js';
|
|
39
39
|
export { createCpuRecorder, percentile, } from './recorder.js';
|
|
@@ -82,4 +82,4 @@ export declare function createLocalBackup(opts: LocalBackupOpts): DatabaseBackup
|
|
|
82
82
|
* a host that genuinely has both.
|
|
83
83
|
*/
|
|
84
84
|
export declare function backupFor(flavor: 'local' | 'd1', local: () => LocalBackupOpts, remote: () => TimeTravelOpts): DatabaseBackup;
|
|
85
|
-
export { type BackupSourceChoice, type BackupSourceInput, backupTables, chooseBackupSource, type PullD1Options, pullD1ToSqlite, servedRuntime, type Wrangler, } from './pullD1.js';
|
|
85
|
+
export { type BackupSourceChoice, type BackupSourceInput, backupTables, chooseBackupSource, type PullD1Options, insertLines, pullD1ToSqlite, servedRuntime, spawnWrangler, stripTrailingNuls, type Wrangler, wranglerArgv, } from './pullD1.js';
|
|
@@ -111,4 +111,4 @@ export function backupFor(flavor, local, remote) {
|
|
|
111
111
|
// After a Worker cutover the live rows are D1's, and a Mac-hosted backup must pull them down
|
|
112
112
|
// rather than snapshot the frozen file — see `./pullD1.ts`. Here, not in the `./d1` barrel,
|
|
113
113
|
// for the reason this file's header gives: it reaches `bun:sqlite`.
|
|
114
|
-
export { backupTables, chooseBackupSource, pullD1ToSqlite, servedRuntime, } from './pullD1.js';
|
|
114
|
+
export { backupTables, chooseBackupSource, insertLines, pullD1ToSqlite, servedRuntime, spawnWrangler, stripTrailingNuls, wranglerArgv, } from './pullD1.js';
|
|
@@ -56,6 +56,30 @@ export type Wrangler = (args: string[]) => {
|
|
|
56
56
|
stdout: string;
|
|
57
57
|
stderr: string;
|
|
58
58
|
};
|
|
59
|
+
/**
|
|
60
|
+
* The argv that runs `wrangler`: THIS bun binary's `x`, never a bare `bunx` — a launchd job's
|
|
61
|
+
* PATH has no `bunx` (task 2112), and vault's and patterns' nightly backups each carried this
|
|
62
|
+
* line until 4.22.0.
|
|
63
|
+
*/
|
|
64
|
+
export declare const wranglerArgv: (bun?: string) => string[];
|
|
65
|
+
/** The real {@link Wrangler}: `bun x wrangler …` in `cwd`, with `env` added to this process's. */
|
|
66
|
+
export declare function spawnWrangler(options: {
|
|
67
|
+
cwd: string;
|
|
68
|
+
env?: Record<string, string | undefined>;
|
|
69
|
+
}): Wrangler;
|
|
70
|
+
/**
|
|
71
|
+
* 🔴 Trailing NUL bytes off a `wrangler d1 export` file, in place. Returns how many were removed.
|
|
72
|
+
*
|
|
73
|
+
* Measured 2026-09-23 on vault's D1 (wrangler 4.136.3): `wrangler d1 export --table … --output`
|
|
74
|
+
* INTERMITTENTLY ends the file in hundreds of NUL bytes (`entries` +988, `oplog` +1,369, `sync_ops`
|
|
75
|
+
* +5,476 across three runs — a different table each time, always trailing). `bun:sqlite`'s `exec`
|
|
76
|
+
* reads SQL as a C string, so the first NUL silently ended the load: every table after it came back
|
|
77
|
+
* EMPTY with no error, and vault's real backup held `vault_keys` = 0 rows (task 2128). Only
|
|
78
|
+
* TRAILING bytes: a NUL inside the SQL is a different fault, and the row-count check refuses it.
|
|
79
|
+
*/
|
|
80
|
+
export declare function stripTrailingNuls(file: string): number;
|
|
81
|
+
/** `^INSERT INTO ` lines — one per row in a `wrangler d1 export --no-schema` dump. */
|
|
82
|
+
export declare const insertLines: (dump: string) => number;
|
|
59
83
|
/**
|
|
60
84
|
* The tables a snapshot carries, from D1's own `sqlite_master` rows — every real table, and
|
|
61
85
|
* none of what cannot or must not be re-inserted: a virtual table and its shadows (`_config`,
|
package/dist/server/d1/pullD1.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* (`./http.ts` has the measurement), and a restore must put back what was there.
|
|
28
28
|
*/
|
|
29
29
|
import { Database } from 'bun:sqlite';
|
|
30
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
30
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
31
31
|
/** Which side a backup must read. Pure, so every branch is testable without a network. */
|
|
32
32
|
export function chooseBackupSource(input) {
|
|
33
33
|
if (input.fromFileFlag) {
|
|
@@ -84,6 +84,48 @@ export async function servedRuntime(url, { attempts = 3, sleep = (ms) => new Pro
|
|
|
84
84
|
}
|
|
85
85
|
/** How many times one table's export is asked for when wrangler says success and writes nothing. */
|
|
86
86
|
const EXPORT_ATTEMPTS = 3;
|
|
87
|
+
/**
|
|
88
|
+
* The argv that runs `wrangler`: THIS bun binary's `x`, never a bare `bunx` — a launchd job's
|
|
89
|
+
* PATH has no `bunx` (task 2112), and vault's and patterns' nightly backups each carried this
|
|
90
|
+
* line until 4.22.0.
|
|
91
|
+
*/
|
|
92
|
+
export const wranglerArgv = (bun = process.execPath) => [bun, 'x', 'wrangler'];
|
|
93
|
+
/** The real {@link Wrangler}: `bun x wrangler …` in `cwd`, with `env` added to this process's. */
|
|
94
|
+
export function spawnWrangler(options) {
|
|
95
|
+
return (args) => {
|
|
96
|
+
const r = Bun.spawnSync([...wranglerArgv(), ...args], {
|
|
97
|
+
cwd: options.cwd,
|
|
98
|
+
env: { ...process.env, ...options.env },
|
|
99
|
+
stdout: 'pipe',
|
|
100
|
+
stderr: 'pipe',
|
|
101
|
+
});
|
|
102
|
+
return { code: r.exitCode ?? 1, stdout: r.stdout.toString(), stderr: r.stderr.toString() };
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 🔴 Trailing NUL bytes off a `wrangler d1 export` file, in place. Returns how many were removed.
|
|
107
|
+
*
|
|
108
|
+
* Measured 2026-09-23 on vault's D1 (wrangler 4.136.3): `wrangler d1 export --table … --output`
|
|
109
|
+
* INTERMITTENTLY ends the file in hundreds of NUL bytes (`entries` +988, `oplog` +1,369, `sync_ops`
|
|
110
|
+
* +5,476 across three runs — a different table each time, always trailing). `bun:sqlite`'s `exec`
|
|
111
|
+
* reads SQL as a C string, so the first NUL silently ended the load: every table after it came back
|
|
112
|
+
* EMPTY with no error, and vault's real backup held `vault_keys` = 0 rows (task 2128). Only
|
|
113
|
+
* TRAILING bytes: a NUL inside the SQL is a different fault, and the row-count check refuses it.
|
|
114
|
+
*/
|
|
115
|
+
export function stripTrailingNuls(file) {
|
|
116
|
+
if (!existsSync(file))
|
|
117
|
+
return 0;
|
|
118
|
+
const bytes = readFileSync(file);
|
|
119
|
+
let end = bytes.length;
|
|
120
|
+
while (end > 0 && bytes[end - 1] === 0)
|
|
121
|
+
end--;
|
|
122
|
+
if (end === bytes.length)
|
|
123
|
+
return 0;
|
|
124
|
+
writeFileSync(file, bytes.subarray(0, end));
|
|
125
|
+
return bytes.length - end;
|
|
126
|
+
}
|
|
127
|
+
/** `^INSERT INTO ` lines — one per row in a `wrangler d1 export --no-schema` dump. */
|
|
128
|
+
export const insertLines = (dump) => (dump.match(/^INSERT INTO /gm) ?? []).length;
|
|
87
129
|
/**
|
|
88
130
|
* The tables a snapshot carries, from D1's own `sqlite_master` rows — every real table, and
|
|
89
131
|
* none of what cannot or must not be re-inserted: a virtual table and its shadows (`_config`,
|
|
@@ -123,50 +165,82 @@ export async function pullD1ToSqlite(options) {
|
|
|
123
165
|
throw new Error(`D1 lists no \`${options.requiredTable}\` table (saw: ${rows.map((r) => r.name).join(', ') || 'nothing'})`);
|
|
124
166
|
}
|
|
125
167
|
let sql = '';
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
exported =
|
|
136
|
-
|
|
137
|
-
|
|
168
|
+
const dumps = [];
|
|
169
|
+
try {
|
|
170
|
+
for (const table of tables) {
|
|
171
|
+
const dump = `${destination}.${table}.sql`;
|
|
172
|
+
// 🔴 `wrangler d1 export` has been measured answering SUCCESS and writing no file —
|
|
173
|
+
// collections' nightly of 2026-09-23T15:35Z refused on `derivative_retries`, and the same
|
|
174
|
+
// command wrote the file (32 bytes, an empty table) an hour later. So a missing file is
|
|
175
|
+
// asked for again, twice, before it is a refusal; a failure that persists still refuses,
|
|
176
|
+
// because a snapshot missing a table is not a backup.
|
|
177
|
+
let exported = { code: 1, stdout: '', stderr: 'not attempted' };
|
|
178
|
+
for (let attempt = 1; attempt <= EXPORT_ATTEMPTS; attempt++) {
|
|
179
|
+
exported = wrangler(['d1', 'export', database, '--remote', '--env=', '--table', table, '--no-schema', '--output', dump, '-y']);
|
|
180
|
+
if (exported.code !== 0 || existsSync(dump))
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
if (exported.code !== 0) {
|
|
184
|
+
throw new Error(`wrangler d1 export --table ${table} failed: ${exported.stderr.trim() || exported.stdout.trim()}`);
|
|
185
|
+
}
|
|
186
|
+
if (!existsSync(dump)) {
|
|
187
|
+
throw new Error(`wrangler d1 export --table ${table} reported success ${EXPORT_ATTEMPTS} times and wrote no file at ${dump}`);
|
|
188
|
+
}
|
|
189
|
+
stripTrailingNuls(dump);
|
|
190
|
+
const text = readFileSync(dump, 'utf8');
|
|
191
|
+
dumps.push({ table, text });
|
|
192
|
+
sql += `${text}\n`;
|
|
138
193
|
}
|
|
139
|
-
if (
|
|
140
|
-
throw new Error(
|
|
194
|
+
if (!/INSERT INTO/i.test(sql)) {
|
|
195
|
+
throw new Error('the D1 export contains no rows — refusing to snapshot an empty database as if it were the library');
|
|
141
196
|
}
|
|
142
|
-
|
|
143
|
-
|
|
197
|
+
const db = new Database(destination, { create: true });
|
|
198
|
+
try {
|
|
199
|
+
// `exec`, not `run`: each is many statements and `run` would execute only the first,
|
|
200
|
+
// leaving a file with a schema and no rows that looks perfectly valid.
|
|
201
|
+
db.exec(options.schemaSql);
|
|
202
|
+
// 🔴 A table D1 holds that the schema does not declare is created from D1's OWN DDL, never
|
|
203
|
+
// dropped: measured on music's first post-cutover pull (2026-09-23), `art_lookups` — a
|
|
204
|
+
// cache its art job creates where it first needs it — made the whole pull refuse with
|
|
205
|
+
// `no such table`, because its rows were exported and its table was not in the schema.
|
|
206
|
+
const have = new Set(db.query("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
|
|
207
|
+
for (const row of rows) {
|
|
208
|
+
if (!tables.includes(row.name) || have.has(row.name) || !row.sql)
|
|
209
|
+
continue;
|
|
210
|
+
db.exec(row.sql.replace(/^\s*CREATE\s+TABLE\s+(?!IF\s+NOT\s+EXISTS)/i, 'CREATE TABLE IF NOT EXISTS '));
|
|
211
|
+
}
|
|
212
|
+
// One table at a time, so a fault is located rather than smeared over the whole load.
|
|
213
|
+
for (const { text } of dumps)
|
|
214
|
+
if (text.trim())
|
|
215
|
+
db.exec(text);
|
|
216
|
+
if (options.afterLoadSql)
|
|
217
|
+
db.exec(options.afterLoadSql);
|
|
218
|
+
// 🔴 Every table holds as many rows as its dump has INSERTs, or this is not a backup: the
|
|
219
|
+
// check that makes a silently truncated load — the NUL padding, or whatever does it next —
|
|
220
|
+
// a REFUSAL. A row count only; nothing here reads what the rows hold.
|
|
221
|
+
const short = [];
|
|
222
|
+
for (const { table, text } of dumps) {
|
|
223
|
+
if (table === 'sqlite_sequence')
|
|
224
|
+
continue;
|
|
225
|
+
const expected = insertLines(text);
|
|
226
|
+
const loaded = db.query(`SELECT COUNT(*) AS n FROM "${table.replaceAll('"', '""')}"`).get().n;
|
|
227
|
+
if (loaded < expected)
|
|
228
|
+
short.push(`${table}: ${loaded} of ${expected}`);
|
|
229
|
+
}
|
|
230
|
+
if (short.length > 0) {
|
|
231
|
+
db.close();
|
|
232
|
+
rmSync(destination, { force: true });
|
|
233
|
+
throw new Error(`the D1 pull did not load every exported row — ${short.join('; ')}. Refusing the snapshot.`);
|
|
234
|
+
}
|
|
144
235
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (!/INSERT INTO/i.test(sql)) {
|
|
148
|
-
throw new Error('the D1 export contains no rows — refusing to snapshot an empty database as if it were the library');
|
|
149
|
-
}
|
|
150
|
-
const db = new Database(destination, { create: true });
|
|
151
|
-
try {
|
|
152
|
-
// `exec`, not `run`: each is many statements and `run` would execute only the first,
|
|
153
|
-
// leaving a file with a schema and no rows that looks perfectly valid.
|
|
154
|
-
db.exec(options.schemaSql);
|
|
155
|
-
// 🔴 A table D1 holds that the schema does not declare is created from D1's OWN DDL, never
|
|
156
|
-
// dropped: measured on music's first post-cutover pull (2026-09-23), `art_lookups` — a
|
|
157
|
-
// cache its art job creates where it first needs it — made the whole pull refuse with
|
|
158
|
-
// `no such table`, because its rows were exported and its table was not in the schema.
|
|
159
|
-
const have = new Set(db.query("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
|
|
160
|
-
for (const row of rows) {
|
|
161
|
-
if (!tables.includes(row.name) || have.has(row.name) || !row.sql)
|
|
162
|
-
continue;
|
|
163
|
-
db.exec(row.sql.replace(/^\s*CREATE\s+TABLE\s+(?!IF\s+NOT\s+EXISTS)/i, 'CREATE TABLE IF NOT EXISTS '));
|
|
236
|
+
finally {
|
|
237
|
+
db.close();
|
|
164
238
|
}
|
|
165
|
-
db.exec(sql);
|
|
166
|
-
if (options.afterLoadSql)
|
|
167
|
-
db.exec(options.afterLoadSql);
|
|
168
239
|
}
|
|
169
240
|
finally {
|
|
170
|
-
|
|
241
|
+
// The per-table dumps are a second, unmanaged copy of the data that no retention rule
|
|
242
|
+
// would ever delete — eighteen were found beside vault's snapshot (task 2128).
|
|
243
|
+
for (const table of tables)
|
|
244
|
+
rmSync(`${destination}.${table}.sql`, { force: true });
|
|
171
245
|
}
|
|
172
246
|
}
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* only be watching one of them.
|
|
34
34
|
*/
|
|
35
35
|
import { MASTER_LOCK_PATHS, MASTER_LOCK_PREFIX } from "cursedbelt-core/master-lock";
|
|
36
|
-
import { MASTER_LOCK_DERIVE_SOURCE } from "./lockPage.js";
|
|
36
|
+
import { MASTER_LOCK_DERIVE_SOURCE, MASTER_LOCK_REVEAL_SOURCE, withRevealToggles } from "./lockPage.js";
|
|
37
37
|
/**
|
|
38
38
|
* The page's own two assets.
|
|
39
39
|
*
|
|
@@ -59,7 +59,7 @@ export const MASTER_LOCK_MIN_PASSWORD_LENGTH = 10;
|
|
|
59
59
|
export function accountsPageHtml(options) {
|
|
60
60
|
const label = escapeHtml(options.appLabel);
|
|
61
61
|
const min = MASTER_LOCK_MIN_PASSWORD_LENGTH;
|
|
62
|
-
return `<!doctype html>
|
|
62
|
+
return withRevealToggles(`<!doctype html>
|
|
63
63
|
<html lang="en">
|
|
64
64
|
<head>
|
|
65
65
|
<meta charset="utf-8">
|
|
@@ -156,7 +156,7 @@ export function accountsPageHtml(options) {
|
|
|
156
156
|
<script type="module" src="${MASTER_LOCK_ACCOUNTS_PAGE_PATHS.script}"></script>
|
|
157
157
|
</body>
|
|
158
158
|
</html>
|
|
159
|
-
|
|
159
|
+
`);
|
|
160
160
|
}
|
|
161
161
|
/**
|
|
162
162
|
* The page-specific stylesheet. Loaded AFTER `lock.css` and inheriting every `--ml-*` token
|
|
@@ -212,6 +212,10 @@ const MIN_LENGTH = ${MASTER_LOCK_MIN_PASSWORD_LENGTH};
|
|
|
212
212
|
// ── derivation ───────────────────────────────────────────────────────────────
|
|
213
213
|
${MASTER_LOCK_DERIVE_SOURCE}
|
|
214
214
|
|
|
215
|
+
// ── show / hide ──────────────────────────────────────────────────────────────
|
|
216
|
+
${MASTER_LOCK_REVEAL_SOURCE}
|
|
217
|
+
wireMasterLockReveal(document);
|
|
218
|
+
|
|
215
219
|
const rotateForm = document.getElementById("ml-rotate");
|
|
216
220
|
const addForm = document.getElementById("ml-add");
|
|
217
221
|
const list = document.getElementById("ml-accounts");
|
|
@@ -21,7 +21,26 @@ export interface LockPageOptions {
|
|
|
21
21
|
*/
|
|
22
22
|
export declare function lockPageHtml(options: LockPageOptions): string;
|
|
23
23
|
/** The stylesheet. Follows the viewer's theme and commits to nothing else. */
|
|
24
|
-
export declare const LOCK_STYLE = ":root{color-scheme:light dark;\n --ml-bg:#f4f5f7;--ml-card:#ffffff;--ml-text:#14171c;--ml-muted:#69707c;--ml-border:#d9dde3;\n --ml-accent:#1f6feb;--ml-accent-text:#ffffff;--ml-danger:#b42318;\n --ml-shadow:0 18px 48px rgba(16,18,22,.14)}\n@media (prefers-color-scheme:dark){:root{\n --ml-bg:#0d0f13;--ml-card:#171a20;--ml-text:#e7eaef;--ml-muted:#8b93a1;--ml-border:#2b3039;\n --ml-accent:#4f8cf7;--ml-accent-text:#0b0d11;--ml-danger:#f97066;\n --ml-shadow:0 18px 48px rgba(0,0,0,.55)}}\n*{box-sizing:border-box}\nbody{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;\n background:var(--ml-bg);color:var(--ml-text);\n font:15px/1.55 ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",sans-serif}\n.card{width:100%;max-width:23rem;background:var(--ml-card);border:1px solid var(--ml-border);\n border-radius:16px;padding:28px 26px 24px;box-shadow:var(--ml-shadow);text-align:center}\n.glyph{font-size:30px;line-height:1;margin-bottom:10px}\nh1{margin:0 0 6px;font-size:18px;font-weight:650;letter-spacing:-.01em}\n.note{margin:0 0 20px;font-size:13px;color:var(--ml-muted)}\n.field{display:block;text-align:left;margin-bottom:14px}\n.field span{display:block;font-size:12px;font-weight:600;color:var(--ml-muted);margin-bottom:6px}\ninput{width:100%;height:40px;padding:0 12px;border:1px solid var(--ml-border);border-radius:9px;\n background:var(--ml-bg);color:inherit;font:inherit}\ninput:focus{outline:2px solid var(--ml-accent);outline-offset:1px;border-color:transparent}\nbutton{width:100%;height:40px;border:0;border-radius:9px;background:var(--ml-accent);\n color:var(--ml-accent-text);font:inherit;font-weight:600;cursor:pointer}\nbutton[disabled]{opacity:.6;cursor:progress}\n.status{margin:14px 0 0;min-height:1.2em;font-size:12.5px;color:var(--ml-muted)}\n.status[data-tone=\"error\"]{color:var(--ml-danger)}\n.field span em{font-style:normal;font-weight:500;text-transform:none;opacity:.7}\nbutton.quiet{margin-top:10px;height:32px;background:none;color:var(--ml-muted);font-weight:500;\n font-size:12.5px;text-decoration:underline;text-underline-offset:3px}\nbutton.quiet:hover{color:var(--ml-text)}\nbutton.quiet[disabled]{opacity:.5}\n.hints{margin:10px 0 0;padding:12px 14px;text-align:left;border:1px solid var(--ml-border);\n border-radius:10px;background:var(--ml-bg);font-size:12.5px}\n.hints dt{font-weight:650;color:var(--ml-text)}\n.hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}\n.hints dd:last-child{margin-bottom:0}\n";
|
|
24
|
+
export declare const LOCK_STYLE = ":root{color-scheme:light dark;\n --ml-bg:#f4f5f7;--ml-card:#ffffff;--ml-text:#14171c;--ml-muted:#69707c;--ml-border:#d9dde3;\n --ml-accent:#1f6feb;--ml-accent-text:#ffffff;--ml-danger:#b42318;\n --ml-shadow:0 18px 48px rgba(16,18,22,.14)}\n@media (prefers-color-scheme:dark){:root{\n --ml-bg:#0d0f13;--ml-card:#171a20;--ml-text:#e7eaef;--ml-muted:#8b93a1;--ml-border:#2b3039;\n --ml-accent:#4f8cf7;--ml-accent-text:#0b0d11;--ml-danger:#f97066;\n --ml-shadow:0 18px 48px rgba(0,0,0,.55)}}\n*{box-sizing:border-box}\nbody{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;\n background:var(--ml-bg);color:var(--ml-text);\n font:15px/1.55 ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",sans-serif}\n.card{width:100%;max-width:23rem;background:var(--ml-card);border:1px solid var(--ml-border);\n border-radius:16px;padding:28px 26px 24px;box-shadow:var(--ml-shadow);text-align:center}\n.glyph{font-size:30px;line-height:1;margin-bottom:10px}\nh1{margin:0 0 6px;font-size:18px;font-weight:650;letter-spacing:-.01em}\n.note{margin:0 0 20px;font-size:13px;color:var(--ml-muted)}\n.field{display:block;text-align:left;margin-bottom:14px}\n.field span{display:block;font-size:12px;font-weight:600;color:var(--ml-muted);margin-bottom:6px}\ninput{width:100%;height:40px;padding:0 12px;border:1px solid var(--ml-border);border-radius:9px;\n background:var(--ml-bg);color:inherit;font:inherit}\ninput:focus{outline:2px solid var(--ml-accent);outline-offset:1px;border-color:transparent}\nbutton{width:100%;height:40px;border:0;border-radius:9px;background:var(--ml-accent);\n color:var(--ml-accent-text);font:inherit;font-weight:600;cursor:pointer}\nbutton[disabled]{opacity:.6;cursor:progress}\n.status{margin:14px 0 0;min-height:1.2em;font-size:12.5px;color:var(--ml-muted)}\n.status[data-tone=\"error\"]{color:var(--ml-danger)}\n.field span em{font-style:normal;font-weight:500;text-transform:none;opacity:.7}\nbutton.quiet{margin-top:10px;height:32px;background:none;color:var(--ml-muted);font-weight:500;\n font-size:12.5px;text-decoration:underline;text-underline-offset:3px}\nbutton.quiet:hover{color:var(--ml-text)}\nbutton.quiet[disabled]{opacity:.5}\n.hints{margin:10px 0 0;padding:12px 14px;text-align:left;border:1px solid var(--ml-border);\n border-radius:10px;background:var(--ml-bg);font-size:12.5px}\n.hints dt{font-weight:650;color:var(--ml-text)}\n.hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}\n.hints dd:last-child{margin-bottom:0}\n.secret{position:relative;display:block}\n.secret input{padding-right:68px}\n.secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:auto;height:30px;\n min-width:56px;padding:0 10px;border-radius:7px;background:none;color:var(--ml-muted);\n font-size:12.5px;font-weight:600}\n.secret .reveal:hover{color:var(--ml-text);background:var(--ml-card)}\n.secret .reveal:focus-visible{outline:2px solid var(--ml-accent);outline-offset:1px}\n";
|
|
25
|
+
/**
|
|
26
|
+
* 🔴 Every password field on the wall gets a Show/Hide control (task 077-438's library share) — a
|
|
27
|
+
* master password is long, typed once and never recoverable, and a typo in "choose" is a lock-out.
|
|
28
|
+
*
|
|
29
|
+
* Markup: {@link withRevealToggles} wraps each `type="password"` input in `.secret` with a
|
|
30
|
+
* `<button type="button" data-reveal>` after it — the inputs keep every attribute, `autocomplete`
|
|
31
|
+
* included, so a password manager still sees a password field. Behaviour:
|
|
32
|
+
* {@link MASTER_LOCK_REVEAL_SOURCE}, interpolated into BOTH same-origin scripts, because the wall's
|
|
33
|
+
* CSP is `script-src 'self'` and allows no inline script (`MASTER_LOCK_CSP`, pinned by
|
|
34
|
+
* `beaconNeverReachesTheWall.spec.ts`). Nothing about the CSP changes for this.
|
|
35
|
+
*/
|
|
36
|
+
export declare function withRevealToggles(html: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* The toggle's behaviour, as browser source. It flips the input BEFORE it to `text` and back,
|
|
39
|
+
* keeps `aria-pressed`/`aria-label` true to what is on screen, and returns every field to
|
|
40
|
+
* `password` when its form submits — so a manager saving the credential sees a password field.
|
|
41
|
+
* Publishes itself on `globalThis`, the seam `lockPage.spec.ts` drives it through.
|
|
42
|
+
*/
|
|
43
|
+
export declare const MASTER_LOCK_REVEAL_SOURCE = "function wireMasterLockReveal(doc) {\n const buttons = doc.querySelectorAll(\"[data-reveal]\");\n for (const button of buttons) {\n const input = button.previousElementSibling;\n if (!input || input.tagName !== \"INPUT\") continue;\n const show = (on) => {\n input.type = on ? \"text\" : \"password\";\n button.setAttribute(\"aria-pressed\", on ? \"true\" : \"false\");\n button.setAttribute(\"aria-label\", on ? \"Hide password\" : \"Show password\");\n button.textContent = on ? \"Hide\" : \"Show\";\n };\n button.addEventListener(\"click\", () => {\n show(input.type === \"password\");\n input.focus();\n });\n input.form?.addEventListener(\"submit\", () => show(false), true);\n }\n return buttons.length;\n}\nglobalThis.wireMasterLockReveal = wireMasterLockReveal;";
|
|
25
44
|
/**
|
|
26
45
|
* 🔴 The ONE browser copy of `deriveMasterLockVerifier`, shared by every page this feature
|
|
27
46
|
* serves. Pinned by `lockPage.spec.ts`, which evaluates it and compares against the
|
|
@@ -41,7 +41,7 @@ export function lockPageHtml(options) {
|
|
|
41
41
|
"Choose one now. Nobody else can see it and nobody else can reset it — " +
|
|
42
42
|
"if you forget it, it cannot be recovered, so leave yourself a hint."
|
|
43
43
|
: "This site is locked. Signing in is not enough — enter the master password to continue."));
|
|
44
|
-
return `<!doctype html>
|
|
44
|
+
return withRevealToggles(`<!doctype html>
|
|
45
45
|
<html lang="en">
|
|
46
46
|
<head>
|
|
47
47
|
<meta charset="utf-8">
|
|
@@ -88,7 +88,7 @@ export function lockPageHtml(options) {
|
|
|
88
88
|
<script type="module" src="${MASTER_LOCK_PATHS.script}"></script>
|
|
89
89
|
</body>
|
|
90
90
|
</html>
|
|
91
|
-
|
|
91
|
+
`);
|
|
92
92
|
}
|
|
93
93
|
/** The stylesheet. Follows the viewer's theme and commits to nothing else. */
|
|
94
94
|
export const LOCK_STYLE = `:root{color-scheme:light dark;
|
|
@@ -128,7 +128,54 @@ button.quiet[disabled]{opacity:.5}
|
|
|
128
128
|
.hints dt{font-weight:650;color:var(--ml-text)}
|
|
129
129
|
.hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}
|
|
130
130
|
.hints dd:last-child{margin-bottom:0}
|
|
131
|
+
.secret{position:relative;display:block}
|
|
132
|
+
.secret input{padding-right:68px}
|
|
133
|
+
.secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:auto;height:30px;
|
|
134
|
+
min-width:56px;padding:0 10px;border-radius:7px;background:none;color:var(--ml-muted);
|
|
135
|
+
font-size:12.5px;font-weight:600}
|
|
136
|
+
.secret .reveal:hover{color:var(--ml-text);background:var(--ml-card)}
|
|
137
|
+
.secret .reveal:focus-visible{outline:2px solid var(--ml-accent);outline-offset:1px}
|
|
131
138
|
`;
|
|
139
|
+
/**
|
|
140
|
+
* 🔴 Every password field on the wall gets a Show/Hide control (task 077-438's library share) — a
|
|
141
|
+
* master password is long, typed once and never recoverable, and a typo in "choose" is a lock-out.
|
|
142
|
+
*
|
|
143
|
+
* Markup: {@link withRevealToggles} wraps each `type="password"` input in `.secret` with a
|
|
144
|
+
* `<button type="button" data-reveal>` after it — the inputs keep every attribute, `autocomplete`
|
|
145
|
+
* included, so a password manager still sees a password field. Behaviour:
|
|
146
|
+
* {@link MASTER_LOCK_REVEAL_SOURCE}, interpolated into BOTH same-origin scripts, because the wall's
|
|
147
|
+
* CSP is `script-src 'self'` and allows no inline script (`MASTER_LOCK_CSP`, pinned by
|
|
148
|
+
* `beaconNeverReachesTheWall.spec.ts`). Nothing about the CSP changes for this.
|
|
149
|
+
*/
|
|
150
|
+
export function withRevealToggles(html) {
|
|
151
|
+
return html.replace(/(<input\b[^>]*\btype="password"[^>]*>)/g, '<span class="secret">$1<button type="button" class="reveal" data-reveal aria-label="Show password" aria-pressed="false">Show</button></span>');
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The toggle's behaviour, as browser source. It flips the input BEFORE it to `text` and back,
|
|
155
|
+
* keeps `aria-pressed`/`aria-label` true to what is on screen, and returns every field to
|
|
156
|
+
* `password` when its form submits — so a manager saving the credential sees a password field.
|
|
157
|
+
* Publishes itself on `globalThis`, the seam `lockPage.spec.ts` drives it through.
|
|
158
|
+
*/
|
|
159
|
+
export const MASTER_LOCK_REVEAL_SOURCE = `function wireMasterLockReveal(doc) {
|
|
160
|
+
const buttons = doc.querySelectorAll("[data-reveal]");
|
|
161
|
+
for (const button of buttons) {
|
|
162
|
+
const input = button.previousElementSibling;
|
|
163
|
+
if (!input || input.tagName !== "INPUT") continue;
|
|
164
|
+
const show = (on) => {
|
|
165
|
+
input.type = on ? "text" : "password";
|
|
166
|
+
button.setAttribute("aria-pressed", on ? "true" : "false");
|
|
167
|
+
button.setAttribute("aria-label", on ? "Hide password" : "Show password");
|
|
168
|
+
button.textContent = on ? "Hide" : "Show";
|
|
169
|
+
};
|
|
170
|
+
button.addEventListener("click", () => {
|
|
171
|
+
show(input.type === "password");
|
|
172
|
+
input.focus();
|
|
173
|
+
});
|
|
174
|
+
input.form?.addEventListener("submit", () => show(false), true);
|
|
175
|
+
}
|
|
176
|
+
return buttons.length;
|
|
177
|
+
}
|
|
178
|
+
globalThis.wireMasterLockReveal = wireMasterLockReveal;`;
|
|
132
179
|
/**
|
|
133
180
|
* 🔴 The ONE browser copy of `deriveMasterLockVerifier`, shared by every page this feature
|
|
134
181
|
* serves. Pinned by `lockPage.spec.ts`, which evaluates it and compares against the
|
|
@@ -179,6 +226,10 @@ export const LOCK_SCRIPT = `const paths = ${JSON.stringify(MASTER_LOCK_PATHS)};
|
|
|
179
226
|
// ── derivation ───────────────────────────────────────────────────────────────
|
|
180
227
|
${MASTER_LOCK_DERIVE_SOURCE}
|
|
181
228
|
|
|
229
|
+
// ── show / hide ──────────────────────────────────────────────────────────────
|
|
230
|
+
${MASTER_LOCK_REVEAL_SOURCE}
|
|
231
|
+
wireMasterLockReveal(document);
|
|
232
|
+
|
|
182
233
|
const form = document.getElementById("ml-form");
|
|
183
234
|
const input = document.getElementById("ml-input");
|
|
184
235
|
const confirmInput = document.getElementById("ml-confirm");
|
|
@@ -18,7 +18,17 @@ export interface MetricRow {
|
|
|
18
18
|
durationMs: number | null;
|
|
19
19
|
bytesOut: number | null;
|
|
20
20
|
userId: string | null;
|
|
21
|
+
/**
|
|
22
|
+
* Where the request came from — a PLACE, never an address (4.23.0). Filled by
|
|
23
|
+
* `requestLogger`'s `locate` hook; absent/null when nothing could place it. There is
|
|
24
|
+
* deliberately no `ip` field: the raw address is never stored, anywhere.
|
|
25
|
+
*/
|
|
26
|
+
country?: string | null;
|
|
27
|
+
region?: string | null;
|
|
28
|
+
city?: string | null;
|
|
21
29
|
}
|
|
30
|
+
/** The optional place columns, in insert order. Each is written only when the table HAS it. */
|
|
31
|
+
export declare const PLACE_COLUMNS: readonly ["country", "region", "city"];
|
|
22
32
|
export interface MetricsBufferOptions {
|
|
23
33
|
db: Database;
|
|
24
34
|
/** Flush cadence in ms. Default: 1500. */
|
|
@@ -1,11 +1,19 @@
|
|
|
1
|
+
/** The optional place columns, in insert order. Each is written only when the table HAS it. */
|
|
2
|
+
export const PLACE_COLUMNS = ['country', 'region', 'city'];
|
|
1
3
|
export function createMetricsBuffer(opts) {
|
|
2
4
|
const { db, flushIntervalMs = 1500, maxSize = 5000 } = opts;
|
|
3
5
|
let buffer = [];
|
|
4
|
-
|
|
5
|
-
|
|
6
|
+
// 🔴 The place columns are written only where the table has them (4.23.0). An app that
|
|
7
|
+
// created `request_metrics` with its own DDL before they existed keeps working unchanged —
|
|
8
|
+
// a fixed column list would make `prepare` throw on its first boot after the bump.
|
|
9
|
+
const present = new Set(db.query('PRAGMA table_info(request_metrics)').all().map((c) => c.name));
|
|
10
|
+
const place = PLACE_COLUMNS.filter((c) => present.has(c));
|
|
11
|
+
const columns = ['id', 'ts', 'method', 'route', 'status', 'duration_ms', 'bytes_out', 'user_id', ...place];
|
|
12
|
+
const insert = db.prepare(`INSERT INTO request_metrics (${columns.join(', ')})
|
|
13
|
+
VALUES (${columns.map(() => '?').join(', ')})`);
|
|
6
14
|
const insertMany = db.transaction((rows) => {
|
|
7
15
|
for (const r of rows) {
|
|
8
|
-
insert.run(crypto.randomUUID(), r.ts, r.method, r.route, r.status, r.durationMs, r.bytesOut, r.userId);
|
|
16
|
+
insert.run(crypto.randomUUID(), r.ts, r.method, r.route, r.status, r.durationMs, r.bytesOut, r.userId, ...place.map((c) => r[c] ?? null));
|
|
9
17
|
}
|
|
10
18
|
});
|
|
11
19
|
const flush = () => {
|