redweb 0.13.3 → 0.13.5
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 +16 -0
- package/README.md +7 -5
- package/bin/redweb.js +1 -1
- package/docs/CLI.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/GETTING_STARTED.md +1 -1
- package/docs/LIVE_HTML.md +2 -2
- package/docs/MIGRATION.md +1 -1
- package/docs/RELEASE_TRUST.md +4 -4
- package/docs/RUNTIME_DIAGNOSTICS.md +1 -1
- package/docs/SOCKET_CONTRACTS.md +2 -2
- package/docs/generated.json +213 -213
- package/docs/releases/0.13.4.json +2154 -0
- package/docs/releases/0.13.5.json +2154 -0
- package/package.json +3 -2
- package/recipes/dashboard/README.md +3 -3
- package/recipes/dashboard/app.test.cjs +31 -1
- package/recipes/dashboard/app.tsx +22 -2
- package/recipes/dashboard/auth.ts +6 -4
- package/recipes/dashboard/rate-window.test.cjs +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "redweb",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.5",
|
|
4
4
|
"description": "A small Node.js foundation for HTTP, WebSockets, multiplayer services, and server-rendered HTML",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
"verify:live-html:browser": "node scripts/build-live-html-examples.js --check && node scripts/verify-live-html-browser.js",
|
|
84
84
|
"verify:browser:supplements": "node node_modules/jest/bin/jest.js tests/unit/browser-supplements.unit.test.js tests/integration/browser-supplements.integration.test.js --runInBand --coverage --collectCoverageFrom=scripts/lib/verify-live-page-ownership.js --collectCoverageFrom=scripts/lib/verify-runtime-browser.js --coverageDirectory=coverage/browser-supplements-authored",
|
|
85
85
|
"verify:dashboard:coverage": "node node_modules/jest/bin/jest.js tests/unit/dashboard-verifier.unit.test.js tests/integration/dashboard-verifier.integration.test.js --runInBand --coverage --collectCoverageFrom=scripts/lib/verify-dashboard-browser.js --coverageDirectory=coverage/dashboard-verifier",
|
|
86
|
+
"verify:starter:browser:coverage": "node node_modules/jest/bin/jest.js tests/unit/browser-acceptance.unit.test.js tests/unit/browser-handle-release.unit.test.js tests/unit/starter-browser.unit.test.js tests/integration/starter-browser.integration.test.js --runInBand --coverage --collectCoverageFrom=scripts/lib/BrowserAcceptance.js --collectCoverageFrom=scripts/lib/releaseBrowserHandles.js --collectCoverageFrom=scripts/lib/verify-starter-browser.js --coverageDirectory=coverage/starter-browser",
|
|
86
87
|
"verify:refresh:coverage": "node node_modules/jest/bin/jest.js tests/unit/refresh-controls-failures.unit.test.js tests/unit/refresh-observation.unit.test.js tests/unit/heading-readiness.unit.test.js tests/integration/refresh-observation.integration.test.js tests/integration/refresh-coverage.integration.test.js --runInBand --coverage --collectCoverageFrom=scripts/lib/verify-refresh-controls.js --collectCoverageFrom=scripts/lib/verify-refresh-coverage.js --coverageDirectory=coverage/refresh-verifier",
|
|
87
88
|
"verify:browser:coverage": "node node_modules/jest/bin/jest.js tests/unit/browser-coverage.unit.test.js tests/unit/application-coverage.unit.test.js tests/unit/packed-browser-harness.unit.test.js --runInBand --coverage --collectCoverageFrom=scripts/lib/BrowserCoverage.js --collectCoverageFrom=scripts/lib/ApplicationCoverage.js --collectCoverageFrom=scripts/lib/assertCoverageFile.js --coverageDirectory=coverage/browser-collector && npm run verify:browser:coordinator:coverage",
|
|
88
89
|
"verify:browser:coordinator:coverage": "node node_modules/jest/bin/jest.js tests/unit/browser-coordinator-report.unit.test.js tests/unit/browser-coordinator-lifecycle.unit.test.js tests/unit/browser-coverage-cleanup.unit.test.js tests/unit/browser-supplements.unit.test.js tests/unit/refresh-controls-failures.unit.test.js tests/unit/refresh-observation.unit.test.js tests/unit/heading-readiness.unit.test.js tests/integration/browser-coordinator.integration.test.js tests/integration/browser-supplements.integration.test.js tests/integration/refresh-observation.integration.test.js tests/integration/refresh-coverage.integration.test.js --runInBand --coverage --collectCoverageFrom=scripts/verify-browser-coverage.js --collectCoverageFrom=scripts/lib/verify-live-page-ownership.js --collectCoverageFrom=scripts/lib/verify-runtime-browser.js --collectCoverageFrom=scripts/lib/verify-refresh-controls.js --collectCoverageFrom=scripts/lib/verify-refresh-coverage.js --coverageDirectory=coverage/browser-coordinator",
|
|
@@ -107,7 +108,7 @@
|
|
|
107
108
|
"verify:package:examples:coverage": "node node_modules/jest/bin/jest.js tests/unit/packed-action.unit.test.js tests/unit/example-dependencies.unit.test.js tests/unit/example-probe.unit.test.js tests/integration/packed-action.integration.test.js tests/integration/example-dependencies.integration.test.js --runInBand --coverage --collectCoverageFrom=scripts/lib/performProbeAction.js --collectCoverageFrom=scripts/lib/verify-example-dependencies.js --collectCoverageFrom=scripts/lib/example-dependency-probe.cjs --coverageDirectory=coverage/example-dependency-tools",
|
|
108
109
|
"verify:package:browser:coverage": "node node_modules/jest/bin/jest.js tests/unit/browser-pages.unit.test.js tests/unit/packed-browser-verifier.unit.test.js tests/unit/browser-coverage-cleanup.unit.test.js tests/integration/packed-browser-verifier.integration.test.js --runInBand --silent --coverage --collectCoverageFrom=scripts/lib/verify-packed-browser.js --collectCoverageFrom=scripts/lib/BrowserPages.js --coverageDirectory=coverage/packed-browser-verifier",
|
|
109
110
|
"verify:client:link": "node --test tests/integration/npm-link.integration.test.cjs",
|
|
110
|
-
"test": "npx jest"
|
|
111
|
+
"test": "npx jest --testPathIgnorePatterns=soak-.*[.]test[.]js$ --testPathIgnorePatterns=benchmark-measurement[.]integration[.]test[.]js$"
|
|
111
112
|
},
|
|
112
113
|
"files": [
|
|
113
114
|
"bin",
|
|
@@ -14,11 +14,11 @@ npm test
|
|
|
14
14
|
npm run dev
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
The provisioning command displays a randomly generated password once. Save it securely; there are no default accounts or passwords. Open **http://127.0.0.1:8181/login**, sign in, and add a card. A second signed-in tab updates immediately. Restart the app: your cards and unexpired credentials remain valid. Sign out all sessions to close every connected tab for that account and invalidate all its cookies.
|
|
17
|
+
The provisioning command displays a randomly generated password once. Save it securely; there are no default accounts or passwords. Open **http://127.0.0.1:8181/login** or **http://localhost:8181/login**, sign in, and add a card. A second signed-in tab updates immediately. Restart the app: your cards and unexpired credentials remain valid. Sign out all sessions to close every connected tab for that account and invalidate all its cookies.
|
|
18
18
|
|
|
19
|
-
`npm test` provisions temporary test accounts and a real temporary database, then exercises HTTP, WebSockets, isolation, restart, and session expiry. It never modifies your application database. Integration tests use no mocks. A separately labelled unit test injects a cleanup error after closing a real SQLite database to verify rejection handling; it does not simulate a real operating-system failure.
|
|
19
|
+
`npm test` provisions temporary test accounts and a real temporary database, then exercises HTTP, WebSockets, isolation, restart, and session expiry. It never modifies your application database. Integration tests use no mocks, including hostile cross-loopback form and socket origins. Redweb's release gate also signs in with both valid and invalid credentials through a visible Chromium window, using `localhost` so synthetic request headers cannot hide browser-origin bugs. CI supplies a virtual display for that same headed test. A separately labelled unit test injects a cleanup error after closing a real SQLite database to verify rejection handling; it does not simulate a real operating-system failure.
|
|
20
20
|
|
|
21
|
-
`npm run test:coverage` measures the TypeScript application through source maps, separately from Redweb's own instrumented-library coverage.
|
|
21
|
+
`npm run test:coverage` measures the TypeScript application through source maps, separately from Redweb's own instrumented-library coverage. Login-window expiry uses the same real limiter with an explicitly short test configuration; it never waits through the production one-minute default. The report includes TypeScript-generated decorator accessor functions; inspect that distinction rather than assuming a library coverage figure applies to this recipe. The generated npm configuration enforces this recipe's Node engine requirement before installation.
|
|
22
22
|
|
|
23
23
|
## Where the behavior lives
|
|
24
24
|
|
|
@@ -8,7 +8,7 @@ const { DatabaseSync } = require('node:sqlite');
|
|
|
8
8
|
const { spawn, spawnSync } = require('node:child_process');
|
|
9
9
|
const net = require('node:net');
|
|
10
10
|
const { WebSocketServer, WebSocket } = require('ws');
|
|
11
|
-
const { createApp, databasePath } = require('../dist/app');
|
|
11
|
+
const { allowsDashboardOrigin, createApp, databasePath } = require('../dist/app');
|
|
12
12
|
const { DashboardStore } = require('../dist/store');
|
|
13
13
|
const { DashboardAuth, credentials, sessionToken } = require('../dist/auth');
|
|
14
14
|
const { PrivateCards } = require('../dist/cards');
|
|
@@ -70,9 +70,12 @@ async function cardClient(t, origin, cookie) {
|
|
|
70
70
|
test('private live cards: real HTTP, sockets, isolation, reconnect, sign-out and durable restart', async t => {
|
|
71
71
|
const fixtureApp = await fixture(t);
|
|
72
72
|
let { origin } = fixtureApp;
|
|
73
|
+
const localhost = origin.replace('127.0.0.1', 'localhost');
|
|
73
74
|
assert.equal((await fetch(`${origin}/login`)).status, 200);
|
|
74
75
|
assert.equal((await fetch(origin)).status, 401);
|
|
75
76
|
assert.equal((await post(origin, '/login', { account: 'alice', password }, '', 'https://foreign.example')).status, 403);
|
|
77
|
+
assert.equal((await post(origin, '/login', { account: 'alice', password }, '', localhost)).status, 403);
|
|
78
|
+
assert.equal((await post(localhost, '/login', { account: 'alice', password: 'wrong-password-at-least-16' })).status, 401);
|
|
76
79
|
assert.equal((await post(origin, '/login', { account: 'alice', password: 'wrong-password-at-least-16' })).status, 401);
|
|
77
80
|
const alice = await login(origin);
|
|
78
81
|
const alice2 = await login(origin);
|
|
@@ -80,6 +83,14 @@ test('private live cards: real HTTP, sockets, isolation, reconnect, sign-out and
|
|
|
80
83
|
const page = await fetch(origin, { headers: { Cookie: alice } });
|
|
81
84
|
assert.match(page.headers.get('cache-control'), /private.*no-store/);
|
|
82
85
|
assert.equal(page.headers.get('etag'), null);
|
|
86
|
+
const document = await page.text();
|
|
87
|
+
const pageConfig = JSON.parse(document.match(/id="__redweb_page">([^<]+)/)[1]);
|
|
88
|
+
const hostile = new WebSocket(`${origin.replace('http:', 'ws:')}${pageConfig.socketPath}?pageId=${pageConfig.pageId}&redwebVersion=${encodeURIComponent(pageConfig.version)}`,
|
|
89
|
+
{ headers: { Cookie: alice, Origin: localhost } });
|
|
90
|
+
hostile.on('error', () => {});
|
|
91
|
+
const [, rejection] = await once(hostile, 'unexpected-response');
|
|
92
|
+
assert.equal(rejection.statusCode, 403);
|
|
93
|
+
rejection.resume();
|
|
83
94
|
const first = await cardClient(t, origin, alice);
|
|
84
95
|
const second = await cardClient(t, origin, alice2);
|
|
85
96
|
const other = await cardClient(t, origin, bob);
|
|
@@ -124,6 +135,23 @@ test('private live cards: real HTTP, sockets, isolation, reconnect, sign-out and
|
|
|
124
135
|
await client.items(value => value.length === 1);
|
|
125
136
|
});
|
|
126
137
|
|
|
138
|
+
test('unit: development accepts only equivalent loopback browser origins on the listening port', () => {
|
|
139
|
+
const expected = 'http://127.0.0.1:8181';
|
|
140
|
+
assert.equal(allowsDashboardOrigin('http://localhost:8181', expected, false, 'localhost:8181'), true);
|
|
141
|
+
assert.equal(allowsDashboardOrigin('http://127.0.0.1:8181', expected, false, '127.0.0.1:8181'), true);
|
|
142
|
+
assert.equal(allowsDashboardOrigin('http://[::1]:8181', expected, false, '[::1]:8181'), true);
|
|
143
|
+
assert.equal(allowsDashboardOrigin('http://localhost:8181', expected, false, '127.0.0.1:8181'), false);
|
|
144
|
+
assert.equal(allowsDashboardOrigin('https://localhost:8181', expected, false, 'localhost:8181'), false);
|
|
145
|
+
assert.equal(allowsDashboardOrigin('http://localhost:8182', expected, false, 'localhost:8182'), false);
|
|
146
|
+
assert.equal(allowsDashboardOrigin('http://localhost:8181/path', expected, false, 'localhost:8181'), false);
|
|
147
|
+
assert.equal(allowsDashboardOrigin('not an origin', expected, false, 'localhost:8181'), false);
|
|
148
|
+
assert.equal(allowsDashboardOrigin(undefined, expected, false, 'localhost:8181'), false);
|
|
149
|
+
assert.equal(allowsDashboardOrigin('http://localhost:8181', expected, false), false);
|
|
150
|
+
assert.equal(allowsDashboardOrigin('http://localhost:8181', expected, false, 'localhost:8181/path'), false);
|
|
151
|
+
assert.equal(allowsDashboardOrigin('https://dashboard.example', 'https://dashboard.example', true), true);
|
|
152
|
+
assert.equal(allowsDashboardOrigin('http://localhost:8181', 'https://dashboard.example', true), false);
|
|
153
|
+
});
|
|
154
|
+
|
|
127
155
|
test('session expiry closes idle sockets and rejects later HTTP access', async t => {
|
|
128
156
|
const { origin } = await fixture(t, { sessionLifetimeMs: 1200 });
|
|
129
157
|
const cookie = await login(origin);
|
|
@@ -200,6 +228,8 @@ test('logout fences password checks in flight; close and admission bounds stop n
|
|
|
200
228
|
assert.equal(await closing, undefined);
|
|
201
229
|
assert.equal(await auth.login('peer', 'alice', password), undefined);
|
|
202
230
|
assert.throws(() => new DashboardAuth(store, 0));
|
|
231
|
+
assert.throws(() => new DashboardAuth(store, 3600000, 19));
|
|
232
|
+
assert.throws(() => new DashboardAuth(store, 3600000, 60001));
|
|
203
233
|
const limited = new DashboardAuth(store);
|
|
204
234
|
const concurrent = Array.from({ length: 5 }, (_, index) => limited.login(`peer-${index}`, 'alice', password));
|
|
205
235
|
assert.equal(await concurrent[4], undefined);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import express, { type ErrorRequestHandler } from 'express';
|
|
2
2
|
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import type { IncomingMessage } from 'node:http';
|
|
3
4
|
import { dirname, resolve } from 'node:path';
|
|
4
5
|
import { page, start, type LivePageRequestContext } from 'redweb';
|
|
5
6
|
import { DashboardAuth, sessionToken } from './auth';
|
|
@@ -11,6 +12,23 @@ export interface DashboardOptions { port?: number; database?: string; origin?: s
|
|
|
11
12
|
|
|
12
13
|
export function databasePath() { return resolve(process.env.DASHBOARD_DATABASE ?? 'data/dashboard.sqlite'); }
|
|
13
14
|
|
|
15
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
16
|
+
|
|
17
|
+
/** Development is loopback-only, so its equivalent browser hostnames share one trust boundary. */
|
|
18
|
+
export function allowsDashboardOrigin(candidate: string | undefined, expected: string, configured: boolean, host?: string) {
|
|
19
|
+
if (!candidate) return false;
|
|
20
|
+
if (configured) return candidate === expected;
|
|
21
|
+
if (!host || /[\/@?#\\]/.test(host)) return false;
|
|
22
|
+
try {
|
|
23
|
+
const actual = new URL(candidate);
|
|
24
|
+
const local = new URL(expected);
|
|
25
|
+
const target = new URL(`http://${host}`);
|
|
26
|
+
return actual.origin === candidate && actual.protocol === 'http:'
|
|
27
|
+
&& actual.origin === target.origin && actual.port === local.port
|
|
28
|
+
&& LOOPBACK_HOSTS.has(actual.hostname);
|
|
29
|
+
} catch { return false; }
|
|
30
|
+
}
|
|
31
|
+
|
|
14
32
|
export function createApp(options: DashboardOptions = {}) {
|
|
15
33
|
const port = options.port ?? Number(process.env.PORT ?? 8181);
|
|
16
34
|
const configuredOrigin = options.origin ?? process.env.DASHBOARD_ORIGIN;
|
|
@@ -32,6 +50,8 @@ export function createApp(options: DashboardOptions = {}) {
|
|
|
32
50
|
};
|
|
33
51
|
app.use(invalidBody);
|
|
34
52
|
const origin = () => configuredOrigin ?? `http://127.0.0.1:${(server.server.address() as { port: number }).port}`;
|
|
53
|
+
const allowsOrigin = (candidate: string | undefined, request: IncomingMessage) =>
|
|
54
|
+
allowsDashboardOrigin(candidate, origin(), Boolean(configuredOrigin), request.headers.host);
|
|
35
55
|
|
|
36
56
|
@page('/login', { live: false, css: 'app.css', head: { title: 'Sign in · Your cards' } })
|
|
37
57
|
class Login {
|
|
@@ -57,10 +77,10 @@ export function createApp(options: DashboardOptions = {}) {
|
|
|
57
77
|
}
|
|
58
78
|
}
|
|
59
79
|
|
|
60
|
-
auth.mount(app, origin, account => server.revoke(account));
|
|
80
|
+
auth.mount(app, origin, allowsOrigin, account => server.revoke(account));
|
|
61
81
|
const server = start([Login, Dashboard], {
|
|
62
82
|
server: app, port, bind: configuredOrigin ? '0.0.0.0' : '127.0.0.1', logger: null, templateRoot: __dirname,
|
|
63
|
-
origins:
|
|
83
|
+
origins: allowsOrigin,
|
|
64
84
|
authenticate: request => request.method === 'GET' && request.url?.split('?')[0] === '/login'
|
|
65
85
|
? true : store.session(sessionToken(request.headers.cookie))?.account,
|
|
66
86
|
});
|
|
@@ -26,8 +26,10 @@ export class DashboardAuth {
|
|
|
26
26
|
private closed = false;
|
|
27
27
|
private readonly attempts = new Map<string, { count: number; expires: number }>();
|
|
28
28
|
|
|
29
|
-
constructor(private readonly store: DashboardStore, private readonly ttlMs = 3600000
|
|
29
|
+
constructor(private readonly store: DashboardStore, private readonly ttlMs = 3600000,
|
|
30
|
+
private readonly attemptWindowMs = 60000) {
|
|
30
31
|
if (!Number.isInteger(ttlMs) || ttlMs < 100 || ttlMs > 86400000) throw new RangeError('Invalid session lifetime.');
|
|
32
|
+
if (!Number.isInteger(attemptWindowMs) || attemptWindowMs < 20 || attemptWindowMs > 60000) throw new RangeError('Invalid login attempt window.');
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
async login(ip: string, account: unknown, password: unknown): Promise<string | undefined> {
|
|
@@ -37,7 +39,7 @@ export class DashboardAuth {
|
|
|
37
39
|
let attempt = this.attempts.get(ip);
|
|
38
40
|
if (!attempt) {
|
|
39
41
|
if (this.attempts.size >= 1024) return undefined;
|
|
40
|
-
attempt = { count: 0, expires: now +
|
|
42
|
+
attempt = { count: 0, expires: now + this.attemptWindowMs };
|
|
41
43
|
this.attempts.set(ip, attempt);
|
|
42
44
|
}
|
|
43
45
|
if (++attempt.count > 10 || this.active >= 4) return undefined;
|
|
@@ -55,12 +57,12 @@ export class DashboardAuth {
|
|
|
55
57
|
|
|
56
58
|
close() { this.closed = true; this.attempts.clear(); }
|
|
57
59
|
|
|
58
|
-
mount(app: Application, origin: () => string, revoke: (account: string) => Promise<unknown>) {
|
|
60
|
+
mount(app: Application, origin: () => string, allowsOrigin: (candidate: string | undefined, request: Request) => boolean, revoke: (account: string) => Promise<unknown>) {
|
|
59
61
|
const cookie = (token: string) => `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${token ? Math.ceil(this.ttlMs / 1000) : 0}${origin().startsWith('https:') ? '; Secure' : ''}`;
|
|
60
62
|
const post = (route: string, handler: (request: Request, response: Response) => Promise<void>) => {
|
|
61
63
|
app.post(route, (request, response) => {
|
|
62
64
|
response.set('Cache-Control', 'private, no-store');
|
|
63
|
-
if (request.get('origin')
|
|
65
|
+
if (!allowsOrigin(request.get('origin'), request)) { response.status(403).send('This form must be submitted from this site.'); return; }
|
|
64
66
|
void handler(request, response).catch(() => response.status(503).send('Unable to complete the request. Try again later.'));
|
|
65
67
|
});
|
|
66
68
|
};
|
|
@@ -3,15 +3,15 @@ const { test } = require('node:test');
|
|
|
3
3
|
const { DashboardStore } = require('../dist/store');
|
|
4
4
|
const { DashboardAuth, credentials } = require('../dist/auth');
|
|
5
5
|
|
|
6
|
-
test('login admission reopens after
|
|
6
|
+
test('login admission reopens after its configured short real window', async t => {
|
|
7
7
|
const store = new DashboardStore(':memory:');
|
|
8
|
-
const auth = new DashboardAuth(store);
|
|
8
|
+
const auth = new DashboardAuth(store, 3600000, 250);
|
|
9
9
|
t.after(() => { auth.close(); store.close(); });
|
|
10
10
|
const password = 'test-only-login-window-password';
|
|
11
11
|
store.provision('alice', await credentials(password));
|
|
12
|
-
for (let attempt = 0; attempt < 10; attempt++) assert.equal(await auth.login('same-peer', 'invalid',
|
|
12
|
+
for (let attempt = 0; attempt < 10; attempt++) assert.equal(await auth.login('same-peer', 'invalid', 'short'), undefined);
|
|
13
13
|
assert.equal(await auth.login('same-peer', 'alice', password), undefined);
|
|
14
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
14
|
+
await new Promise(resolve => setTimeout(resolve, 275));
|
|
15
15
|
const token = await auth.login('same-peer', 'alice', password);
|
|
16
16
|
assert.equal(store.session(token).account, 'alice');
|
|
17
17
|
});
|