@open-mercato/shared 0.7.1-develop.7151.1.00d0391847 → 0.7.1-develop.7152.1.a69e92f9c9

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.
@@ -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
+ }