redweb 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +166 -9
- package/README.md +177 -523
- package/bin/redweb.js +11 -20
- package/client.d.ts +7 -2
- package/config/tsconfig.json +14 -14
- package/contract.d.ts +45 -0
- package/contract.js +5 -0
- package/docs/ACTION_INPUT_VERIFICATION.md +96 -0
- package/docs/ADMISSION_TIMEOUT_VERIFICATION.md +69 -0
- package/docs/AGENT_ACCESS.md +35 -0
- package/docs/AGENT_EVALUATION.md +58 -0
- package/docs/AGENT_READY_ACCEPTANCE.md +763 -0
- package/docs/APPLICATION_RECORDER_VERIFICATION.md +50 -0
- package/docs/BENCHMARK_VERIFICATION.md +307 -0
- package/docs/BROWSER_OWNER_VERIFICATION.md +191 -0
- package/docs/CLI.md +116 -0
- package/docs/CLIENT_DEVELOPMENT.md +152 -0
- package/docs/CLIENT_POLISH_VERIFICATION.md +282 -0
- package/docs/COVERAGE_COUNTER_VALIDATION.md +109 -0
- package/docs/COVERAGE_SCOPE_AUDIT.md +1183 -0
- package/docs/DEVELOPMENT.md +79 -0
- package/docs/DIAGNOSTIC_COMPATIBILITY.md +76 -0
- package/docs/DOCUMENTATION.md +37 -0
- package/docs/FEEDBACK_COMMAND_VERIFICATION.md +228 -0
- package/docs/GETTING_STARTED.md +58 -0
- package/docs/JSX_PERFORMANCE_VERIFICATION.md +59 -0
- package/docs/LIVE_HTML.md +169 -21
- package/docs/LIVE_HTML_LOAD_VERIFICATION.md +98 -0
- package/docs/MIGRATION.md +28 -0
- package/docs/MULTIPLAYER_OPERATIONS.md +26 -4
- package/docs/ORIGINAL_RECOVERY_VERIFICATION.md +100 -0
- package/docs/PACKAGED_EXAMPLE_VERIFICATION.md +126 -0
- package/docs/POLISH_RELEASE_CHECKPOINT.md +91 -0
- package/docs/PROCESS_CLEANUP_OBSERVATION.md +61 -0
- package/docs/PROCESS_REAPING_VERIFICATION.md +30 -0
- package/docs/PRODUCTION_READINESS.md +11 -2
- package/docs/RECOVERY_CLIENT_HEAP.md +201 -0
- package/docs/RECOVERY_CODE_ATTRIBUTION.md +174 -0
- package/docs/RECOVERY_CODE_CENSUS.md +158 -0
- package/docs/RECOVERY_COMPARISON.md +103 -0
- package/docs/RECOVERY_DEOPTIMIZATION.md +169 -0
- package/docs/RECOVERY_FOLLOWUP_SPIKE.md +147 -0
- package/docs/RECOVERY_INVESTIGATION.md +229 -0
- package/docs/RECOVERY_RUNTIME_CONTROLS.md +181 -0
- package/docs/RELEASE_TRUST.md +58 -0
- package/docs/ROOM_AUTHORIZATION.md +49 -0
- package/docs/RUNTIME_DIAGNOSTICS.md +78 -0
- package/docs/SERVER_RECOVERY_CANDIDATE.md +185 -0
- package/docs/SOAK_ROTATION_OBSERVATION.md +160 -0
- package/docs/SOAK_VERIFICATION.md +154 -0
- package/docs/SOCKET_CONTRACTS.md +39 -0
- package/docs/SPLIT_RECOVERY_COVERAGE.md +83 -0
- package/docs/SPLIT_RECOVERY_ERROR_HANDLING.md +67 -0
- package/docs/STARTER_COORDINATOR_VERIFICATION.md +112 -0
- package/docs/STARTER_LIFECYCLE_VERIFICATION.md +75 -0
- package/docs/STARTER_REPORT_RETENTION.md +73 -0
- package/docs/VERIFICATION_EVIDENCE.md +2 -0
- package/docs/generated.json +2154 -0
- package/docs/guides/chatroom.md +27 -0
- package/docs/guides/http-websocket.md +28 -0
- package/docs/guides/jsx-without-react.md +26 -0
- package/docs/guides/realtime-dashboard.md +29 -0
- package/docs/guides/typed-websockets.md +26 -0
- package/docs/reference.json +1207 -0
- package/docs/snippets/room-access.tsx +51 -0
- package/docs/topics.json +21 -0
- package/examples/live-html/chatroom.js +207 -268
- package/examples/live-html/chatroom.tsx +167 -0
- package/examples/live-html/jsx-page.js +1 -1
- package/examples/live-html/jsx-page.tsx +1 -1
- package/examples/live-html/tsconfig.json +8 -7
- package/index.d.ts +170 -45
- package/index.js +2 -0
- package/jsx-dev-runtime.js +2 -2
- package/jsx-runtime.d.ts +7 -2
- package/package.json +88 -7
- package/recipes/add/artifact.test.cjs +57 -0
- package/recipes/add/live.tsx +18 -0
- package/recipes/add/socket-route.ts +24 -0
- package/recipes/chat/README.md +22 -0
- package/recipes/chat/app.test.cjs +105 -0
- package/recipes/chat/app.tsx +9 -0
- package/recipes/dashboard/README.md +43 -0
- package/recipes/dashboard/admin.ts +21 -0
- package/recipes/dashboard/app.css +16 -0
- package/recipes/dashboard/app.test.cjs +450 -0
- package/recipes/dashboard/app.tsx +86 -0
- package/recipes/dashboard/auth.ts +80 -0
- package/recipes/dashboard/cards.tsx +102 -0
- package/recipes/dashboard/rate-window.test.cjs +17 -0
- package/recipes/dashboard/store.ts +120 -0
- package/recipes/http-ws/README.md +11 -0
- package/recipes/http-ws/app.test.cjs +92 -0
- package/recipes/http-ws/app.tsx +36 -0
- package/recipes/realtime/README.md +8 -0
- package/recipes/realtime/app.test.cjs +15 -0
- package/recipes/realtime/app.tsx +28 -0
- package/recipes/shared/README.md +40 -0
- package/recipes/shared/app.css +8 -0
- package/recipes/shared/copy-assets.cjs +8 -0
- package/recipes/shared/network.cjs +59 -0
- package/recipes/shared/run-app.test.cjs +158 -0
- package/recipes/shared/run-app.ts +50 -0
- package/recipes/site/README.md +4 -0
- package/recipes/site/app.test.cjs +19 -0
- package/recipes/site/app.tsx +25 -0
- package/recipes/socket/README.md +39 -0
- package/recipes/socket/app.test.cjs +85 -0
- package/recipes/socket/app.tsx +30 -0
- package/recipes/socket/contract.ts +12 -0
- package/recipes/socket/handlers.ts +40 -0
- package/src/OwnedServerLifecycle.js +66 -0
- package/src/access/AccessPolicy.js +37 -0
- package/src/access/AuthenticationFailure.js +13 -0
- package/src/access/RequestFailure.js +33 -0
- package/src/access/failure-codes.json +25 -0
- package/src/async/BoundedOperation.js +62 -0
- package/src/cli/ActionReferences.js +193 -0
- package/src/cli/AdditionLayout.js +140 -0
- package/src/cli/FilePlan.js +94 -0
- package/src/cli/ProjectAddition.js +60 -0
- package/src/cli/ProjectConfig.js +26 -0
- package/src/cli/ProjectDoctor.js +112 -0
- package/src/cli/ProjectInitializer.js +20 -30
- package/src/cli/SourceInspector.js +207 -0
- package/src/cli/StaticSource.js +192 -0
- package/src/cli/arguments.js +62 -0
- package/src/cli/formatCommand.js +10 -0
- package/src/cli/run.js +57 -0
- package/src/cli/templates.js +86 -87
- package/src/context/RequestSnapshot.js +41 -0
- package/src/dataProperty.js +11 -0
- package/src/development/DevelopmentPageManager.js +48 -0
- package/src/development/Inspection.js +104 -0
- package/src/development/ObservedRenderer.js +42 -0
- package/src/development/description.js +35 -0
- package/src/development/loopbackRequest.js +27 -0
- package/src/development/refreshBrowser.js +96 -0
- package/src/development/refreshStyles.js +9 -0
- package/src/development/settings.js +17 -0
- package/src/docs/Documentation.js +182 -0
- package/src/htmx/ActionDefinition.js +44 -0
- package/src/htmx/Jsx.js +24 -8
- package/src/htmx/LiveHtmlServer.js +41 -19
- package/src/htmx/LivePage.js +63 -13
- package/src/htmx/PageIdentity.js +32 -0
- package/src/htmx/PageLifetime.js +37 -0
- package/src/htmx/PageManager.js +203 -74
- package/src/htmx/ReactiveRenderer.js +241 -0
- package/src/htmx/StaticExporter.js +1 -1
- package/src/htmx/TemplateRenderer.js +13 -7
- package/src/htmx/browserRuntime.js +2 -93
- package/src/htmx/metadata.js +19 -7
- package/src/validation/ActionInputError.js +12 -0
- package/src/validation/SchemaValidator.js +38 -0
- package/src/ws/AdmissionPolicy.js +24 -23
- package/src/ws/BaseSocketServer.js +53 -38
- package/src/ws/ContractValidationError.js +12 -0
- package/src/ws/HeartbeatMonitor.js +19 -7
- package/src/ws/ProtocolPolicy.js +1 -1
- package/src/ws/RoomAccess.js +82 -0
- package/src/ws/RoomRegistry.js +56 -6
- package/src/ws/RouteRuntime.js +56 -10
- package/src/ws/SocketContract.js +112 -0
- package/src/ws/SocketRoute.js +18 -0
- package/src/ws/protocol-schema.json +6 -1
- package/examples/live-html/chatroom.ts +0 -217
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const { test } = require('node:test');
|
|
3
|
+
const { once } = require('node:events');
|
|
4
|
+
const { mkdtempSync, rmSync, writeFileSync } = require('node:fs');
|
|
5
|
+
const { tmpdir } = require('node:os');
|
|
6
|
+
const { join } = require('node:path');
|
|
7
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
8
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
9
|
+
const net = require('node:net');
|
|
10
|
+
const { WebSocketServer, WebSocket } = require('ws');
|
|
11
|
+
const { createApp, databasePath } = require('../dist/app');
|
|
12
|
+
const { DashboardStore } = require('../dist/store');
|
|
13
|
+
const { DashboardAuth, credentials, sessionToken } = require('../dist/auth');
|
|
14
|
+
const { PrivateCards } = require('../dist/cards');
|
|
15
|
+
const { live, connect } = require('./network.cjs');
|
|
16
|
+
|
|
17
|
+
const password = 'test-only-correct-password';
|
|
18
|
+
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
19
|
+
|
|
20
|
+
async function fixture(t, options = {}) {
|
|
21
|
+
const directory = mkdtempSync(join(tmpdir(), 'redweb-private-cards-'));
|
|
22
|
+
const database = join(directory, 'cards.sqlite');
|
|
23
|
+
const store = new DashboardStore(database);
|
|
24
|
+
const secret = await credentials(password);
|
|
25
|
+
store.provision('alice', secret);
|
|
26
|
+
store.provision('bob', secret);
|
|
27
|
+
store.close();
|
|
28
|
+
let app;
|
|
29
|
+
t.after(async () => { await app?.shutdown(); rmSync(directory, { recursive: true, force: true }); });
|
|
30
|
+
async function restart() {
|
|
31
|
+
await app?.shutdown();
|
|
32
|
+
app = createApp({ port: 0, database, ...options });
|
|
33
|
+
if (!app.server.listening) await once(app.server, 'listening');
|
|
34
|
+
return `http://127.0.0.1:${app.server.address().port}`;
|
|
35
|
+
}
|
|
36
|
+
return { database, restart, origin: await restart(), get app() { return app; } };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function post(origin, path, values, cookie, suppliedOrigin = origin) {
|
|
40
|
+
return fetch(`${origin}${path}`, {
|
|
41
|
+
method: 'POST', redirect: 'manual',
|
|
42
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: suppliedOrigin, ...(cookie ? { Cookie: cookie } : {}) },
|
|
43
|
+
body: new URLSearchParams(values),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function login(origin, account = 'alice') {
|
|
48
|
+
const response = await post(origin, '/login', { account, password });
|
|
49
|
+
assert.equal(response.status, 303);
|
|
50
|
+
const header = response.headers.get('set-cookie');
|
|
51
|
+
assert.match(header, /HttpOnly; SameSite=Strict; Path=\//);
|
|
52
|
+
return header.split(';')[0];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function cardClient(t, origin, cookie) {
|
|
56
|
+
const client = await live(t, origin, { Cookie: cookie });
|
|
57
|
+
const component = client.document.match(/data-rw-component="([^"]+)"/)[1];
|
|
58
|
+
await client.patch(patch => patch.id === 'root');
|
|
59
|
+
const parseCards = html => [...html.matchAll(/data-card-id="([^"]+)"[^>]*><h2>([\s\S]*?)<\/h2>/g)].map(match => ({
|
|
60
|
+
id: match[1], title: match[2].replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll(''', "'").replaceAll('&', '&'),
|
|
61
|
+
}));
|
|
62
|
+
return {
|
|
63
|
+
...client, component,
|
|
64
|
+
add: title => client.action('add', [{ title }], component),
|
|
65
|
+
remove: id => client.action('remove', [{ id }], component),
|
|
66
|
+
items: predicate => client.patch(patch => predicate(parseCards(patch.html))).then(message => parseCards(message.payload.patches.find(patch => predicate(parseCards(patch.html))).html)),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test('private live cards: real HTTP, sockets, isolation, reconnect, sign-out and durable restart', async t => {
|
|
71
|
+
const fixtureApp = await fixture(t);
|
|
72
|
+
let { origin } = fixtureApp;
|
|
73
|
+
assert.equal((await fetch(`${origin}/login`)).status, 200);
|
|
74
|
+
assert.equal((await fetch(origin)).status, 401);
|
|
75
|
+
assert.equal((await post(origin, '/login', { account: 'alice', password }, '', 'https://foreign.example')).status, 403);
|
|
76
|
+
assert.equal((await post(origin, '/login', { account: 'alice', password: 'wrong-password-at-least-16' })).status, 401);
|
|
77
|
+
const alice = await login(origin);
|
|
78
|
+
const alice2 = await login(origin);
|
|
79
|
+
const bob = await login(origin, 'bob');
|
|
80
|
+
const page = await fetch(origin, { headers: { Cookie: alice } });
|
|
81
|
+
assert.match(page.headers.get('cache-control'), /private.*no-store/);
|
|
82
|
+
assert.equal(page.headers.get('etag'), null);
|
|
83
|
+
const first = await cardClient(t, origin, alice);
|
|
84
|
+
const second = await cardClient(t, origin, alice2);
|
|
85
|
+
const other = await cardClient(t, origin, bob);
|
|
86
|
+
first.add('Saved <script>alert(1)</script>');
|
|
87
|
+
const [items] = await Promise.all([first.items(value => value.length === 1), second.items(value => value.length === 1)]);
|
|
88
|
+
assert.equal(items[0].title, 'Saved <script>alert(1)</script>');
|
|
89
|
+
const db = new DashboardStore(fixtureApp.database);
|
|
90
|
+
assert.deepEqual(db.list(sessionToken(bob)), []);
|
|
91
|
+
other.remove(items[0].id);
|
|
92
|
+
await delay(50);
|
|
93
|
+
assert.equal(db.list(sessionToken(alice)).length, 1);
|
|
94
|
+
first.action('add', [{ title: 'forged', account: 'bob' }], first.component);
|
|
95
|
+
const invalid = await first.receive(message => message.type === 'error');
|
|
96
|
+
assert.equal(invalid.error.code, 'ACTION_INVALID_INPUT');
|
|
97
|
+
assert.equal(db.list(sessionToken(alice)).length, 1);
|
|
98
|
+
const closed = once(first.socket, 'close'); first.socket.close(); await closed;
|
|
99
|
+
second.add('While disconnected');
|
|
100
|
+
await second.items(value => value.length === 2);
|
|
101
|
+
const config = first.config;
|
|
102
|
+
const reconnect = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin, { Cookie: alice });
|
|
103
|
+
await reconnect.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(patch => patch.html.includes('While disconnected')));
|
|
104
|
+
const deniedLogout = await post(origin, '/logout', {}, alice, 'https://foreign.example');
|
|
105
|
+
assert.equal(deniedLogout.status, 403);
|
|
106
|
+
const aliceClosed = once(reconnect.socket, 'close');
|
|
107
|
+
const secondClosed = once(second.socket, 'close');
|
|
108
|
+
const loggedOut = await post(origin, '/logout', {}, alice);
|
|
109
|
+
assert.equal(loggedOut.status, 303);
|
|
110
|
+
assert.match(loggedOut.headers.get('set-cookie'), /Max-Age=0/);
|
|
111
|
+
await Promise.all([aliceClosed, secondClosed]);
|
|
112
|
+
assert.equal((await fetch(origin, { headers: { Cookie: alice2 } })).status, 401);
|
|
113
|
+
assert.equal(other.socket.readyState, 1);
|
|
114
|
+
assert.equal(db.session(sessionToken(alice)), undefined);
|
|
115
|
+
db.close();
|
|
116
|
+
origin = await fixtureApp.restart();
|
|
117
|
+
const renewed = await login(origin);
|
|
118
|
+
const restored = await fetch(origin, { headers: { Cookie: renewed } });
|
|
119
|
+
const text = await restored.text();
|
|
120
|
+
assert.match(text, /While disconnected/);
|
|
121
|
+
assert.match(text, /<script>/);
|
|
122
|
+
const client = await cardClient(t, origin, renewed);
|
|
123
|
+
client.remove(items[0].id);
|
|
124
|
+
await client.items(value => value.length === 1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('session expiry closes idle sockets and rejects later HTTP access', async t => {
|
|
128
|
+
const { origin } = await fixture(t, { sessionLifetimeMs: 1200 });
|
|
129
|
+
const cookie = await login(origin);
|
|
130
|
+
const client = await cardClient(t, origin, cookie);
|
|
131
|
+
const [code] = await once(client.socket, 'close');
|
|
132
|
+
assert.equal(code, 1008);
|
|
133
|
+
assert.equal((await fetch(origin, { headers: { Cookie: cookie } })).status, 401);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('store and authentication units use actual SQLite and scrypt, never substitutes', async t => {
|
|
137
|
+
const previousDatabase = process.env.DASHBOARD_DATABASE;
|
|
138
|
+
const previousPort = process.env.PORT;
|
|
139
|
+
try {
|
|
140
|
+
delete process.env.DASHBOARD_DATABASE;
|
|
141
|
+
delete process.env.PORT;
|
|
142
|
+
assert.equal(databasePath(), require('node:path').resolve('data/dashboard.sqlite'));
|
|
143
|
+
assert.throws(() => createApp({ origin: 'ftp://invalid.example' }), /exact/);
|
|
144
|
+
} finally {
|
|
145
|
+
if (previousDatabase === undefined) delete process.env.DASHBOARD_DATABASE; else process.env.DASHBOARD_DATABASE = previousDatabase;
|
|
146
|
+
if (previousPort === undefined) delete process.env.PORT; else process.env.PORT = previousPort;
|
|
147
|
+
}
|
|
148
|
+
const directory = mkdtempSync(join(tmpdir(), 'redweb-store-'));
|
|
149
|
+
const database = join(directory, 'unit.sqlite');
|
|
150
|
+
const store = new DashboardStore(database);
|
|
151
|
+
t.after(() => { store.close(); rmSync(directory, { recursive: true, force: true }); });
|
|
152
|
+
await assert.rejects(credentials('short'), /16/);
|
|
153
|
+
const secret = await credentials(password);
|
|
154
|
+
assert.throws(() => store.provision('?', secret), /Invalid/);
|
|
155
|
+
store.provision('alice', secret);
|
|
156
|
+
assert.throws(() => store.provision('alice', secret));
|
|
157
|
+
assert.equal(store.credentials('missing'), undefined);
|
|
158
|
+
assert.throws(() => store.issue('alice', 0));
|
|
159
|
+
const auth = new DashboardAuth(store);
|
|
160
|
+
assert.equal(await auth.login('peer', 'unknown', password), undefined);
|
|
161
|
+
assert.equal(await auth.login('peer', {}, password), undefined);
|
|
162
|
+
const token = await auth.login('peer', 'alice', password);
|
|
163
|
+
assert.equal(store.session(token).account, 'alice');
|
|
164
|
+
assert.equal(sessionToken(`redweb_dashboard=${token}`), token);
|
|
165
|
+
assert.equal(sessionToken(`redweb_dashboard=${token}; redweb_dashboard=${token}`), '');
|
|
166
|
+
assert.equal(sessionToken('redweb_dashboard=invalid'), '');
|
|
167
|
+
assert.equal(store.session('invalid'), undefined);
|
|
168
|
+
assert.throws(() => store.list('invalid'), /expired/);
|
|
169
|
+
assert.throws(() => store.add(token, ''), /Invalid/);
|
|
170
|
+
assert.throws(() => store.add(token, '\u0000'), /Invalid/);
|
|
171
|
+
for (let i = 0; i < 100; i++) store.add(token, `Card ${i}`);
|
|
172
|
+
assert.throws(() => store.add(token, 'Over capacity'), /limit/);
|
|
173
|
+
assert.equal(store.list(token).length, 100);
|
|
174
|
+
store.remove(token, store.list(token)[0].id);
|
|
175
|
+
assert.equal(store.list(token).length, 99);
|
|
176
|
+
for (let i = 1; i < 32; i++) store.issue('alice', 10000);
|
|
177
|
+
assert.throws(() => store.issue('alice', 10000), /existing sessions/);
|
|
178
|
+
assert.equal(store.signOut(token), 'alice');
|
|
179
|
+
assert.equal(store.signOut(token), undefined);
|
|
180
|
+
assert.throws(() => store.remove(token, 'anything'), /expired/);
|
|
181
|
+
for (let i = 0; i < 10; i++) await auth.login('limited', '!', password);
|
|
182
|
+
assert.equal(await auth.login('limited', 'alice', password), undefined);
|
|
183
|
+
store.close(); store.close();
|
|
184
|
+
const raw = new DatabaseSync(database);
|
|
185
|
+
raw.exec('PRAGMA user_version = 2'); raw.close();
|
|
186
|
+
assert.throws(() => new DashboardStore(database), /Unsupported/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('logout fences password checks in flight; close and admission bounds stop new sessions', async t => {
|
|
190
|
+
const store = new DashboardStore(':memory:');
|
|
191
|
+
t.after(() => store.close());
|
|
192
|
+
store.provision('alice', await credentials(password));
|
|
193
|
+
const auth = new DashboardAuth(store);
|
|
194
|
+
const token = store.issue('alice', 5000);
|
|
195
|
+
const pending = auth.login('peer', 'alice', password);
|
|
196
|
+
store.signOut(token);
|
|
197
|
+
await assert.rejects(pending, /Sign-out occurred/);
|
|
198
|
+
const closing = auth.login('peer', 'alice', password);
|
|
199
|
+
auth.close();
|
|
200
|
+
assert.equal(await closing, undefined);
|
|
201
|
+
assert.equal(await auth.login('peer', 'alice', password), undefined);
|
|
202
|
+
assert.throws(() => new DashboardAuth(store, 0));
|
|
203
|
+
const limited = new DashboardAuth(store);
|
|
204
|
+
const concurrent = Array.from({ length: 5 }, (_, index) => limited.login(`peer-${index}`, 'alice', password));
|
|
205
|
+
assert.equal(await concurrent[4], undefined);
|
|
206
|
+
const issued = await Promise.all(concurrent.slice(0, 4));
|
|
207
|
+
assert.ok(issued.every(Boolean));
|
|
208
|
+
for (let index = 0; index < 1024; index++) await limited.login(`invalid-${index}`, null, null);
|
|
209
|
+
assert.equal(await limited.login('new-peer', 'alice', password), undefined);
|
|
210
|
+
const expiring = store.issue('alice', 100);
|
|
211
|
+
await delay(110);
|
|
212
|
+
assert.equal(store.session(expiring), undefined);
|
|
213
|
+
store.issue('alice', 1000); // Prunes expired rows during issuance.
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('subscription cleanup is idempotent across replacement groups and failed callbacks', async t => {
|
|
217
|
+
const store = new DashboardStore(':memory:');
|
|
218
|
+
store.provision('alice', await credentials(password));
|
|
219
|
+
const token = store.issue('alice', 5000);
|
|
220
|
+
const cards = new PrivateCards(store);
|
|
221
|
+
const sockets = new WebSocketServer({ port: 0, host: '127.0.0.1' });
|
|
222
|
+
await once(sockets, 'listening');
|
|
223
|
+
const peers = [];
|
|
224
|
+
t.after(async () => {
|
|
225
|
+
for (const peer of peers) peer.terminate();
|
|
226
|
+
for (const peer of sockets.clients) peer.terminate();
|
|
227
|
+
await new Promise(resolve => sockets.close(resolve)); store.close();
|
|
228
|
+
});
|
|
229
|
+
async function context() {
|
|
230
|
+
const accepted = once(sockets, 'connection');
|
|
231
|
+
const client = new WebSocket(`ws://127.0.0.1:${sockets.address().port}`); peers.push(client);
|
|
232
|
+
const opened = once(client, 'open');
|
|
233
|
+
const [socket] = await accepted;
|
|
234
|
+
await opened;
|
|
235
|
+
const controller = new AbortController();
|
|
236
|
+
return { controller, value: { principal: 'alice', signal: controller.signal, socket, request: { get: name => name === 'cookie' ? `redweb_dashboard=${token}` : undefined } } };
|
|
237
|
+
}
|
|
238
|
+
const original = await context();
|
|
239
|
+
const cleanup = cards.subscribe(original.value, () => {});
|
|
240
|
+
original.controller.abort();
|
|
241
|
+
const replacement = await context();
|
|
242
|
+
let updates = 0;
|
|
243
|
+
const release = cards.subscribe(replacement.value, () => updates++);
|
|
244
|
+
cleanup(); cleanup();
|
|
245
|
+
cards.publish(store.add(token, 'Replacement still registered'));
|
|
246
|
+
assert.equal(updates, 2);
|
|
247
|
+
const broken = await context();
|
|
248
|
+
let fail = false;
|
|
249
|
+
cards.subscribe(broken.value, () => { if (fail) throw new Error('Intentional consumer failure'); });
|
|
250
|
+
fail = true;
|
|
251
|
+
cards.publish(store.add(token, 'Failure isolation'));
|
|
252
|
+
assert.equal(updates, 3);
|
|
253
|
+
const failedInitial = await context();
|
|
254
|
+
assert.throws(() => cards.subscribe(failedInitial.value, () => { throw new Error('Initial callback failure'); }), /Initial/);
|
|
255
|
+
const invalid = await context(); invalid.controller.abort();
|
|
256
|
+
assert.throws(() => cards.subscribe(invalid.value, () => {}), /Sign in/);
|
|
257
|
+
store.signOut(token);
|
|
258
|
+
cards.publish('alice');
|
|
259
|
+
assert.equal(updates, 3);
|
|
260
|
+
assert.throws(() => cards.subscribe(replacement.value, () => {}), /Sign in/);
|
|
261
|
+
release(); cards.publish('missing');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('incomplete HTTP uploads cannot keep shutdown or the database alive indefinitely', async t => {
|
|
265
|
+
const directory = mkdtempSync(join(tmpdir(), 'redweb-drain-'));
|
|
266
|
+
const database = join(directory, 'drain.sqlite');
|
|
267
|
+
let app;
|
|
268
|
+
t.after(async () => { await app?.shutdown(); rmSync(directory, { recursive: true, force: true }); });
|
|
269
|
+
assert.throws(() => createApp({ port: 0, database, sessionLifetimeMs: 0 }), /lifetime/);
|
|
270
|
+
assert.throws(() => createApp({ port: 0, database, origin: 'https://example.com/path' }), /exact/);
|
|
271
|
+
assert.throws(() => createApp({ port: 0, database, origin: 'ftp://example.com' }), /exact/);
|
|
272
|
+
app = createApp({ port: 0, database });
|
|
273
|
+
await once(app.server, 'listening');
|
|
274
|
+
const socket = net.connect(app.server.address().port, '127.0.0.1');
|
|
275
|
+
t.after(() => socket.destroy());
|
|
276
|
+
socket.on('error', () => {});
|
|
277
|
+
await once(socket, 'connect');
|
|
278
|
+
socket.write('POST /login HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 1000\r\n\r\naccount=');
|
|
279
|
+
await delay(30);
|
|
280
|
+
const started = Date.now();
|
|
281
|
+
await app.shutdown();
|
|
282
|
+
assert.ok(Date.now() - started < 2000);
|
|
283
|
+
const reopened = new DashboardStore(database); reopened.close();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('SQLite commits survive abrupt process termination rather than only graceful shutdown', async t => {
|
|
287
|
+
const directory = mkdtempSync(join(tmpdir(), 'redweb-crash-'));
|
|
288
|
+
const database = join(directory, 'crash.sqlite');
|
|
289
|
+
const store = new DashboardStore(database);
|
|
290
|
+
store.provision('alice', await credentials(password));
|
|
291
|
+
const token = store.issue('alice', 60000); store.close();
|
|
292
|
+
const child = spawn(process.execPath, ['-e', `
|
|
293
|
+
const { DashboardStore } = require('./dist/store');
|
|
294
|
+
const db = new DashboardStore(process.argv[1]);
|
|
295
|
+
db.add(process.argv[2], 'Committed before crash');
|
|
296
|
+
process.send('committed');
|
|
297
|
+
setInterval(() => {}, 1000);
|
|
298
|
+
`, database, token], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true });
|
|
299
|
+
t.after(async () => {
|
|
300
|
+
if (child.exitCode === null && child.signalCode === null) { const exited = once(child, 'exit'); child.kill('SIGKILL'); await exited; }
|
|
301
|
+
rmSync(directory, { recursive: true, force: true });
|
|
302
|
+
});
|
|
303
|
+
assert.deepEqual(await once(child, 'message'), ['committed', undefined]);
|
|
304
|
+
const exited = once(child, 'exit'); child.kill('SIGKILL'); await exited;
|
|
305
|
+
const recovered = new DashboardStore(database);
|
|
306
|
+
try { assert.equal(recovered.list(token)[0].title, 'Committed before crash'); }
|
|
307
|
+
finally { recovered.close(); }
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('production origin/cookies and malformed forms use real HTTP', async t => {
|
|
311
|
+
const { origin } = await fixture(t, { origin: 'https://dashboard.example' });
|
|
312
|
+
const authenticated = await post(origin, '/login', { account: 'alice', password }, '', 'https://dashboard.example');
|
|
313
|
+
assert.equal(authenticated.status, 303);
|
|
314
|
+
assert.match(authenticated.headers.get('set-cookie'), /; Secure/);
|
|
315
|
+
assert.equal((await post(origin, '/login', { account: 'alice', password: 'x'.repeat(5000) })).status, 400);
|
|
316
|
+
assert.equal((await post(origin, '/logout', {}, '', 'https://dashboard.example')).status, 303);
|
|
317
|
+
assert.equal((await post(origin, '/login', {})).status, 403);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('unit: listener-error cleanup observes rejection without hiding it from the application owner', async t => {
|
|
321
|
+
const directory = mkdtempSync(join(tmpdir(), 'redweb-dashboard-cleanup-'));
|
|
322
|
+
const database = join(directory, 'cards.sqlite');
|
|
323
|
+
const app = createApp({ port: 0, database });
|
|
324
|
+
t.after(async () => {
|
|
325
|
+
// This test deliberately makes the returned cleanup promise reject.
|
|
326
|
+
// Await settlement before removing files, including on assertion failure.
|
|
327
|
+
await Promise.allSettled([app.shutdown()]);
|
|
328
|
+
rmSync(directory, { recursive: true, force: true });
|
|
329
|
+
});
|
|
330
|
+
await once(app.server, 'listening');
|
|
331
|
+
const failure = new Error('Injected database cleanup failure');
|
|
332
|
+
const close = DashboardStore.prototype.close;
|
|
333
|
+
// Unit-only fault injection, not a claim of a naturally occurring SQLite
|
|
334
|
+
// failure. Real database/socket cleanup still runs; network ITs use no mocks.
|
|
335
|
+
const injected = t.mock.method(DashboardStore.prototype, 'close', function () {
|
|
336
|
+
close.call(this);
|
|
337
|
+
throw failure;
|
|
338
|
+
});
|
|
339
|
+
app.server.emit('error', new Error('Injected listener failure'));
|
|
340
|
+
const closing = app.shutdown();
|
|
341
|
+
assert.equal(app.shutdown(), closing);
|
|
342
|
+
await assert.rejects(closing, error => error === failure);
|
|
343
|
+
assert.equal(injected.mock.callCount(), 1);
|
|
344
|
+
assert.equal(app.server.listening, false);
|
|
345
|
+
injected.mock.restore();
|
|
346
|
+
const reopened = new DashboardStore(database);
|
|
347
|
+
reopened.close();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('invalid-form middleware leaves an already destroyed native HTTP response untouched', async t => {
|
|
351
|
+
const { origin, app } = await fixture(t);
|
|
352
|
+
const handled = new Promise(resolve => app.server.once('request', (request, response) => resolve({ request, response })));
|
|
353
|
+
const page = await fetch(`${origin}/login`);
|
|
354
|
+
assert.equal(page.status, 200);
|
|
355
|
+
await page.text();
|
|
356
|
+
const { request, response } = await handled;
|
|
357
|
+
// Unit-test the defensive state with genuine Express objects. This is not
|
|
358
|
+
// a claim that an aborted upload naturally reaches this middleware branch.
|
|
359
|
+
const handlers = app.server.listeners('request').flatMap(listener => listener._router?.stack ?? [])
|
|
360
|
+
.filter(layer => layer.handle.name === 'invalidBody');
|
|
361
|
+
assert.equal(handlers.length, 1);
|
|
362
|
+
response.destroy();
|
|
363
|
+
assert.equal(response.destroyed, true);
|
|
364
|
+
const before = { status: response.statusCode, headers: response.getHeaders(), ended: response.writableEnded };
|
|
365
|
+
handlers[0].handle(new Error('Invalid form after disconnect'), request, response, () => assert.fail('must not forward'));
|
|
366
|
+
assert.deepEqual({ status: response.statusCode, headers: response.getHeaders(), ended: response.writableEnded }, before);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('capacity failures and abandoned uploads remain contained over real HTTP', { timeout: 10000 }, async t => {
|
|
370
|
+
const { origin, database } = await fixture(t);
|
|
371
|
+
const store = new DashboardStore(database);
|
|
372
|
+
try { for (let index = 0; index < 32; index++) store.issue('alice', 60000); }
|
|
373
|
+
finally { store.close(); }
|
|
374
|
+
const response = await post(origin, '/login', { account: 'alice', password });
|
|
375
|
+
assert.equal(response.status, 503);
|
|
376
|
+
assert.equal(await response.text(), 'Unable to complete the request. Try again later.');
|
|
377
|
+
const socket = net.connect(Number(new URL(origin).port), '127.0.0.1');
|
|
378
|
+
socket.on('error', () => {});
|
|
379
|
+
t.after(() => socket.destroy());
|
|
380
|
+
await once(socket, 'connect');
|
|
381
|
+
const closed = new Promise(resolve => socket.once('close', resolve));
|
|
382
|
+
socket.end('POST /login HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 100\r\n\r\naccount=alice');
|
|
383
|
+
socket.resume();
|
|
384
|
+
await closed;
|
|
385
|
+
const reset = net.connect(Number(new URL(origin).port), '127.0.0.1');
|
|
386
|
+
reset.on('error', () => {});
|
|
387
|
+
t.after(() => reset.destroy());
|
|
388
|
+
await once(reset, 'connect');
|
|
389
|
+
const resetClosed = new Promise(resolve => reset.once('close', resolve));
|
|
390
|
+
reset.write('POST /login HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Encoding: gzip\r\nContent-Length: 100\r\n\r\n');
|
|
391
|
+
await delay(20);
|
|
392
|
+
reset.resetAndDestroy();
|
|
393
|
+
await resetClosed;
|
|
394
|
+
assert.equal((await fetch(`${origin}/login`)).status, 200);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('real administrator and standalone startup commands expose errors and persist accounts', async t => {
|
|
398
|
+
const directory = mkdtempSync(join(tmpdir(), 'redweb-dashboard-cli-'));
|
|
399
|
+
const database = join(directory, 'cli.sqlite');
|
|
400
|
+
let app;
|
|
401
|
+
let child;
|
|
402
|
+
t.after(async () => {
|
|
403
|
+
if (child && child.exitCode === null && child.signalCode === null) { const exit = once(child, 'exit'); child.kill(); await exit; }
|
|
404
|
+
await app?.shutdown();
|
|
405
|
+
rmSync(directory, { recursive: true, force: true });
|
|
406
|
+
});
|
|
407
|
+
const env = { ...process.env, DASHBOARD_DATABASE: database, PORT: '0', NODE_ENV: 'test' };
|
|
408
|
+
delete env.DASHBOARD_ORIGIN;
|
|
409
|
+
const run = (file, args = [], overrides = {}) => spawnSync(process.execPath, [file, ...args], {
|
|
410
|
+
env: { ...env, ...overrides }, encoding: 'utf8', timeout: 10000, windowsHide: true,
|
|
411
|
+
});
|
|
412
|
+
assert.equal(run('dist/admin.js').status, 1);
|
|
413
|
+
assert.equal(run('dist/admin.js', ['?', 'extra']).status, 1);
|
|
414
|
+
const created = run('dist/admin.js', ['carol']);
|
|
415
|
+
assert.equal(created.status, 0); // Never include stdout (a generated password) in diagnostic output.
|
|
416
|
+
assert.ok(created.stdout.startsWith('Created carol.'));
|
|
417
|
+
assert.equal(run('dist/admin.js', ['carol']).status, 1);
|
|
418
|
+
assert.equal(run('dist/app.js', [], { NODE_ENV: 'production' }).status, 1);
|
|
419
|
+
assert.equal(run('dist/app.js', [], { NODE_ENV: 'production', DASHBOARD_ORIGIN: 'http://example.com' }).status, 1);
|
|
420
|
+
const store = new DashboardStore(database);
|
|
421
|
+
try { assert.ok(store.credentials('carol')); }
|
|
422
|
+
finally { store.close(); }
|
|
423
|
+
app = createApp({ database, port: 0 });
|
|
424
|
+
await once(app.server, 'listening');
|
|
425
|
+
const unavailable = run('dist/app.js', [], { PORT: String(app.server.address().port) });
|
|
426
|
+
assert.equal(unavailable.status, 1);
|
|
427
|
+
assert.match(unavailable.stderr, /Application listener failed/);
|
|
428
|
+
// Windows kill('SIGTERM') terminates immediately without invoking Node handlers.
|
|
429
|
+
// An actual IPC message delivers the signal event there; Unix uses its OS signal.
|
|
430
|
+
const signalControl = join(directory, 'signal.cjs');
|
|
431
|
+
writeFileSync(signalControl, "process.once('message', () => { process.disconnect(); process.emit('SIGTERM'); });");
|
|
432
|
+
for (const configured of [false, true]) {
|
|
433
|
+
const args = [...(process.platform === 'win32' ? ['--require', signalControl] : []), 'dist/app.js'];
|
|
434
|
+
child = spawn(process.execPath, args, { env: { ...env, ...(configured ? { DASHBOARD_ORIGIN: 'https://dashboard.example', NODE_ENV: 'production' } : {}) },
|
|
435
|
+
stdio: ['ignore', 'pipe', 'pipe', ...(process.platform === 'win32' ? ['ipc'] : [])], windowsHide: true });
|
|
436
|
+
let output = '', errors = '';
|
|
437
|
+
child.stdout.on('data', chunk => { output += chunk; });
|
|
438
|
+
child.stderr.on('data', chunk => { errors += chunk; });
|
|
439
|
+
const deadline = Date.now() + 5000;
|
|
440
|
+
while (!output.includes('/login') && Date.now() < deadline && child.exitCode === null) await delay(20);
|
|
441
|
+
assert.match(output, configured ? /Dashboard: https:\/\/dashboard.example\/login/ : /Dashboard: http:\/\/127\.0\.0\.1:\d+\/login/);
|
|
442
|
+
if (!configured) assert.equal((await fetch(output.match(/http:\/\/127\.0\.0\.1:\d+\/login/)[0])).status, 200);
|
|
443
|
+
const exit = once(child, 'exit');
|
|
444
|
+
if (process.platform === 'win32') child.send('stop');
|
|
445
|
+
else child.kill('SIGTERM');
|
|
446
|
+
const [code, signal] = await exit;
|
|
447
|
+
assert.equal(code, 0, errors);
|
|
448
|
+
assert.equal(signal, null);
|
|
449
|
+
}
|
|
450
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import express, { type ErrorRequestHandler } from 'express';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { page, start, type LivePageRequestContext } from 'redweb';
|
|
5
|
+
import { DashboardAuth, sessionToken } from './auth';
|
|
6
|
+
import { Cards, PrivateCards } from './cards';
|
|
7
|
+
import { DashboardStore } from './store';
|
|
8
|
+
import { runApp } from './run-app';
|
|
9
|
+
|
|
10
|
+
export interface DashboardOptions { port?: number; database?: string; origin?: string; sessionLifetimeMs?: number; }
|
|
11
|
+
|
|
12
|
+
export function databasePath() { return resolve(process.env.DASHBOARD_DATABASE ?? 'data/dashboard.sqlite'); }
|
|
13
|
+
|
|
14
|
+
export function createApp(options: DashboardOptions = {}) {
|
|
15
|
+
const port = options.port ?? Number(process.env.PORT ?? 8181);
|
|
16
|
+
const configuredOrigin = options.origin ?? process.env.DASHBOARD_ORIGIN;
|
|
17
|
+
if (configuredOrigin && (!/^https?:$/.test(new URL(configuredOrigin).protocol) || new URL(configuredOrigin).origin !== configuredOrigin)) {
|
|
18
|
+
throw new Error('DASHBOARD_ORIGIN must be an exact HTTP(S) origin without a path.');
|
|
19
|
+
}
|
|
20
|
+
if (process.env.NODE_ENV === 'production' && !configuredOrigin?.startsWith('https://')) throw new Error('Production requires an explicit HTTPS DASHBOARD_ORIGIN.');
|
|
21
|
+
const filename = options.database ?? databasePath();
|
|
22
|
+
mkdirSync(dirname(filename), { recursive: true });
|
|
23
|
+
const store = new DashboardStore(filename);
|
|
24
|
+
try {
|
|
25
|
+
const cards = new PrivateCards(store);
|
|
26
|
+
const auth = new DashboardAuth(store, options.sessionLifetimeMs);
|
|
27
|
+
const app = express();
|
|
28
|
+
app.disable('x-powered-by');
|
|
29
|
+
app.use(express.urlencoded({ extended: false, limit: '4kb', parameterLimit: 4 }));
|
|
30
|
+
const invalidBody: ErrorRequestHandler = (_error, _request, response, _next) => {
|
|
31
|
+
if (!response.destroyed) response.status(400).send('Invalid form submission.');
|
|
32
|
+
};
|
|
33
|
+
app.use(invalidBody);
|
|
34
|
+
const origin = () => configuredOrigin ?? `http://127.0.0.1:${(server.server.address() as { port: number }).port}`;
|
|
35
|
+
|
|
36
|
+
@page('/login', { live: false, css: 'app.css', head: { title: 'Sign in · Your cards' } })
|
|
37
|
+
class Login {
|
|
38
|
+
render() {
|
|
39
|
+
return <main class="home"><h1>Your private workspace</h1>
|
|
40
|
+
<p>Sign in with the credentials created by your administrator.</p>
|
|
41
|
+
<form method="post" action="/login">
|
|
42
|
+
<label for="account">Account</label><input id="account" name="account" autocomplete="username" required />
|
|
43
|
+
<label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required />
|
|
44
|
+
<button type="submit">Sign in</button>
|
|
45
|
+
</form>
|
|
46
|
+
</main>;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@page('/', { css: 'app.css', authorize: context => cards.allowed(context), head: { title: 'Your cards' } })
|
|
51
|
+
class Dashboard {
|
|
52
|
+
private readonly workspace = new Cards(cards);
|
|
53
|
+
render(context: LivePageRequestContext) {
|
|
54
|
+
return <main class="home"><header><div><h1>Your cards</h1><p>Signed in as {context.principal}</p></div>
|
|
55
|
+
<form method="post" action="/logout"><button type="submit">Sign out all sessions</button></form>
|
|
56
|
+
</header>{this.workspace}<p>Open another tab to see your changes instantly.</p></main>;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
auth.mount(app, origin, account => server.revoke(account));
|
|
61
|
+
const server = start([Login, Dashboard], {
|
|
62
|
+
server: app, port, bind: configuredOrigin ? '0.0.0.0' : '127.0.0.1', logger: null, templateRoot: __dirname,
|
|
63
|
+
origins: value => value === origin(),
|
|
64
|
+
authenticate: request => request.method === 'GET' && request.url?.split('?')[0] === '/login'
|
|
65
|
+
? true : store.session(sessionToken(request.headers.cookie))?.account,
|
|
66
|
+
});
|
|
67
|
+
let closing: Promise<void> | undefined;
|
|
68
|
+
const shutdown = () => {
|
|
69
|
+
auth.close();
|
|
70
|
+
if (!closing) {
|
|
71
|
+
closing = server.shutdown().finally(() => store.close());
|
|
72
|
+
}
|
|
73
|
+
return closing;
|
|
74
|
+
};
|
|
75
|
+
server.server.once('error', () => { void shutdown().catch(() => {}); });
|
|
76
|
+
return {
|
|
77
|
+
server: server.server,
|
|
78
|
+
shutdown,
|
|
79
|
+
};
|
|
80
|
+
} catch (error) { store.close(); throw error; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (require.main === module) {
|
|
84
|
+
const app = runApp(createApp);
|
|
85
|
+
app?.server.once('listening', () => console.log(`Dashboard: ${process.env.DASHBOARD_ORIGIN ?? `http://127.0.0.1:${(app.server.address() as { port: number }).port}`}/login`));
|
|
86
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import type { Application, Request, Response } from 'express';
|
|
4
|
+
import { DashboardStore, USERNAME, type Credentials } from './store';
|
|
5
|
+
|
|
6
|
+
const COOKIE = 'redweb_dashboard';
|
|
7
|
+
const DUMMY: Credentials = { salt: '00'.repeat(16), hash: '00'.repeat(64) };
|
|
8
|
+
|
|
9
|
+
export function sessionToken(cookie: string | undefined): string {
|
|
10
|
+
const matches = (cookie ?? '').split(';').map(part => part.trim()).filter(part => part.startsWith(`${COOKIE}=`));
|
|
11
|
+
const token = matches.length === 1 ? matches[0].slice(COOKIE.length + 1) : '';
|
|
12
|
+
return /^[A-Za-z0-9_-]{43}$/.test(token) ? token : '';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const passwordHash = promisify(scrypt);
|
|
16
|
+
|
|
17
|
+
export async function credentials(password: string): Promise<Credentials> {
|
|
18
|
+
if (typeof password !== 'string' || password.length < 16 || password.length > 128) throw new TypeError('Use a password of 16–128 characters.');
|
|
19
|
+
const salt = randomBytes(16).toString('hex');
|
|
20
|
+
return { salt, hash: (await passwordHash(password, salt, 64) as Buffer).toString('hex') };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Bounded asynchronous password work; neither proxy headers nor browser input establish identity. */
|
|
24
|
+
export class DashboardAuth {
|
|
25
|
+
private active = 0;
|
|
26
|
+
private closed = false;
|
|
27
|
+
private readonly attempts = new Map<string, { count: number; expires: number }>();
|
|
28
|
+
|
|
29
|
+
constructor(private readonly store: DashboardStore, private readonly ttlMs = 3600000) {
|
|
30
|
+
if (!Number.isInteger(ttlMs) || ttlMs < 100 || ttlMs > 86400000) throw new RangeError('Invalid session lifetime.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async login(ip: string, account: unknown, password: unknown): Promise<string | undefined> {
|
|
34
|
+
if (this.closed) return undefined;
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
for (const [key, entry] of this.attempts) if (entry.expires <= now) this.attempts.delete(key);
|
|
37
|
+
let attempt = this.attempts.get(ip);
|
|
38
|
+
if (!attempt) {
|
|
39
|
+
if (this.attempts.size >= 1024) return undefined;
|
|
40
|
+
attempt = { count: 0, expires: now + 60000 };
|
|
41
|
+
this.attempts.set(ip, attempt);
|
|
42
|
+
}
|
|
43
|
+
if (++attempt.count > 10 || this.active >= 4) return undefined;
|
|
44
|
+
if (typeof account !== 'string' || !USERNAME.test(account) || typeof password !== 'string' || password.length < 16 || password.length > 128) return undefined;
|
|
45
|
+
this.active++;
|
|
46
|
+
try {
|
|
47
|
+
const stored = this.store.credentials(account);
|
|
48
|
+
const expected = stored ?? DUMMY;
|
|
49
|
+
const actual = await passwordHash(password, expected.salt, 64) as Buffer;
|
|
50
|
+
if (this.closed) return undefined;
|
|
51
|
+
if (!timingSafeEqual(actual, Buffer.from(expected.hash, 'hex')) || !stored) return undefined;
|
|
52
|
+
return this.store.issue(account, this.ttlMs, stored.epoch);
|
|
53
|
+
} finally { this.active--; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
close() { this.closed = true; this.attempts.clear(); }
|
|
57
|
+
|
|
58
|
+
mount(app: Application, origin: () => string, revoke: (account: string) => Promise<unknown>) {
|
|
59
|
+
const cookie = (token: string) => `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${token ? Math.ceil(this.ttlMs / 1000) : 0}${origin().startsWith('https:') ? '; Secure' : ''}`;
|
|
60
|
+
const post = (route: string, handler: (request: Request, response: Response) => Promise<void>) => {
|
|
61
|
+
app.post(route, (request, response) => {
|
|
62
|
+
response.set('Cache-Control', 'private, no-store');
|
|
63
|
+
if (request.get('origin') !== origin()) { response.status(403).send('This form must be submitted from this site.'); return; }
|
|
64
|
+
void handler(request, response).catch(() => response.status(503).send('Unable to complete the request. Try again later.'));
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
post('/login', async (request, response) => {
|
|
68
|
+
const token = await this.login(String(request.socket.remoteAddress), request.body?.account, request.body?.password);
|
|
69
|
+
if (!token) { response.status(401).send('Unable to sign in. Check your credentials or try again later.'); return; }
|
|
70
|
+
response.setHeader('Set-Cookie', cookie(token));
|
|
71
|
+
response.redirect(303, '/');
|
|
72
|
+
});
|
|
73
|
+
post('/logout', async (request, response) => {
|
|
74
|
+
const account = this.store.signOut(sessionToken(request.headers.cookie));
|
|
75
|
+
response.setHeader('Set-Cookie', cookie(''));
|
|
76
|
+
if (account) await revoke(account);
|
|
77
|
+
response.redirect(303, '/login');
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|