@saluzi/saluzi-edu 0.3.1 → 0.3.2
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/cli.js +194 -192
- package/dist/guide/guide-data.json +8 -0
- package/package.json +1 -1
- package/packages/remote-control-server/src/__tests__/otp-route.test.ts +130 -0
- package/packages/remote-control-server/src/__tests__/web-login.test.ts +84 -0
- package/packages/remote-control-server/src/nodeServer.ts +56 -19
- package/packages/remote-control-server/src/registerRoutes.ts +37 -0
- package/packages/remote-control-server/src/services/disconnect-monitor.ts +2 -2
- package/packages/remote-control-server/src/services/web-login.ts +96 -0
|
@@ -5920,6 +5920,14 @@
|
|
|
5920
5920
|
"defaultValue": "0.0.0.0",
|
|
5921
5921
|
"description": "RCS 监听地址"
|
|
5922
5922
|
},
|
|
5923
|
+
{
|
|
5924
|
+
"name": "RCS_ONE_WORKER_DIR",
|
|
5925
|
+
"category": "other"
|
|
5926
|
+
},
|
|
5927
|
+
{
|
|
5928
|
+
"name": "RCS_ONE_WORKER_NAME",
|
|
5929
|
+
"category": "other"
|
|
5930
|
+
},
|
|
5923
5931
|
{
|
|
5924
5932
|
"name": "RCS_PORT",
|
|
5925
5933
|
"category": "runtime",
|
package/package.json
CHANGED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, mock } from 'bun:test'
|
|
2
|
+
import { createMockConfig } from './helpers/mock-config'
|
|
3
|
+
|
|
4
|
+
// Mutable config mock — tests flip `singleUser` to exercise the
|
|
5
|
+
// single-user-only gate on GET /otp/:code.
|
|
6
|
+
const configState = createMockConfig()
|
|
7
|
+
mock.module('../config', () => ({
|
|
8
|
+
config: configState,
|
|
9
|
+
getBaseUrl: () => configState.baseUrl,
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
import { Hono } from 'hono'
|
|
13
|
+
import type { MiddlewareHandler } from 'hono/types'
|
|
14
|
+
import { randomUUID } from 'node:crypto'
|
|
15
|
+
import { tmpdir } from 'node:os'
|
|
16
|
+
import { resolve } from 'node:path'
|
|
17
|
+
import { initDatabase, resetDbSingleton, getDb } from '../db/sqlite'
|
|
18
|
+
import { registerRoutes } from '../registerRoutes'
|
|
19
|
+
import { _resetWebLoginCodes, issueWebLoginCode } from '../services/web-login'
|
|
20
|
+
import { createUserDirectly } from './helpers/fixtures'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* GET /otp/:code — one-time browser login for `slz rcs one`:
|
|
24
|
+
* - valid code (single-user mode) → session cookies for the single admin
|
|
25
|
+
* + redirect to the stored target path; the minted token authenticates
|
|
26
|
+
* against /web/auth/me
|
|
27
|
+
* - codes are single-use — a second hit redirects to /login
|
|
28
|
+
* - unknown/expired codes redirect to /login (manual login still works)
|
|
29
|
+
* - full mode (singleUser=false) → 404 — the route is one-only
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
// Dummy serveStatic — passes through so API routes handle everything.
|
|
33
|
+
const serveStatic: (opts: Record<string, unknown>) => MiddlewareHandler =
|
|
34
|
+
() => async (_c, next) => {
|
|
35
|
+
await next()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Dummy upgradeWebSocket
|
|
39
|
+
const upgradeWebSocket = (() => {
|
|
40
|
+
return () =>
|
|
41
|
+
async (_c: import('hono').Context, next: import('hono').Next) => {
|
|
42
|
+
await next()
|
|
43
|
+
}
|
|
44
|
+
}) as unknown as import('hono/ws').UpgradeWebSocket<unknown>
|
|
45
|
+
|
|
46
|
+
function createApp(): Hono {
|
|
47
|
+
const app = new Hono()
|
|
48
|
+
registerRoutes(app, serveStatic, upgradeWebSocket)
|
|
49
|
+
return app
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function setupTestDb(): ReturnType<typeof getDb> {
|
|
53
|
+
try {
|
|
54
|
+
resetDbSingleton()
|
|
55
|
+
} catch {
|
|
56
|
+
// ignore
|
|
57
|
+
}
|
|
58
|
+
const uniquePath = resolve(
|
|
59
|
+
tmpdir(),
|
|
60
|
+
`rcs-otp-${randomUUID().replace(/-/g, '').slice(0, 8)}.db`,
|
|
61
|
+
)
|
|
62
|
+
return initDatabase(uniquePath)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('GET /otp/:code', () => {
|
|
66
|
+
let app: Hono
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
setupTestDb()
|
|
70
|
+
_resetWebLoginCodes()
|
|
71
|
+
configState.singleUser = true
|
|
72
|
+
app = createApp()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('valid code sets admin session cookies and redirects to the target', async () => {
|
|
76
|
+
const db = getDb()
|
|
77
|
+
const admin = createUserDirectly(db, { username: 'admin', role: 'admin' })
|
|
78
|
+
const issued = issueWebLoginCode(admin.id, '/code/session_abc')
|
|
79
|
+
|
|
80
|
+
const res = await app.request(`/otp/${issued.code}`)
|
|
81
|
+
expect(res.status).toBe(302)
|
|
82
|
+
expect(res.headers.get('Location')).toBe('/code/session_abc')
|
|
83
|
+
|
|
84
|
+
const cookies = res.headers.getSetCookie()
|
|
85
|
+
const accessCookie = cookies.find(c => c.startsWith('rcs_access='))
|
|
86
|
+
const refreshCookie = cookies.find(c => c.startsWith('rcs_refresh='))
|
|
87
|
+
expect(accessCookie).toBeDefined()
|
|
88
|
+
expect(refreshCookie).toBeDefined()
|
|
89
|
+
|
|
90
|
+
// The minted access token must authenticate as the admin user.
|
|
91
|
+
const accessToken = accessCookie!.split(';')[0]!.slice('rcs_access='.length)
|
|
92
|
+
const me = await app.request('/web/auth/me', {
|
|
93
|
+
headers: { Cookie: `rcs_access=${accessToken}` },
|
|
94
|
+
})
|
|
95
|
+
expect(me.status).toBe(200)
|
|
96
|
+
const meBody = (await me.json()) as { userId: string; role: string }
|
|
97
|
+
expect(meBody.userId).toBe(admin.id)
|
|
98
|
+
expect(meBody.role).toBe('admin')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('codes are single-use — a second hit falls back to /login', async () => {
|
|
102
|
+
const db = getDb()
|
|
103
|
+
const admin = createUserDirectly(db, { username: 'admin', role: 'admin' })
|
|
104
|
+
const issued = issueWebLoginCode(admin.id, '/code/')
|
|
105
|
+
|
|
106
|
+
const first = await app.request(`/otp/${issued.code}`)
|
|
107
|
+
expect(first.status).toBe(302)
|
|
108
|
+
expect(first.headers.get('Location')).toBe('/code/')
|
|
109
|
+
|
|
110
|
+
const second = await app.request(`/otp/${issued.code}`)
|
|
111
|
+
expect(second.status).toBe(302)
|
|
112
|
+
expect(second.headers.get('Location')).toBe('/login')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('unknown code redirects to /login', async () => {
|
|
116
|
+
const res = await app.request('/otp/wlc_unknowncodeunknowncode')
|
|
117
|
+
expect(res.status).toBe(302)
|
|
118
|
+
expect(res.headers.get('Location')).toBe('/login')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('returns 404 in full (non-single-user) mode', async () => {
|
|
122
|
+
configState.singleUser = false
|
|
123
|
+
const db = getDb()
|
|
124
|
+
const admin = createUserDirectly(db, { username: 'admin', role: 'admin' })
|
|
125
|
+
const issued = issueWebLoginCode(admin.id, '/code/')
|
|
126
|
+
|
|
127
|
+
const res = await app.request(`/otp/${issued.code}`)
|
|
128
|
+
expect(res.status).toBe(404)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
MAX_PENDING_CODES,
|
|
4
|
+
consumeWebLoginCode,
|
|
5
|
+
issueWebLoginCode,
|
|
6
|
+
_resetWebLoginCodes,
|
|
7
|
+
} from '../services/web-login'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One-time web login codes (`slz rcs one` browser auto-open):
|
|
11
|
+
* - single use — the first consume returns the record, the second null
|
|
12
|
+
* - 2-minute TTL — expired codes are rejected and pruned
|
|
13
|
+
* - target paths are clamped to same-origin paths (no '//host' open redirect)
|
|
14
|
+
* - the pending map is bounded
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
describe('web-login codes', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
_resetWebLoginCodes()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('consume returns the record exactly once (single use)', () => {
|
|
23
|
+
const issued = issueWebLoginCode('usr_abc', '/code/session_1')
|
|
24
|
+
expect(issued.code).toMatch(/^wlc_[0-9a-f]{32}$/)
|
|
25
|
+
expect(issued.targetPath).toBe('/code/session_1')
|
|
26
|
+
expect(issued.userId).toBe('usr_abc')
|
|
27
|
+
|
|
28
|
+
const consumed = consumeWebLoginCode(issued.code)
|
|
29
|
+
expect(consumed).not.toBeNull()
|
|
30
|
+
expect(consumed?.userId).toBe('usr_abc')
|
|
31
|
+
expect(consumed?.targetPath).toBe('/code/session_1')
|
|
32
|
+
|
|
33
|
+
// Second consume — already used.
|
|
34
|
+
expect(consumeWebLoginCode(issued.code)).toBeNull()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('unknown code returns null', () => {
|
|
38
|
+
expect(
|
|
39
|
+
consumeWebLoginCode('wlc_deadbeefdeadbeefdeadbeefdeadbeef'),
|
|
40
|
+
).toBeNull()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('targetPath is clamped to a same-origin path', () => {
|
|
44
|
+
expect(issueWebLoginCode('usr_abc', '/code/session_1').targetPath).toBe(
|
|
45
|
+
'/code/session_1',
|
|
46
|
+
)
|
|
47
|
+
// No leading slash → root.
|
|
48
|
+
expect(issueWebLoginCode('usr_abc', 'code/session_1').targetPath).toBe('/')
|
|
49
|
+
// Protocol-relative would be an open redirect — clamp.
|
|
50
|
+
expect(issueWebLoginCode('usr_abc', '//evil.example/x').targetPath).toBe(
|
|
51
|
+
'/',
|
|
52
|
+
)
|
|
53
|
+
// Absolute URL → root.
|
|
54
|
+
expect(
|
|
55
|
+
issueWebLoginCode('usr_abc', 'https://evil.example/x').targetPath,
|
|
56
|
+
).toBe('/')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('expired code is rejected', () => {
|
|
60
|
+
const issued = issueWebLoginCode('usr_abc', '/code/')
|
|
61
|
+
const realNow = Date.now
|
|
62
|
+
// Jump past the 2-minute TTL.
|
|
63
|
+
Date.now = () => realNow() + 3 * 60 * 1000
|
|
64
|
+
try {
|
|
65
|
+
expect(consumeWebLoginCode(issued.code)).toBeNull()
|
|
66
|
+
} finally {
|
|
67
|
+
Date.now = realNow
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('pending map is bounded — oldest entries are evicted', () => {
|
|
72
|
+
const oldest = issueWebLoginCode('usr_abc', '/code/oldest')
|
|
73
|
+
// Fill to exactly the cap without exceeding it.
|
|
74
|
+
let last: ReturnType<typeof issueWebLoginCode> | undefined
|
|
75
|
+
for (let i = 1; i < MAX_PENDING_CODES; i++) {
|
|
76
|
+
last = issueWebLoginCode('usr_abc', `/code/fill-${i}`)
|
|
77
|
+
}
|
|
78
|
+
// One more beyond the cap — the oldest was evicted to make room…
|
|
79
|
+
issueWebLoginCode('usr_abc', '/code/overflow')
|
|
80
|
+
expect(consumeWebLoginCode(oldest.code)).toBeNull()
|
|
81
|
+
// …while recent entries stay intact.
|
|
82
|
+
expect(consumeWebLoginCode(last!.code)).not.toBeNull()
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -37,9 +37,26 @@ export interface RcsServerOptions {
|
|
|
37
37
|
/** Single-user mode (`slz rcs one`): auto-init admin account, workers
|
|
38
38
|
* auto-owned, registration disabled, host defaults to 127.0.0.1. */
|
|
39
39
|
singleUser?: boolean
|
|
40
|
+
/** Embedded mode: the server shares its process with something else that
|
|
41
|
+
* owns the terminal and the process lifecycle (e.g. `slz rcs one` boots
|
|
42
|
+
* an in-process REPL as the single repl worker). Skips the SIGINT/SIGTERM
|
|
43
|
+
* handlers — they would process.exit(0) on the REPL's first Ctrl+C — and
|
|
44
|
+
* skips the block-forever await so the caller can continue booting. */
|
|
45
|
+
embedded?: boolean
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
|
|
48
|
+
/** Bound server coordinates, as returned by startRcsServer. */
|
|
49
|
+
export interface RcsServerInfo {
|
|
50
|
+
port: number
|
|
51
|
+
/** The address the server actually bound (may be 0.0.0.0). */
|
|
52
|
+
host: string
|
|
53
|
+
/** Browser-reachable origin (0.0.0.0/:: mapped to localhost). */
|
|
54
|
+
webUiUrl: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function startRcsServer(
|
|
58
|
+
opts: RcsServerOptions,
|
|
59
|
+
): Promise<RcsServerInfo> {
|
|
43
60
|
// Set env vars so config module picks them up
|
|
44
61
|
process.env.RCS_API_KEYS = opts.apiKeys.join(',')
|
|
45
62
|
// The config module may already be evaluated (static import above) — its
|
|
@@ -55,18 +72,6 @@ export async function startRcsServer(opts: RcsServerOptions): Promise<void> {
|
|
|
55
72
|
config.host = opts.host
|
|
56
73
|
}
|
|
57
74
|
if (opts.singleUser) process.env.RCS_SINGLE_USER = 'true'
|
|
58
|
-
// The config module may already be evaluated (static import above) — its
|
|
59
|
-
// env-derived values are frozen, so opts.host/opts.port must also be
|
|
60
|
-
// applied to the live object or they would silently no-op.
|
|
61
|
-
if (opts.port) {
|
|
62
|
-
process.env.RCS_PORT = String(opts.port)
|
|
63
|
-
config.port = opts.port
|
|
64
|
-
}
|
|
65
|
-
if (opts.host) {
|
|
66
|
-
process.env.RCS_HOST = opts.host
|
|
67
|
-
config.host = opts.host
|
|
68
|
-
}
|
|
69
|
-
if (opts.singleUser) process.env.RCS_SINGLE_USER = 'true'
|
|
70
75
|
|
|
71
76
|
// Effective single-user mode: the `one` argument OR RCS_SINGLE_USER=true
|
|
72
77
|
// set before process start (config.singleUser already reflects the env).
|
|
@@ -93,8 +98,13 @@ export async function startRcsServer(opts: RcsServerOptions): Promise<void> {
|
|
|
93
98
|
// Create Node.js WebSocket adapter
|
|
94
99
|
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
|
|
95
100
|
|
|
96
|
-
// Middleware
|
|
97
|
-
|
|
101
|
+
// Middleware. Per-request hono logger only in standalone mode — embedded
|
|
102
|
+
// mode shares the terminal with the host flow (`slz rcs one` prints a
|
|
103
|
+
// clean banner; the worker logs to its own file), so request spam would
|
|
104
|
+
// just be noise.
|
|
105
|
+
if (!opts.embedded) {
|
|
106
|
+
app.use('*', logger())
|
|
107
|
+
}
|
|
98
108
|
app.use('*', async (c: Context, next: Next) => {
|
|
99
109
|
const path = new URL(c.req.url).pathname
|
|
100
110
|
if (path.includes('//')) {
|
|
@@ -142,11 +152,28 @@ export async function startRcsServer(opts: RcsServerOptions): Promise<void> {
|
|
|
142
152
|
hostname: host,
|
|
143
153
|
})
|
|
144
154
|
|
|
155
|
+
// Port conflicts (EADDRINUSE) surface asynchronously as an 'error' event —
|
|
156
|
+
// without a listener the process crashes with an ugly unhandled error.
|
|
157
|
+
// Embedded mode keeps running (the REPL owns the process; the bridge will
|
|
158
|
+
// talk to whatever server already owns the port); standalone exits loudly.
|
|
159
|
+
server.on('error', (err: NodeJS.ErrnoException) => {
|
|
160
|
+
console.error(`[RCS] Server error: ${err.message}`)
|
|
161
|
+
if (!opts.embedded) {
|
|
162
|
+
// eslint-disable-next-line custom-rules/no-process-exit
|
|
163
|
+
process.exit(1)
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
145
167
|
// Inject WebSocket support for Node.js
|
|
146
168
|
injectWebSocket(server)
|
|
147
169
|
|
|
148
|
-
// Start disconnect monitor
|
|
149
|
-
|
|
170
|
+
// Start disconnect monitor. Unref'd: the HTTP server (standalone) or the
|
|
171
|
+
// host process (embedded) owns the event loop — the interval must not
|
|
172
|
+
// keep an exited REPL's process alive.
|
|
173
|
+
const monitorTimer = startDisconnectMonitor()
|
|
174
|
+
if (monitorTimer.unref) {
|
|
175
|
+
monitorTimer.unref()
|
|
176
|
+
}
|
|
150
177
|
|
|
151
178
|
console.log(
|
|
152
179
|
`[RCS] Remote Control Server starting on ${host}:${port} (Node.js)`,
|
|
@@ -192,6 +219,15 @@ export async function startRcsServer(opts: RcsServerOptions): Promise<void> {
|
|
|
192
219
|
)
|
|
193
220
|
}
|
|
194
221
|
|
|
222
|
+
// Embedded mode: the caller owns the process lifecycle (e.g. `slz rcs one`
|
|
223
|
+
// boots an in-process REPL as the single repl worker). No signal handlers
|
|
224
|
+
// — they would process.exit(0) on the REPL's first Ctrl+C — and no
|
|
225
|
+
// block-forever: return the bound coordinates so the caller can wire the
|
|
226
|
+
// in-process bridge + browser to it.
|
|
227
|
+
if (opts.embedded) {
|
|
228
|
+
return { port, host, webUiUrl: `http://${webUiHost}:${port}` }
|
|
229
|
+
}
|
|
230
|
+
|
|
195
231
|
// Graceful shutdown
|
|
196
232
|
function gracefulShutdown(signal: string) {
|
|
197
233
|
console.log(`\n[RCS] Received ${signal}, shutting down...`)
|
|
@@ -205,6 +241,7 @@ export async function startRcsServer(opts: RcsServerOptions): Promise<void> {
|
|
|
205
241
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'))
|
|
206
242
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
|
|
207
243
|
|
|
208
|
-
// Block forever — server keeps running
|
|
209
|
-
await
|
|
244
|
+
// Block forever — server keeps running. The promise never resolves; it
|
|
245
|
+
// only exists so the caller's await parks and the return type holds.
|
|
246
|
+
return new Promise<RcsServerInfo>(() => {})
|
|
210
247
|
}
|
|
@@ -7,6 +7,11 @@ import { fileURLToPath } from 'node:url'
|
|
|
7
7
|
import { createAcpApp } from './routes/acp'
|
|
8
8
|
import { createSessionIngressApp } from './routes/v1/session-ingress'
|
|
9
9
|
import { csrfCheck } from './auth/csrf'
|
|
10
|
+
import { config } from './config'
|
|
11
|
+
import { getDb } from './db/sqlite'
|
|
12
|
+
import { issueSessionToken } from './auth/session'
|
|
13
|
+
import { buildSessionCookies } from './auth/cookie'
|
|
14
|
+
import { consumeWebLoginCode } from './services/web-login'
|
|
10
15
|
|
|
11
16
|
// Routes
|
|
12
17
|
import v1Artifacts from './routes/v1/artifacts'
|
|
@@ -134,6 +139,38 @@ export function registerRoutes(
|
|
|
134
139
|
app.get(`${p}/*`, ss({ root: webDir, path: 'index.html' }))
|
|
135
140
|
}
|
|
136
141
|
|
|
142
|
+
// GET /otp/:code — one-time browser login for `slz rcs one`.
|
|
143
|
+
//
|
|
144
|
+
// The CLI mints a single-use code in-process right before opening the
|
|
145
|
+
// browser (services/web-login.ts). The browser hits this route, the
|
|
146
|
+
// server sets the single admin's session cookies and redirects to the
|
|
147
|
+
// stored target path — typically /code/<sessionId>, so a fresh browser
|
|
148
|
+
// lands straight in the just-created session instead of the login page.
|
|
149
|
+
// Codes are 2-minute TTL and only exist in single-user mode; full mode
|
|
150
|
+
// keeps the username/password ceremony.
|
|
151
|
+
app.get('/otp/:code', async c => {
|
|
152
|
+
if (!config.singleUser) {
|
|
153
|
+
return c.json({ error: 'Not found' }, 404)
|
|
154
|
+
}
|
|
155
|
+
const record = consumeWebLoginCode(c.req.param('code')!)
|
|
156
|
+
if (!record) {
|
|
157
|
+
// Unknown/expired/used — the manual login page still works
|
|
158
|
+
// (single-user mode hints the default credentials there).
|
|
159
|
+
return c.redirect('/login')
|
|
160
|
+
}
|
|
161
|
+
const db = getDb()
|
|
162
|
+
const { accessToken, refreshToken } = issueSessionToken(record.userId, db)
|
|
163
|
+
const secure =
|
|
164
|
+
c.req.header('X-Forwarded-Proto') === 'https' ||
|
|
165
|
+
c.req.url.startsWith('https')
|
|
166
|
+
for (const cookie of buildSessionCookies(accessToken, refreshToken, {
|
|
167
|
+
secure,
|
|
168
|
+
})) {
|
|
169
|
+
c.header('Set-Cookie', cookie, { append: true })
|
|
170
|
+
}
|
|
171
|
+
return c.redirect(record.targetPath)
|
|
172
|
+
})
|
|
173
|
+
|
|
137
174
|
// Root redirect → /code/ (main dashboard entry point)
|
|
138
175
|
app.get('/', c => c.redirect('/code/'))
|
|
139
176
|
}
|
|
@@ -51,8 +51,8 @@ export function runDisconnectMonitorSweep(now = Date.now()) {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export function startDisconnectMonitor() {
|
|
55
|
-
setInterval(() => {
|
|
54
|
+
export function startDisconnectMonitor(): ReturnType<typeof setInterval> {
|
|
55
|
+
return setInterval(() => {
|
|
56
56
|
runDisconnectMonitorSweep()
|
|
57
57
|
}, 60_000) // Check every minute
|
|
58
58
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One-time browser login codes for single-user mode (`slz rcs one`).
|
|
5
|
+
*
|
|
6
|
+
* `slz rcs one` auto-opens the Web UI straight into the freshly created
|
|
7
|
+
* session — but a fresh browser has no session cookies, so a plain deep
|
|
8
|
+
* link would bounce off the login page. Instead the CLI mints a
|
|
9
|
+
* short-lived one-time code in-process (only the server process itself
|
|
10
|
+
* can mint one), the browser hits GET /otp/<code>, and the server sets
|
|
11
|
+
* the single admin's session cookies before redirecting to the stored
|
|
12
|
+
* target path (see registerRoutes.ts).
|
|
13
|
+
*
|
|
14
|
+
* Trust model: same as the rest of single-user mode — loopback binding,
|
|
15
|
+
* the API key is the only real credential, and the code is a random
|
|
16
|
+
* 122-bit value that is single-use and expires in two minutes. Codes
|
|
17
|
+
* live in memory only: a server restart invalidates any unspent ones.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface WebLoginCodeRecord {
|
|
21
|
+
code: string
|
|
22
|
+
userId: string
|
|
23
|
+
/** Path the browser is redirected to after login (must start with '/'). */
|
|
24
|
+
targetPath: string
|
|
25
|
+
expiresAt: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const CODE_TTL_MS = 2 * 60 * 1000
|
|
29
|
+
/** Upper bound so a pathological minter can't grow the map forever. */
|
|
30
|
+
export const MAX_PENDING_CODES = 64
|
|
31
|
+
|
|
32
|
+
const pendingCodes = new Map<string, WebLoginCodeRecord>()
|
|
33
|
+
|
|
34
|
+
function pruneExpired(now = Date.now()): void {
|
|
35
|
+
for (const [code, record] of pendingCodes) {
|
|
36
|
+
if (record.expiresAt <= now) {
|
|
37
|
+
pendingCodes.delete(code)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Clamp a redirect target to a same-origin path ('/…', never '//…'). */
|
|
43
|
+
function toSafeTargetPath(targetPath: string): string {
|
|
44
|
+
return targetPath.startsWith('/') && !targetPath.startsWith('//')
|
|
45
|
+
? targetPath
|
|
46
|
+
: '/'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Mint a one-time login code for a user + redirect target.
|
|
51
|
+
* The target path is clamped to a same-origin path — protocol-relative
|
|
52
|
+
* ('//host') and absolute URLs are rejected to block open redirects.
|
|
53
|
+
*/
|
|
54
|
+
export function issueWebLoginCode(
|
|
55
|
+
userId: string,
|
|
56
|
+
targetPath: string,
|
|
57
|
+
): WebLoginCodeRecord {
|
|
58
|
+
pruneExpired()
|
|
59
|
+
if (pendingCodes.size >= MAX_PENDING_CODES) {
|
|
60
|
+
// Drop the oldest — Map preserves insertion order.
|
|
61
|
+
const oldest = pendingCodes.keys().next().value
|
|
62
|
+
if (oldest !== undefined) {
|
|
63
|
+
pendingCodes.delete(oldest)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const record: WebLoginCodeRecord = {
|
|
67
|
+
code: `wlc_${randomUUID().replace(/-/g, '')}`,
|
|
68
|
+
userId,
|
|
69
|
+
targetPath: toSafeTargetPath(targetPath),
|
|
70
|
+
expiresAt: Date.now() + CODE_TTL_MS,
|
|
71
|
+
}
|
|
72
|
+
pendingCodes.set(record.code, record)
|
|
73
|
+
return record
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Consume a login code (single use). Returns null when the code is
|
|
78
|
+
* unknown, already used, or expired.
|
|
79
|
+
*/
|
|
80
|
+
export function consumeWebLoginCode(code: string): WebLoginCodeRecord | null {
|
|
81
|
+
pruneExpired()
|
|
82
|
+
const record = pendingCodes.get(code)
|
|
83
|
+
if (!record) {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
pendingCodes.delete(code)
|
|
87
|
+
if (record.expiresAt <= Date.now()) {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
return record
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Test hook — clear all pending codes. */
|
|
94
|
+
export function _resetWebLoginCodes(): void {
|
|
95
|
+
pendingCodes.clear()
|
|
96
|
+
}
|