@open-mercato/shared 0.7.1-develop.7151.1.00d0391847 → 0.7.1-develop.7153.1.7145d295e6
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/.turbo/turbo-build.log +1 -1
- package/dist/lib/dev-runtime/layout.js +22 -0
- package/dist/lib/dev-runtime/layout.js.map +7 -0
- package/dist/lib/dev-runtime/redaction.js +25 -0
- package/dist/lib/dev-runtime/redaction.js.map +7 -0
- package/dist/lib/dev-runtime/report.js +92 -0
- package/dist/lib/dev-runtime/report.js.map +7 -0
- package/dist/lib/dev-runtime/routes.js +164 -0
- package/dist/lib/dev-runtime/routes.js.map +7 -0
- package/dist/lib/dev-runtime/server.js +111 -0
- package/dist/lib/dev-runtime/server.js.map +7 -0
- package/dist/lib/dev-runtime/types.js +23 -0
- package/dist/lib/dev-runtime/types.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/dev-runtime/__tests__/routes.test.ts +473 -0
- package/src/lib/dev-runtime/layout.ts +39 -0
- package/src/lib/dev-runtime/redaction.ts +29 -0
- package/src/lib/dev-runtime/report.ts +116 -0
- package/src/lib/dev-runtime/routes.ts +219 -0
- package/src/lib/dev-runtime/server.ts +174 -0
- package/src/lib/dev-runtime/types.ts +101 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createDevRuntimeActionsRoute,
|
|
7
|
+
createDevRuntimeLogsRoute,
|
|
8
|
+
createDevRuntimeDiagnosticsRoute,
|
|
9
|
+
createDevRuntimeStatusRoute,
|
|
10
|
+
} from '../routes'
|
|
11
|
+
import { readDevRuntimeStatus, resolveDevRuntimeServerConfig, type DevRuntimeServerConfig } from '../server'
|
|
12
|
+
import { DEV_RUNTIME_TOKEN_HEADER, type RuntimeStatus } from '../types'
|
|
13
|
+
|
|
14
|
+
const TOKEN = 'dev-runtime-token-fixture'
|
|
15
|
+
|
|
16
|
+
function createStatus(overrides: Partial<RuntimeStatus> = {}): RuntimeStatus {
|
|
17
|
+
return {
|
|
18
|
+
schemaVersion: 1,
|
|
19
|
+
generation: 1,
|
|
20
|
+
health: 'degraded',
|
|
21
|
+
ready: true,
|
|
22
|
+
failed: false,
|
|
23
|
+
updatedAt: '2026-08-18T10:00:00.000Z',
|
|
24
|
+
upstream: { configuredPort: 3000, publicUrl: 'http://localhost:3000' },
|
|
25
|
+
incidents: [],
|
|
26
|
+
legacy: { failureLines: [] },
|
|
27
|
+
...overrides,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createTempDir(): string {
|
|
32
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'om-dev-runtime-routes-'))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createConfig(directory: string, overrides: Partial<DevRuntimeServerConfig> = {}): DevRuntimeServerConfig {
|
|
36
|
+
return {
|
|
37
|
+
enabled: true,
|
|
38
|
+
bannerEnabled: true,
|
|
39
|
+
token: TOKEN,
|
|
40
|
+
statusFilePath: path.join(directory, 'status.json'),
|
|
41
|
+
diagnosticsFilePath: path.join(directory, 'diagnostics.ndjson'),
|
|
42
|
+
actionsFilePath: path.join(directory, 'actions.ndjson'),
|
|
43
|
+
logsFilePath: path.join(directory, 'logs.json'),
|
|
44
|
+
...overrides,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeStatusFile(config: DevRuntimeServerConfig, status: RuntimeStatus, token = TOKEN): void {
|
|
49
|
+
fs.writeFileSync(config.statusFilePath!, JSON.stringify({ token, pid: process.pid, status }), 'utf8')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function statusRequest(headers: Record<string, string> = { [DEV_RUNTIME_TOKEN_HEADER]: TOKEN }): Request {
|
|
53
|
+
return new Request('http://localhost:3000/api/dev-runtime/status', { headers })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function diagnosticsRequest(body: unknown, headers: Record<string, string> = {}): Request {
|
|
57
|
+
return new Request('http://localhost:3000/api/dev-runtime/diagnostics', {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: {
|
|
60
|
+
'content-type': 'application/json',
|
|
61
|
+
[DEV_RUNTIME_TOKEN_HEADER]: TOKEN,
|
|
62
|
+
...headers,
|
|
63
|
+
},
|
|
64
|
+
body: typeof body === 'string' ? body : JSON.stringify(body),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('resolveDevRuntimeServerConfig', () => {
|
|
69
|
+
const baseEnv = {
|
|
70
|
+
NODE_ENV: 'development',
|
|
71
|
+
OM_DEV_RUNTIME_DIAGNOSTICS: '1',
|
|
72
|
+
OM_DEV_RUNTIME_TOKEN: TOKEN,
|
|
73
|
+
OM_DEV_RUNTIME_STATUS_FILE: '/tmp/status.json',
|
|
74
|
+
OM_DEV_RUNTIME_DIAGNOSTICS_FILE: '/tmp/diagnostics.ndjson',
|
|
75
|
+
} as NodeJS.ProcessEnv
|
|
76
|
+
|
|
77
|
+
it('enables diagnostics only for a supervised development process', () => {
|
|
78
|
+
expect(resolveDevRuntimeServerConfig(baseEnv).enabled).toBe(true)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// `mercato dev` runs the Next.js dev server with NODE_ENV=production
|
|
82
|
+
// (buildServerProcessEnvironment), so NODE_ENV cannot be the production guard
|
|
83
|
+
// — the supervisor handshake is.
|
|
84
|
+
it('stays enabled under NODE_ENV=production while the supervisor handshake is present', () => {
|
|
85
|
+
expect(resolveDevRuntimeServerConfig({ ...baseEnv, NODE_ENV: 'production' }).enabled).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('stays disabled for a deployed server that has no supervisor handshake', () => {
|
|
89
|
+
// What a real `mercato server` process looks like: no token, no state files.
|
|
90
|
+
expect(resolveDevRuntimeServerConfig({
|
|
91
|
+
NODE_ENV: 'production',
|
|
92
|
+
OM_DEV_RUNTIME_DIAGNOSTICS: '1',
|
|
93
|
+
} as NodeJS.ProcessEnv).enabled).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('stays disabled without an explicit flag', () => {
|
|
97
|
+
const { OM_DEV_RUNTIME_DIAGNOSTICS: _flag, ...withoutFlag } = baseEnv
|
|
98
|
+
expect(resolveDevRuntimeServerConfig(withoutFlag).enabled).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('stays disabled when the supervisor did not supply a token or paths', () => {
|
|
102
|
+
expect(resolveDevRuntimeServerConfig({ ...baseEnv, OM_DEV_RUNTIME_TOKEN: '' }).enabled).toBe(false)
|
|
103
|
+
expect(resolveDevRuntimeServerConfig({ ...baseEnv, OM_DEV_RUNTIME_STATUS_FILE: '' }).enabled).toBe(false)
|
|
104
|
+
expect(resolveDevRuntimeServerConfig({ ...baseEnv, OM_DEV_RUNTIME_DIAGNOSTICS_FILE: '' }).enabled).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('honours the banner opt-out independently', () => {
|
|
108
|
+
expect(resolveDevRuntimeServerConfig({ ...baseEnv, OM_DEV_RUNTIME_BANNER: '0' })).toMatchObject({
|
|
109
|
+
enabled: true,
|
|
110
|
+
bannerEnabled: false,
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('readDevRuntimeStatus', () => {
|
|
116
|
+
let directory: string
|
|
117
|
+
|
|
118
|
+
beforeEach(() => { directory = createTempDir() })
|
|
119
|
+
afterEach(() => { fs.rmSync(directory, { recursive: true, force: true }) })
|
|
120
|
+
|
|
121
|
+
it('returns the supervisor status', () => {
|
|
122
|
+
const config = createConfig(directory)
|
|
123
|
+
writeStatusFile(config, createStatus())
|
|
124
|
+
expect(readDevRuntimeStatus(config)?.health).toBe('degraded')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('rejects a status file written by a different run', () => {
|
|
128
|
+
const config = createConfig(directory)
|
|
129
|
+
writeStatusFile(config, createStatus(), 'a-stale-token-of-len')
|
|
130
|
+
expect(readDevRuntimeStatus(config)).toBeNull()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('returns null for a missing or malformed file', () => {
|
|
134
|
+
const config = createConfig(directory)
|
|
135
|
+
expect(readDevRuntimeStatus(config)).toBeNull()
|
|
136
|
+
fs.writeFileSync(config.statusFilePath!, 'not json', 'utf8')
|
|
137
|
+
expect(readDevRuntimeStatus(config)).toBeNull()
|
|
138
|
+
fs.writeFileSync(config.statusFilePath!, JSON.stringify({ token: TOKEN, status: { nope: true } }), 'utf8')
|
|
139
|
+
expect(readDevRuntimeStatus(config)).toBeNull()
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('createDevRuntimeStatusRoute', () => {
|
|
144
|
+
let directory: string
|
|
145
|
+
|
|
146
|
+
beforeEach(() => { directory = createTempDir() })
|
|
147
|
+
afterEach(() => { fs.rmSync(directory, { recursive: true, force: true }) })
|
|
148
|
+
|
|
149
|
+
it('serves the supervisor status for a valid token', async () => {
|
|
150
|
+
const config = createConfig(directory)
|
|
151
|
+
writeStatusFile(config, createStatus())
|
|
152
|
+
const GET = createDevRuntimeStatusRoute({ resolveConfig: () => config })
|
|
153
|
+
|
|
154
|
+
const response = await GET(statusRequest())
|
|
155
|
+
expect(response.status).toBe(200)
|
|
156
|
+
expect(response.headers.get('cache-control')).toBe('no-store')
|
|
157
|
+
await expect(response.json()).resolves.toMatchObject({ health: 'degraded', generation: 1 })
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('returns 404 when diagnostics are disabled', async () => {
|
|
161
|
+
const GET = createDevRuntimeStatusRoute({ resolveConfig: () => createConfig(directory, { enabled: false }) })
|
|
162
|
+
const response = await GET(statusRequest())
|
|
163
|
+
expect(response.status).toBe(404)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('returns 403 for a missing or wrong token', async () => {
|
|
167
|
+
const config = createConfig(directory)
|
|
168
|
+
writeStatusFile(config, createStatus())
|
|
169
|
+
const GET = createDevRuntimeStatusRoute({ resolveConfig: () => config })
|
|
170
|
+
|
|
171
|
+
await expect(GET(statusRequest({})).then((r) => r.status)).resolves.toBe(403)
|
|
172
|
+
await expect(
|
|
173
|
+
GET(statusRequest({ [DEV_RUNTIME_TOKEN_HEADER]: 'wrong-token-value-xx' })).then((r) => r.status),
|
|
174
|
+
).resolves.toBe(403)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('rejects a cross-origin request', async () => {
|
|
178
|
+
const config = createConfig(directory)
|
|
179
|
+
writeStatusFile(config, createStatus())
|
|
180
|
+
const GET = createDevRuntimeStatusRoute({ resolveConfig: () => config })
|
|
181
|
+
|
|
182
|
+
const response = await GET(statusRequest({
|
|
183
|
+
[DEV_RUNTIME_TOKEN_HEADER]: TOKEN,
|
|
184
|
+
origin: 'http://evil.example',
|
|
185
|
+
}))
|
|
186
|
+
expect(response.status).toBe(403)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('returns 404 while the supervisor state is unavailable', async () => {
|
|
190
|
+
const GET = createDevRuntimeStatusRoute({ resolveConfig: () => createConfig(directory) })
|
|
191
|
+
const response = await GET(statusRequest())
|
|
192
|
+
expect(response.status).toBe(404)
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe('createDevRuntimeDiagnosticsRoute', () => {
|
|
197
|
+
let directory: string
|
|
198
|
+
let config: DevRuntimeServerConfig
|
|
199
|
+
|
|
200
|
+
beforeEach(() => {
|
|
201
|
+
directory = createTempDir()
|
|
202
|
+
config = createConfig(directory)
|
|
203
|
+
})
|
|
204
|
+
afterEach(() => { fs.rmSync(directory, { recursive: true, force: true }) })
|
|
205
|
+
|
|
206
|
+
function readSink(): Array<Record<string, unknown>> {
|
|
207
|
+
if (!fs.existsSync(config.diagnosticsFilePath!)) return []
|
|
208
|
+
return fs.readFileSync(config.diagnosticsFilePath!, 'utf8')
|
|
209
|
+
.split('\n')
|
|
210
|
+
.filter(Boolean)
|
|
211
|
+
.map((line) => JSON.parse(line) as Record<string, unknown>)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
it('accepts a valid report and appends it to the local sink', async () => {
|
|
215
|
+
const POST = createDevRuntimeDiagnosticsRoute({ resolveConfig: () => config })
|
|
216
|
+
const response = await POST(diagnosticsRequest({
|
|
217
|
+
kind: 'global-error',
|
|
218
|
+
message: 'TypeError: x is not a function',
|
|
219
|
+
digest: 'abc123',
|
|
220
|
+
path: '/backend/example',
|
|
221
|
+
}))
|
|
222
|
+
|
|
223
|
+
expect(response.status).toBe(202)
|
|
224
|
+
await expect(response.json()).resolves.toMatchObject({ accepted: true })
|
|
225
|
+
expect(readSink()).toEqual([expect.objectContaining({
|
|
226
|
+
kind: 'global-error',
|
|
227
|
+
message: 'TypeError: x is not a function',
|
|
228
|
+
path: '/backend/example',
|
|
229
|
+
})])
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('returns 404 when diagnostics are disabled', async () => {
|
|
233
|
+
const POST = createDevRuntimeDiagnosticsRoute({ resolveConfig: () => createConfig(directory, { enabled: false }) })
|
|
234
|
+
const response = await POST(diagnosticsRequest({ kind: 'global-error', message: 'boom' }))
|
|
235
|
+
expect(response.status).toBe(404)
|
|
236
|
+
expect(readSink()).toEqual([])
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('returns 403 for a missing token', async () => {
|
|
240
|
+
const POST = createDevRuntimeDiagnosticsRoute({ resolveConfig: () => config })
|
|
241
|
+
const request = new Request('http://localhost:3000/api/dev-runtime/diagnostics', {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
headers: { 'content-type': 'application/json' },
|
|
244
|
+
body: JSON.stringify({ kind: 'global-error', message: 'boom' }),
|
|
245
|
+
})
|
|
246
|
+
expect((await POST(request)).status).toBe(403)
|
|
247
|
+
expect(readSink()).toEqual([])
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('rejects a non-JSON content type', async () => {
|
|
251
|
+
const POST = createDevRuntimeDiagnosticsRoute({ resolveConfig: () => config })
|
|
252
|
+
const request = new Request('http://localhost:3000/api/dev-runtime/diagnostics', {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers: { 'content-type': 'text/plain', [DEV_RUNTIME_TOKEN_HEADER]: TOKEN },
|
|
255
|
+
body: 'boom',
|
|
256
|
+
})
|
|
257
|
+
expect((await POST(request)).status).toBe(400)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('rejects an invalid schema', async () => {
|
|
261
|
+
const POST = createDevRuntimeDiagnosticsRoute({ resolveConfig: () => config })
|
|
262
|
+
expect((await POST(diagnosticsRequest({ kind: 'shell-exec', message: 'boom' }))).status).toBe(400)
|
|
263
|
+
expect((await POST(diagnosticsRequest({ kind: 'global-error' }))).status).toBe(400)
|
|
264
|
+
expect((await POST(diagnosticsRequest({ kind: 'global-error', message: 'x', digest: 'a b' }))).status).toBe(400)
|
|
265
|
+
expect((await POST(diagnosticsRequest('{not json'))).status).toBe(400)
|
|
266
|
+
expect(readSink()).toEqual([])
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('rejects an oversized body before parsing it', async () => {
|
|
270
|
+
const POST = createDevRuntimeDiagnosticsRoute({ resolveConfig: () => config })
|
|
271
|
+
const response = await POST(diagnosticsRequest({ kind: 'global-error', message: 'x'.repeat(20_000) }))
|
|
272
|
+
expect(response.status).toBe(400)
|
|
273
|
+
await expect(response.json()).resolves.toMatchObject({ error: { code: 'report_too_large' } })
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('redacts secrets before writing to the sink', async () => {
|
|
277
|
+
const POST = createDevRuntimeDiagnosticsRoute({ resolveConfig: () => config })
|
|
278
|
+
await POST(diagnosticsRequest({
|
|
279
|
+
kind: 'window-error',
|
|
280
|
+
message: 'failed for postgres://admin:hunter2@localhost:5432/app',
|
|
281
|
+
stack: 'cookie: om_session=super-secret',
|
|
282
|
+
}))
|
|
283
|
+
|
|
284
|
+
const written = JSON.stringify(readSink())
|
|
285
|
+
expect(written).not.toContain('hunter2')
|
|
286
|
+
expect(written).not.toContain('super-secret')
|
|
287
|
+
expect(written).toContain('postgres://***')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('rate limits a looping reporter', async () => {
|
|
291
|
+
const POST = createDevRuntimeDiagnosticsRoute({ resolveConfig: () => config })
|
|
292
|
+
const statuses: number[] = []
|
|
293
|
+
for (let index = 0; index < 35; index += 1) {
|
|
294
|
+
statuses.push((await POST(diagnosticsRequest({ kind: 'global-error', message: `boom ${index}` }))).status)
|
|
295
|
+
}
|
|
296
|
+
expect(statuses.filter((status) => status === 202)).toHaveLength(30)
|
|
297
|
+
expect(statuses.filter((status) => status === 429)).toHaveLength(5)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('reports a collector failure instead of throwing', async () => {
|
|
301
|
+
const POST = createDevRuntimeDiagnosticsRoute({
|
|
302
|
+
resolveConfig: () => createConfig(directory, {
|
|
303
|
+
diagnosticsFilePath: path.join(directory, 'missing-directory', 'diagnostics.ndjson'),
|
|
304
|
+
}),
|
|
305
|
+
})
|
|
306
|
+
const response = await POST(diagnosticsRequest({ kind: 'global-error', message: 'boom' }))
|
|
307
|
+
expect(response.status).toBe(503)
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
describe('createDevRuntimeActionsRoute', () => {
|
|
312
|
+
let directory: string
|
|
313
|
+
let config: DevRuntimeServerConfig
|
|
314
|
+
|
|
315
|
+
beforeEach(() => {
|
|
316
|
+
directory = createTempDir()
|
|
317
|
+
config = createConfig(directory)
|
|
318
|
+
})
|
|
319
|
+
afterEach(() => { fs.rmSync(directory, { recursive: true, force: true }) })
|
|
320
|
+
|
|
321
|
+
function actionRequest(action: string, headers: Record<string, string> = { [DEV_RUNTIME_TOKEN_HEADER]: TOKEN }): [Request, { params: Promise<{ action: string }> }] {
|
|
322
|
+
return [
|
|
323
|
+
new Request(`http://localhost:3000/api/dev-runtime/actions/${action}`, { method: 'POST', headers }),
|
|
324
|
+
{ params: Promise.resolve({ action }) },
|
|
325
|
+
]
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function readQueue(): Array<Record<string, unknown>> {
|
|
329
|
+
if (!fs.existsSync(config.actionsFilePath!)) return []
|
|
330
|
+
return fs.readFileSync(config.actionsFilePath!, 'utf8')
|
|
331
|
+
.split('\n').filter(Boolean)
|
|
332
|
+
.map((line) => JSON.parse(line) as Record<string, unknown>)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
it('queues an allowlisted action with the current generation', async () => {
|
|
336
|
+
writeStatusFile(config, createStatus({ generation: 7 }))
|
|
337
|
+
const POST = createDevRuntimeActionsRoute({ resolveConfig: () => config })
|
|
338
|
+
|
|
339
|
+
const response = await POST(...actionRequest('migrate'))
|
|
340
|
+
expect(response.status).toBe(202)
|
|
341
|
+
await expect(response.json()).resolves.toMatchObject({ accepted: true, generation: 7 })
|
|
342
|
+
expect(readQueue()).toEqual([expect.objectContaining({ action: 'migrate', generation: 7 })])
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('rejects an action outside the allowlist without queueing anything', async () => {
|
|
346
|
+
writeStatusFile(config, createStatus())
|
|
347
|
+
const POST = createDevRuntimeActionsRoute({ resolveConfig: () => config })
|
|
348
|
+
|
|
349
|
+
const response = await POST(...actionRequest('rm-rf'))
|
|
350
|
+
expect(response.status).toBe(400)
|
|
351
|
+
await expect(response.json()).resolves.toMatchObject({ error: { code: 'unknown_action' } })
|
|
352
|
+
expect(readQueue()).toEqual([])
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('returns 403 for a missing or wrong token', async () => {
|
|
356
|
+
writeStatusFile(config, createStatus())
|
|
357
|
+
const POST = createDevRuntimeActionsRoute({ resolveConfig: () => config })
|
|
358
|
+
|
|
359
|
+
expect((await POST(...actionRequest('restart', {}))).status).toBe(403)
|
|
360
|
+
expect((await POST(...actionRequest('restart', { [DEV_RUNTIME_TOKEN_HEADER]: 'wrong-token-value-xx' }))).status).toBe(403)
|
|
361
|
+
expect(readQueue()).toEqual([])
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('rejects a cross-origin request', async () => {
|
|
365
|
+
writeStatusFile(config, createStatus())
|
|
366
|
+
const POST = createDevRuntimeActionsRoute({ resolveConfig: () => config })
|
|
367
|
+
const response = await POST(...actionRequest('restart', {
|
|
368
|
+
[DEV_RUNTIME_TOKEN_HEADER]: TOKEN,
|
|
369
|
+
origin: 'http://evil.example',
|
|
370
|
+
}))
|
|
371
|
+
expect(response.status).toBe(403)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('returns 404 when diagnostics are disabled', async () => {
|
|
375
|
+
const POST = createDevRuntimeActionsRoute({ resolveConfig: () => createConfig(directory, { enabled: false }) })
|
|
376
|
+
expect((await POST(...actionRequest('restart'))).status).toBe(404)
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('reports a conflict while another action is running', async () => {
|
|
380
|
+
writeStatusFile(config, createStatus({
|
|
381
|
+
recovery: { action: 'generate', startedAt: '2026-08-18T10:00:00.000Z', busy: true },
|
|
382
|
+
}))
|
|
383
|
+
const POST = createDevRuntimeActionsRoute({ resolveConfig: () => config })
|
|
384
|
+
|
|
385
|
+
const response = await POST(...actionRequest('migrate'))
|
|
386
|
+
expect(response.status).toBe(409)
|
|
387
|
+
await expect(response.json()).resolves.toMatchObject({ error: { code: 'action_busy' } })
|
|
388
|
+
expect(readQueue()).toEqual([])
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('reports 503 when the supervisor state is unavailable', async () => {
|
|
392
|
+
const POST = createDevRuntimeActionsRoute({ resolveConfig: () => config })
|
|
393
|
+
expect((await POST(...actionRequest('restart'))).status).toBe(503)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('reports 503 when the supervisor exposed no action channel', async () => {
|
|
397
|
+
const withoutChannel = createConfig(directory, { actionsFilePath: null })
|
|
398
|
+
writeStatusFile(withoutChannel, createStatus())
|
|
399
|
+
const POST = createDevRuntimeActionsRoute({ resolveConfig: () => withoutChannel })
|
|
400
|
+
expect((await POST(...actionRequest('restart'))).status).toBe(503)
|
|
401
|
+
})
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
describe('createDevRuntimeLogsRoute', () => {
|
|
405
|
+
let directory: string
|
|
406
|
+
let config: DevRuntimeServerConfig
|
|
407
|
+
|
|
408
|
+
beforeEach(() => {
|
|
409
|
+
directory = createTempDir()
|
|
410
|
+
config = createConfig(directory)
|
|
411
|
+
})
|
|
412
|
+
afterEach(() => { fs.rmSync(directory, { recursive: true, force: true }) })
|
|
413
|
+
|
|
414
|
+
function writeLogs(lines: Array<Record<string, unknown>>, token = TOKEN): void {
|
|
415
|
+
fs.writeFileSync(config.logsFilePath!, JSON.stringify({ token, generation: 1, lines }), 'utf8')
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function logsRequest(cursor?: number, headers: Record<string, string> = { [DEV_RUNTIME_TOKEN_HEADER]: TOKEN }): Request {
|
|
419
|
+
const suffix = cursor === undefined ? '' : `?cursor=${cursor}`
|
|
420
|
+
return new Request(`http://localhost:3000/api/dev-runtime/logs${suffix}`, { headers })
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const LINES = [
|
|
424
|
+
{ seq: 1, at: '2026-08-18T10:00:01.000Z', generation: 1, source: 'log', text: 'first' },
|
|
425
|
+
{ seq: 2, at: '2026-08-18T10:00:02.000Z', generation: 1, source: 'log', text: 'second' },
|
|
426
|
+
]
|
|
427
|
+
|
|
428
|
+
it('serves the bounded log tail', async () => {
|
|
429
|
+
writeLogs(LINES)
|
|
430
|
+
const GET = createDevRuntimeLogsRoute({ resolveConfig: () => config })
|
|
431
|
+
const response = await GET(logsRequest())
|
|
432
|
+
expect(response.status).toBe(200)
|
|
433
|
+
await expect(response.json()).resolves.toMatchObject({ generation: 1, nextCursor: 2 })
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('honours the cursor so the view can poll incrementally', async () => {
|
|
437
|
+
writeLogs(LINES)
|
|
438
|
+
const GET = createDevRuntimeLogsRoute({ resolveConfig: () => config })
|
|
439
|
+
const body = await (await GET(logsRequest(1))).json()
|
|
440
|
+
expect(body.lines).toEqual([expect.objectContaining({ seq: 2, text: 'second' })])
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it('restarts the snapshot on a malformed cursor instead of failing', async () => {
|
|
444
|
+
writeLogs(LINES)
|
|
445
|
+
const GET = createDevRuntimeLogsRoute({ resolveConfig: () => config })
|
|
446
|
+
const response = await GET(new Request('http://localhost:3000/api/dev-runtime/logs?cursor=nope', {
|
|
447
|
+
headers: { [DEV_RUNTIME_TOKEN_HEADER]: TOKEN },
|
|
448
|
+
}))
|
|
449
|
+
expect(response.status).toBe(200)
|
|
450
|
+
await expect(response.json()).resolves.toMatchObject({ nextCursor: 2 })
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('rejects a missing token, a wrong origin and a disabled runtime', async () => {
|
|
454
|
+
writeLogs(LINES)
|
|
455
|
+
const GET = createDevRuntimeLogsRoute({ resolveConfig: () => config })
|
|
456
|
+
expect((await GET(logsRequest(0, {}))).status).toBe(403)
|
|
457
|
+
expect((await GET(logsRequest(0, { [DEV_RUNTIME_TOKEN_HEADER]: TOKEN, origin: 'http://evil.example' }))).status).toBe(403)
|
|
458
|
+
|
|
459
|
+
const disabled = createDevRuntimeLogsRoute({ resolveConfig: () => createConfig(directory, { enabled: false }) })
|
|
460
|
+
expect((await disabled(logsRequest())).status).toBe(404)
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('rejects a log file written by a different run', async () => {
|
|
464
|
+
writeLogs(LINES, 'a-stale-token-of-len')
|
|
465
|
+
const GET = createDevRuntimeLogsRoute({ resolveConfig: () => config })
|
|
466
|
+
expect((await GET(logsRequest())).status).toBe(404)
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('returns 404 when the supervisor published no logs', async () => {
|
|
470
|
+
const GET = createDevRuntimeLogsRoute({ resolveConfig: () => config })
|
|
471
|
+
expect((await GET(logsRequest())).status).toBe(404)
|
|
472
|
+
})
|
|
473
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { resolveDevRuntimeServerConfig } from './server'
|
|
2
|
+
import {
|
|
3
|
+
DEV_RUNTIME_BANNER_META_NAME,
|
|
4
|
+
DEV_RUNTIME_LOGS_URL_META_NAME,
|
|
5
|
+
DEV_RUNTIME_TOKEN_META_NAME,
|
|
6
|
+
} from './types'
|
|
7
|
+
|
|
8
|
+
export type DevRuntimeLayoutMeta = {
|
|
9
|
+
name: string
|
|
10
|
+
content: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type DevRuntimeLayoutConfig = {
|
|
14
|
+
enabled: boolean
|
|
15
|
+
bannerEnabled: boolean
|
|
16
|
+
meta: DevRuntimeLayoutMeta[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DISABLED: DevRuntimeLayoutConfig = { enabled: false, bannerEnabled: false, meta: [] }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Server-side helper for the app layout. It exposes the per-run token to the
|
|
23
|
+
* local dev browser through dev-only `<meta>` elements without adding a context
|
|
24
|
+
* provider, and returns nothing at all outside a supervised dev runtime.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveDevRuntimeLayoutConfig(env: NodeJS.ProcessEnv = process.env): DevRuntimeLayoutConfig {
|
|
27
|
+
const config = resolveDevRuntimeServerConfig(env)
|
|
28
|
+
if (!config.enabled || !config.token) return DISABLED
|
|
29
|
+
|
|
30
|
+
const meta: DevRuntimeLayoutMeta[] = [
|
|
31
|
+
{ name: DEV_RUNTIME_TOKEN_META_NAME, content: config.token },
|
|
32
|
+
{ name: DEV_RUNTIME_BANNER_META_NAME, content: config.bannerEnabled ? '1' : '0' },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const logsUrl = typeof env.OM_DEV_RUNTIME_SPLASH_URL === 'string' ? env.OM_DEV_RUNTIME_SPLASH_URL.trim() : ''
|
|
36
|
+
if (logsUrl) meta.push({ name: DEV_RUNTIME_LOGS_URL_META_NAME, content: logsUrl })
|
|
37
|
+
|
|
38
|
+
return { enabled: true, bannerEnabled: config.bannerEnabled, meta }
|
|
39
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirror of the supervisor-side rules in `scripts/dev-runtime-state.mjs`.
|
|
3
|
+
* The supervisor re-redacts everything it ingests, but the dev-only app route
|
|
4
|
+
* writes reports to a local file first, so the same rules must apply here.
|
|
5
|
+
* `scripts/__tests__/dev-runtime-redaction-parity.test.mjs` keeps the two lists
|
|
6
|
+
* from drifting.
|
|
7
|
+
*/
|
|
8
|
+
const REDACTION_RULES: Array<[RegExp, string]> = [
|
|
9
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '***'],
|
|
10
|
+
[/\b(postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|rediss?|amqps?)::?\/\/[^\s'"`<>)]+/gi, '$1://***'],
|
|
11
|
+
[/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, 'Bearer ***'],
|
|
12
|
+
[/\b(authorization|proxy-authorization|x-api-key|x-auth-token)(\s*[:=]\s*)(?:\w+\s+)?\S+/gi, '$1$2***'],
|
|
13
|
+
[/\b(set-cookie|cookie)(\s*[:=]\s*)[^\n]+/gi, '$1$2***'],
|
|
14
|
+
[/\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]*/g, '***'],
|
|
15
|
+
[/\b(sk|pk|rk)_(live|test)_[A-Za-z0-9]{8,}/g, '***'],
|
|
16
|
+
[/\bgh[pousr]_[A-Za-z0-9]{16,}/g, '***'],
|
|
17
|
+
[/\b(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|session[_-]?id)("?\s*[:=]\s*"?)([^\s"',;)}]+)/gi, '$1$2***'],
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
export function redactDevRuntimeText(value: unknown, maxLength = 400): string | undefined {
|
|
21
|
+
if (value == null) return undefined
|
|
22
|
+
let text = String(value)
|
|
23
|
+
for (const [pattern, replacement] of REDACTION_RULES) {
|
|
24
|
+
text = text.replace(pattern, replacement)
|
|
25
|
+
}
|
|
26
|
+
text = text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').replace(/\s+$/g, '')
|
|
27
|
+
if (!text) return undefined
|
|
28
|
+
return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text
|
|
29
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEV_RUNTIME_BANNER_META_NAME,
|
|
3
|
+
DEV_RUNTIME_DIAGNOSTICS_PATH,
|
|
4
|
+
DEV_RUNTIME_LOGS_URL_META_NAME,
|
|
5
|
+
DEV_RUNTIME_TOKEN_HEADER,
|
|
6
|
+
DEV_RUNTIME_TOKEN_META_NAME,
|
|
7
|
+
type DevRuntimeReport,
|
|
8
|
+
type DevRuntimeReportKind,
|
|
9
|
+
} from './types'
|
|
10
|
+
|
|
11
|
+
const MAX_MESSAGE_LENGTH = 500
|
|
12
|
+
const MAX_STACK_LENGTH = 2000
|
|
13
|
+
const MAX_REPORTS_PER_PAGE = 20
|
|
14
|
+
|
|
15
|
+
let sentReports = 0
|
|
16
|
+
const seenFingerprints = new Set<string>()
|
|
17
|
+
|
|
18
|
+
function readMeta(name: string): string | null {
|
|
19
|
+
if (typeof document === 'undefined') return null
|
|
20
|
+
const element = document.querySelector(`meta[name="${name}"]`)
|
|
21
|
+
const content = element?.getAttribute('content')?.trim()
|
|
22
|
+
return content ? content : null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readDevRuntimeToken(): string | null {
|
|
26
|
+
return readMeta(DEV_RUNTIME_TOKEN_META_NAME)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isDevRuntimeBannerEnabled(): boolean {
|
|
30
|
+
return readMeta(DEV_RUNTIME_BANNER_META_NAME) === '1'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readDevRuntimeLogsUrl(): string | null {
|
|
34
|
+
return readMeta(DEV_RUNTIME_LOGS_URL_META_NAME)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function truncate(value: unknown, maxLength: number): string | undefined {
|
|
38
|
+
if (typeof value !== 'string') return undefined
|
|
39
|
+
const trimmed = value.trim()
|
|
40
|
+
if (!trimmed) return undefined
|
|
41
|
+
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function describeDevRuntimeError(error: unknown): { message: string; stack?: string; digest?: string } {
|
|
45
|
+
if (error instanceof Error) {
|
|
46
|
+
return {
|
|
47
|
+
message: `${error.name}: ${error.message}`,
|
|
48
|
+
stack: truncate(error.stack, MAX_STACK_LENGTH),
|
|
49
|
+
digest: truncate((error as { digest?: unknown }).digest, 64),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (typeof error === 'string') return { message: error }
|
|
53
|
+
return { message: 'Unknown browser error' }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Best-effort, fire-and-forget browser report. It never blocks rendering, never
|
|
58
|
+
* retries, and silently gives up when the collector is unavailable — a broken
|
|
59
|
+
* runtime must still show its own rendered error state.
|
|
60
|
+
*/
|
|
61
|
+
export function reportDevRuntimeError(input: {
|
|
62
|
+
kind: DevRuntimeReportKind
|
|
63
|
+
error?: unknown
|
|
64
|
+
message?: string
|
|
65
|
+
digest?: string
|
|
66
|
+
stack?: string
|
|
67
|
+
}): void {
|
|
68
|
+
if (typeof window === 'undefined') return
|
|
69
|
+
if (sentReports >= MAX_REPORTS_PER_PAGE) return
|
|
70
|
+
|
|
71
|
+
const token = readDevRuntimeToken()
|
|
72
|
+
if (!token) return
|
|
73
|
+
|
|
74
|
+
const described: Partial<ReturnType<typeof describeDevRuntimeError>> = input.error !== undefined
|
|
75
|
+
? describeDevRuntimeError(input.error)
|
|
76
|
+
: {}
|
|
77
|
+
const message = truncate(input.message ?? described.message, MAX_MESSAGE_LENGTH)
|
|
78
|
+
if (!message) return
|
|
79
|
+
|
|
80
|
+
const digest = truncate(input.digest ?? described.digest, 64)
|
|
81
|
+
const stack = truncate(input.stack ?? described.stack, MAX_STACK_LENGTH)
|
|
82
|
+
const path = truncate(window.location?.pathname, 300)
|
|
83
|
+
|
|
84
|
+
// One report per distinct failure per page: a render loop must not turn into
|
|
85
|
+
// a request loop.
|
|
86
|
+
const fingerprint = `${input.kind}|${digest ?? message}|${path ?? ''}`
|
|
87
|
+
if (seenFingerprints.has(fingerprint)) return
|
|
88
|
+
seenFingerprints.add(fingerprint)
|
|
89
|
+
sentReports += 1
|
|
90
|
+
|
|
91
|
+
const report: DevRuntimeReport = {
|
|
92
|
+
kind: input.kind,
|
|
93
|
+
message,
|
|
94
|
+
timestamp: new Date().toISOString(),
|
|
95
|
+
}
|
|
96
|
+
if (digest) report.digest = digest
|
|
97
|
+
if (stack) report.stack = stack
|
|
98
|
+
if (path) report.path = path
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
void fetch(DEV_RUNTIME_DIAGNOSTICS_PATH, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { 'content-type': 'application/json', [DEV_RUNTIME_TOKEN_HEADER]: token },
|
|
104
|
+
body: JSON.stringify(report),
|
|
105
|
+
cache: 'no-store',
|
|
106
|
+
keepalive: true,
|
|
107
|
+
}).catch(() => {})
|
|
108
|
+
} catch {
|
|
109
|
+
// Reporting is optional; the rendered fallback stays the source of truth.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function resetDevRuntimeReporterForTests(): void {
|
|
114
|
+
sentReports = 0
|
|
115
|
+
seenFingerprints.clear()
|
|
116
|
+
}
|