@open-mercato/shared 0.7.1-develop.7150.1.c1941e0c22 → 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.
Files changed (33) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/lib/dev-runtime/layout.js +22 -0
  3. package/dist/lib/dev-runtime/layout.js.map +7 -0
  4. package/dist/lib/dev-runtime/redaction.js +25 -0
  5. package/dist/lib/dev-runtime/redaction.js.map +7 -0
  6. package/dist/lib/dev-runtime/report.js +92 -0
  7. package/dist/lib/dev-runtime/report.js.map +7 -0
  8. package/dist/lib/dev-runtime/routes.js +164 -0
  9. package/dist/lib/dev-runtime/routes.js.map +7 -0
  10. package/dist/lib/dev-runtime/server.js +111 -0
  11. package/dist/lib/dev-runtime/server.js.map +7 -0
  12. package/dist/lib/dev-runtime/types.js +23 -0
  13. package/dist/lib/dev-runtime/types.js.map +7 -0
  14. package/dist/lib/email/config.js +20 -0
  15. package/dist/lib/email/config.js.map +2 -2
  16. package/dist/lib/email/send.js +25 -22
  17. package/dist/lib/email/send.js.map +2 -2
  18. package/dist/lib/email/transport.js +19 -0
  19. package/dist/lib/email/transport.js.map +7 -0
  20. package/dist/lib/version.js +1 -1
  21. package/dist/lib/version.js.map +1 -1
  22. package/package.json +2 -2
  23. package/src/lib/dev-runtime/__tests__/routes.test.ts +473 -0
  24. package/src/lib/dev-runtime/layout.ts +39 -0
  25. package/src/lib/dev-runtime/redaction.ts +29 -0
  26. package/src/lib/dev-runtime/report.ts +116 -0
  27. package/src/lib/dev-runtime/routes.ts +219 -0
  28. package/src/lib/dev-runtime/server.ts +174 -0
  29. package/src/lib/dev-runtime/types.ts +101 -0
  30. package/src/lib/email/__tests__/send.test.ts +140 -69
  31. package/src/lib/email/config.ts +26 -1
  32. package/src/lib/email/send.ts +59 -37
  33. package/src/lib/email/transport.ts +29 -0
@@ -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
+ }
@@ -0,0 +1,219 @@
1
+ import { z } from 'zod'
2
+
3
+ import { redactDevRuntimeText } from './redaction'
4
+ import {
5
+ appendDevRuntimeActionRequest,
6
+ appendDevRuntimeReport,
7
+ isMatchingDevRuntimeToken,
8
+ readDevRuntimeLogs,
9
+ readDevRuntimeStatus,
10
+ resolveDevRuntimeServerConfig,
11
+ type DevRuntimeServerConfig,
12
+ } from './server'
13
+ import {
14
+ DEV_RUNTIME_RECOVERY_ACTIONS,
15
+ DEV_RUNTIME_TOKEN_HEADER,
16
+ type DevRuntimeReport,
17
+ type RuntimeRecoveryAction,
18
+ } from './types'
19
+
20
+ export const MAX_DEV_RUNTIME_REPORT_BYTES = 8192
21
+ const MAX_REPORTS_PER_WINDOW = 30
22
+ const RATE_LIMIT_WINDOW_MS = 10_000
23
+
24
+ const reportSchema = z.object({
25
+ kind: z.enum(['global-error', 'window-error', 'unhandled-rejection', 'chunk-load-error', 'request-error']),
26
+ message: z.string().trim().min(1).max(2000),
27
+ digest: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/).optional(),
28
+ path: z.string().max(300).optional(),
29
+ stack: z.string().max(20_000).optional(),
30
+ timestamp: z.string().datetime().optional(),
31
+ })
32
+
33
+ type RateLimiter = { tryConsume: () => boolean }
34
+
35
+ function createRateLimiter(now: () => number = () => Date.now()): RateLimiter {
36
+ let windowStart = now()
37
+ let count = 0
38
+ return {
39
+ tryConsume() {
40
+ const timestamp = now()
41
+ if (timestamp - windowStart >= RATE_LIMIT_WINDOW_MS) {
42
+ windowStart = timestamp
43
+ count = 0
44
+ }
45
+ if (count >= MAX_REPORTS_PER_WINDOW) return false
46
+ count += 1
47
+ return true
48
+ },
49
+ }
50
+ }
51
+
52
+ function jsonError(status: number, code: string, message: string): Response {
53
+ return Response.json({ error: { code, message } }, { status })
54
+ }
55
+
56
+ const NOT_FOUND = () => jsonError(404, 'not_found', 'Not found.')
57
+
58
+ function isAuthorized(request: Request, config: DevRuntimeServerConfig): boolean {
59
+ return isMatchingDevRuntimeToken(config.token, request.headers.get(DEV_RUNTIME_TOKEN_HEADER))
60
+ }
61
+
62
+ // A browser sends `Origin` on cross-origin requests; anything that does not
63
+ // match the request's own host is rejected outright. Non-browser callers that
64
+ // omit `Origin` still have to present the token.
65
+ function isAcceptableOrigin(request: Request): boolean {
66
+ const origin = request.headers.get('origin')
67
+ if (!origin) return true
68
+ try {
69
+ return new URL(origin).host === new URL(request.url).host
70
+ } catch {
71
+ return false
72
+ }
73
+ }
74
+
75
+ export type DevRuntimeRouteOptions = {
76
+ resolveConfig?: () => DevRuntimeServerConfig
77
+ }
78
+
79
+ /**
80
+ * Dev-only status bridge for the in-app banner. It exposes only the
81
+ * supervisor's local runtime state and returns 404 whenever diagnostics are off
82
+ * or the supervisor is not running.
83
+ */
84
+ export function createDevRuntimeStatusRoute(options: DevRuntimeRouteOptions = {}) {
85
+ const resolveConfig = options.resolveConfig ?? (() => resolveDevRuntimeServerConfig())
86
+
87
+ return async function GET(request: Request): Promise<Response> {
88
+ const config = resolveConfig()
89
+ if (!config.enabled) return NOT_FOUND()
90
+ if (!isAcceptableOrigin(request)) return jsonError(403, 'forbidden', 'Origin is not allowed.')
91
+ if (!isAuthorized(request, config)) return jsonError(403, 'forbidden', 'Invalid dev runtime token.')
92
+
93
+ const status = readDevRuntimeStatus(config)
94
+ if (!status) return jsonError(404, 'not_found', 'Runtime status is not available.')
95
+
96
+ return Response.json(status, { headers: { 'cache-control': 'no-store' } })
97
+ }
98
+ }
99
+
100
+ export function createDevRuntimeDiagnosticsRoute(options: DevRuntimeRouteOptions = {}) {
101
+ const resolveConfig = options.resolveConfig ?? (() => resolveDevRuntimeServerConfig())
102
+ const limiter = createRateLimiter()
103
+
104
+ return async function POST(request: Request): Promise<Response> {
105
+ const config = resolveConfig()
106
+ if (!config.enabled) return NOT_FOUND()
107
+ if (!isAcceptableOrigin(request)) return jsonError(403, 'forbidden', 'Origin is not allowed.')
108
+ if (!isAuthorized(request, config)) return jsonError(403, 'forbidden', 'Invalid dev runtime token.')
109
+
110
+ const contentType = request.headers.get('content-type') ?? ''
111
+ if (!contentType.toLowerCase().includes('application/json')) {
112
+ return jsonError(400, 'invalid_report', 'Diagnostic report must be JSON.')
113
+ }
114
+
115
+ const body = await request.text()
116
+ if (Buffer.byteLength(body, 'utf8') > MAX_DEV_RUNTIME_REPORT_BYTES) {
117
+ return jsonError(400, 'report_too_large', 'Diagnostic report exceeds the size limit.')
118
+ }
119
+
120
+ let parsedBody: unknown
121
+ try {
122
+ parsedBody = JSON.parse(body)
123
+ } catch {
124
+ return jsonError(400, 'invalid_report', 'Diagnostic report is not valid JSON.')
125
+ }
126
+
127
+ const parsed = reportSchema.safeParse(parsedBody)
128
+ if (!parsed.success) {
129
+ return jsonError(400, 'invalid_report', 'Diagnostic report failed validation.')
130
+ }
131
+
132
+ if (!limiter.tryConsume()) {
133
+ return jsonError(429, 'rate_limited', 'Too many diagnostic reports.')
134
+ }
135
+
136
+ const message = redactDevRuntimeText(parsed.data.message, 500)
137
+ if (!message) return jsonError(400, 'invalid_report', 'Diagnostic report message is empty after redaction.')
138
+
139
+ const report: DevRuntimeReport = { kind: parsed.data.kind, message }
140
+ if (parsed.data.digest) report.digest = parsed.data.digest
141
+ const sanitizedPath = redactDevRuntimeText(parsed.data.path, 300)
142
+ if (sanitizedPath) report.path = sanitizedPath.startsWith('/') ? sanitizedPath : `/${sanitizedPath}`
143
+ const sanitizedStack = redactDevRuntimeText(parsed.data.stack, 2000)
144
+ if (sanitizedStack) report.stack = sanitizedStack
145
+ report.timestamp = parsed.data.timestamp ?? new Date().toISOString()
146
+
147
+ if (!appendDevRuntimeReport(config, report)) {
148
+ return jsonError(503, 'collector_unavailable', 'Diagnostic collector is unavailable.')
149
+ }
150
+
151
+ return Response.json({ accepted: true, issueId: `${report.kind}:${report.timestamp}` }, { status: 202 })
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Dev-only recovery bridge for the in-app banner. The action is matched against
157
+ * the fixed allowlist and queued for the supervisor, which owns the actual
158
+ * lifecycle step — this route never spawns a process itself.
159
+ */
160
+ export function createDevRuntimeActionsRoute(options: DevRuntimeRouteOptions = {}) {
161
+ const resolveConfig = options.resolveConfig ?? (() => resolveDevRuntimeServerConfig())
162
+
163
+ return async function POST(request: Request, context: { params: Promise<{ action: string }> }): Promise<Response> {
164
+ const config = resolveConfig()
165
+ if (!config.enabled) return NOT_FOUND()
166
+ if (!isAcceptableOrigin(request)) return jsonError(403, 'forbidden', 'Origin is not allowed.')
167
+ if (!isAuthorized(request, config)) return jsonError(403, 'forbidden', 'Invalid dev runtime token.')
168
+
169
+ const { action } = await context.params
170
+ if (!DEV_RUNTIME_RECOVERY_ACTIONS.includes(action as RuntimeRecoveryAction)) {
171
+ return jsonError(400, 'unknown_action', 'Unknown recovery action.')
172
+ }
173
+
174
+ const status = readDevRuntimeStatus(config)
175
+ if (!status) return jsonError(503, 'supervisor_unavailable', 'The supervisor is not available.')
176
+ // Serialization is enforced again by the runner; rejecting here just gives
177
+ // the banner an immediate, accurate answer instead of a silent queue.
178
+ if (status.recovery?.busy) {
179
+ return jsonError(409, 'action_busy', `The "${status.recovery.action}" action is still running.`)
180
+ }
181
+
182
+ const requestedAt = new Date().toISOString()
183
+ const queued = appendDevRuntimeActionRequest(config, {
184
+ action: action as RuntimeRecoveryAction,
185
+ generation: status.generation,
186
+ requestedAt,
187
+ })
188
+ if (!queued) return jsonError(503, 'supervisor_unavailable', 'The supervisor cannot accept recovery actions.')
189
+
190
+ return Response.json(
191
+ { accepted: true, actionId: `${status.generation}:${action}:${requestedAt}`, generation: status.generation },
192
+ { status: 202 },
193
+ )
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Dev-only bounded log tail for the in-app logs view. Serving it from the app
199
+ * keeps the developer on the page they are debugging instead of bouncing them
200
+ * to the standalone splash on another port.
201
+ */
202
+ export function createDevRuntimeLogsRoute(options: DevRuntimeRouteOptions = {}) {
203
+ const resolveConfig = options.resolveConfig ?? (() => resolveDevRuntimeServerConfig())
204
+
205
+ return async function GET(request: Request): Promise<Response> {
206
+ const config = resolveConfig()
207
+ if (!config.enabled) return NOT_FOUND()
208
+ if (!isAcceptableOrigin(request)) return jsonError(403, 'forbidden', 'Origin is not allowed.')
209
+ if (!isAuthorized(request, config)) return jsonError(403, 'forbidden', 'Invalid dev runtime token.')
210
+
211
+ // A malformed cursor restarts the snapshot rather than failing the view.
212
+ const raw = new URL(request.url).searchParams.get('cursor')
213
+ const parsed = Number.parseInt(raw ?? '', 10)
214
+ const snapshot = readDevRuntimeLogs(config, Number.isInteger(parsed) && parsed >= 0 ? parsed : 0)
215
+ if (!snapshot) return jsonError(404, 'not_found', 'Runtime logs are not available.')
216
+
217
+ return Response.json(snapshot, { headers: { 'cache-control': 'no-store' } })
218
+ }
219
+ }
@@ -0,0 +1,174 @@
1
+ import { appendFileSync, readFileSync } from 'node:fs'
2
+ import { timingSafeEqual } from 'node:crypto'
3
+
4
+ import type {
5
+ DevRuntimeLogLine,
6
+ DevRuntimeLogSnapshot,
7
+ DevRuntimeReport,
8
+ DevRuntimeRecoveryAction,
9
+ RuntimeStatus,
10
+ } from './types'
11
+
12
+ export type DevRuntimeServerConfig = {
13
+ enabled: boolean
14
+ bannerEnabled: boolean
15
+ token: string | null
16
+ statusFilePath: string | null
17
+ diagnosticsFilePath: string | null
18
+ actionsFilePath: string | null
19
+ logsFilePath: string | null
20
+ }
21
+
22
+ const DISABLED_CONFIG: DevRuntimeServerConfig = {
23
+ enabled: false,
24
+ bannerEnabled: false,
25
+ token: null,
26
+ statusFilePath: null,
27
+ diagnosticsFilePath: null,
28
+ actionsFilePath: null,
29
+ logsFilePath: null,
30
+ }
31
+
32
+ function readFlag(value: string | undefined, fallback: boolean): boolean {
33
+ if (typeof value !== 'string' || value.trim() === '') return fallback
34
+ const normalized = value.trim().toLowerCase()
35
+ if (['1', 'true', 'on', 'yes', 'enabled'].includes(normalized)) return true
36
+ if (['0', 'false', 'off', 'no', 'disabled'].includes(normalized)) return false
37
+ return fallback
38
+ }
39
+
40
+ function readNonEmpty(value: string | undefined): string | null {
41
+ if (typeof value !== 'string') return null
42
+ const trimmed = value.trim()
43
+ return trimmed.length > 0 ? trimmed : null
44
+ }
45
+
46
+ /**
47
+ * Dev diagnostics exist only while a local `yarn dev` supervisor is managing
48
+ * this process.
49
+ *
50
+ * NODE_ENV is deliberately NOT the guard: `mercato dev` spawns the Next.js dev
51
+ * server through `buildServerProcessEnvironment`, which forces
52
+ * `NODE_ENV=production` (the same helper already forces the logging facade to
53
+ * re-apply its dev defaults by hand). Keying off it would disable diagnostics
54
+ * in exactly the environment they exist for.
55
+ *
56
+ * The real discriminator is the supervisor handshake: the per-run token and the
57
+ * two state-file paths are injected by `createDevRuntimeSupervisor().childEnv()`
58
+ * and exist nowhere else. A deployed `mercato server` has no supervisor, so it
59
+ * has none of them and every route stays closed — even if someone sets
60
+ * `OM_DEV_RUNTIME_DIAGNOSTICS` by hand.
61
+ */
62
+ export function resolveDevRuntimeServerConfig(env: NodeJS.ProcessEnv = process.env): DevRuntimeServerConfig {
63
+ if (!readFlag(env.OM_DEV_RUNTIME_DIAGNOSTICS, false)) return DISABLED_CONFIG
64
+
65
+ const token = readNonEmpty(env.OM_DEV_RUNTIME_TOKEN)
66
+ const statusFilePath = readNonEmpty(env.OM_DEV_RUNTIME_STATUS_FILE)
67
+ const diagnosticsFilePath = readNonEmpty(env.OM_DEV_RUNTIME_DIAGNOSTICS_FILE)
68
+ if (!token || !statusFilePath || !diagnosticsFilePath) return DISABLED_CONFIG
69
+
70
+ return {
71
+ enabled: true,
72
+ bannerEnabled: readFlag(env.OM_DEV_RUNTIME_BANNER, true),
73
+ token,
74
+ statusFilePath,
75
+ diagnosticsFilePath,
76
+ // Absent when the supervisor predates the action channel; the actions route
77
+ // then reports itself unavailable rather than silently dropping requests.
78
+ actionsFilePath: readNonEmpty(env.OM_DEV_RUNTIME_ACTIONS_FILE),
79
+ logsFilePath: readNonEmpty(env.OM_DEV_RUNTIME_LOGS_FILE),
80
+ }
81
+ }
82
+
83
+ export function isMatchingDevRuntimeToken(expected: string | null, provided: string | null): boolean {
84
+ if (!expected || !provided) return false
85
+ const expectedBuffer = Buffer.from(expected)
86
+ const providedBuffer = Buffer.from(provided)
87
+ if (expectedBuffer.length !== providedBuffer.length) return false
88
+ return timingSafeEqual(expectedBuffer, providedBuffer)
89
+ }
90
+
91
+ type SupervisorStatusFile = {
92
+ token?: unknown
93
+ pid?: unknown
94
+ status?: unknown
95
+ }
96
+
97
+ function isRuntimeStatus(value: unknown): value is RuntimeStatus {
98
+ if (!value || typeof value !== 'object') return false
99
+ const candidate = value as Partial<RuntimeStatus>
100
+ return typeof candidate.health === 'string'
101
+ && typeof candidate.generation === 'number'
102
+ && Array.isArray(candidate.incidents)
103
+ }
104
+
105
+ /**
106
+ * Reads the supervisor-owned status file. The route never derives status from
107
+ * application state, so it cannot leak module, tenant, or organization data.
108
+ */
109
+ export function readDevRuntimeStatus(config: DevRuntimeServerConfig): RuntimeStatus | null {
110
+ if (!config.enabled || !config.statusFilePath) return null
111
+ let parsed: SupervisorStatusFile
112
+ try {
113
+ parsed = JSON.parse(readFileSync(config.statusFilePath, 'utf8')) as SupervisorStatusFile
114
+ } catch {
115
+ return null
116
+ }
117
+ // A status file written by a different run must not be served: its token is
118
+ // stale and its generation would confuse the banner.
119
+ if (typeof parsed.token !== 'string' || !isMatchingDevRuntimeToken(config.token, parsed.token)) return null
120
+ return isRuntimeStatus(parsed.status) ? parsed.status : null
121
+ }
122
+
123
+ export function appendDevRuntimeReport(config: DevRuntimeServerConfig, report: DevRuntimeReport): boolean {
124
+ if (!config.enabled || !config.diagnosticsFilePath) return false
125
+ try {
126
+ appendFileSync(config.diagnosticsFilePath, `${JSON.stringify(report)}\n`, 'utf8')
127
+ return true
128
+ } catch {
129
+ return false
130
+ }
131
+ }
132
+
133
+ export function appendDevRuntimeActionRequest(
134
+ config: DevRuntimeServerConfig,
135
+ request: { action: DevRuntimeRecoveryAction; generation?: number; requestedAt: string },
136
+ ): boolean {
137
+ if (!config.enabled || !config.actionsFilePath) return false
138
+ try {
139
+ appendFileSync(config.actionsFilePath, `${JSON.stringify(request)}\n`, 'utf8')
140
+ return true
141
+ } catch {
142
+ return false
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Reads the supervisor-published log tail. Lines are already bounded and
148
+ * redacted by the collector; this only filters by cursor so the logs view can
149
+ * poll incrementally.
150
+ */
151
+ export function readDevRuntimeLogs(
152
+ config: DevRuntimeServerConfig,
153
+ cursor = 0,
154
+ ): DevRuntimeLogSnapshot | null {
155
+ if (!config.enabled || !config.logsFilePath) return null
156
+ let parsed: { token?: unknown; generation?: unknown; lines?: unknown }
157
+ try {
158
+ parsed = JSON.parse(readFileSync(config.logsFilePath, 'utf8'))
159
+ } catch {
160
+ return null
161
+ }
162
+ if (typeof parsed.token !== 'string' || !isMatchingDevRuntimeToken(config.token, parsed.token)) return null
163
+ if (!Array.isArray(parsed.lines)) return null
164
+
165
+ const from = Number.isInteger(cursor) && cursor >= 0 ? cursor : 0
166
+ const lines = (parsed.lines as DevRuntimeLogLine[]).filter((line) => (
167
+ line && typeof line === 'object' && typeof line.text === 'string' && Number(line.seq) > from
168
+ ))
169
+ return {
170
+ generation: typeof parsed.generation === 'number' ? parsed.generation : 0,
171
+ lines,
172
+ nextCursor: lines.length > 0 ? Number(lines[lines.length - 1].seq) : from,
173
+ }
174
+ }
@@ -0,0 +1,101 @@
1
+ export const RUNTIME_STATUS_SCHEMA_VERSION = 1 as const
2
+
3
+ export type RuntimeHealth =
4
+ | 'starting'
5
+ | 'ready'
6
+ | 'degraded'
7
+ | 'recovering'
8
+ | 'unavailable'
9
+
10
+ export type RuntimeIssueSource = 'process' | 'log' | 'warmup' | 'probe' | 'browser'
11
+ export type RuntimeIssueSeverity = 'warning' | 'error'
12
+ export type RuntimeRecoveryAction = 'generate' | 'migrate' | 'restart'
13
+ export type DevRuntimeRecoveryAction = RuntimeRecoveryAction
14
+ export const DEV_RUNTIME_RECOVERY_ACTIONS: RuntimeRecoveryAction[] = ['generate', 'migrate', 'restart']
15
+
16
+ export type RuntimeIssue = {
17
+ id: string
18
+ fingerprint: string
19
+ code: string
20
+ source: RuntimeIssueSource
21
+ severity: RuntimeIssueSeverity
22
+ title: string
23
+ detail?: string
24
+ firstSeenAt: string
25
+ lastSeenAt: string
26
+ occurrences: number
27
+ generation: number
28
+ path?: string
29
+ digest?: string
30
+ recovery?: RuntimeRecoveryAction
31
+ }
32
+
33
+ export type RuntimeStatus = {
34
+ schemaVersion: typeof RUNTIME_STATUS_SCHEMA_VERSION
35
+ generation: number
36
+ health: RuntimeHealth
37
+ ready: boolean
38
+ failed: boolean
39
+ updatedAt: string
40
+ upstream: {
41
+ configuredPort: number
42
+ actualPort?: number
43
+ publicUrl: string
44
+ }
45
+ issueSummary?: RuntimeIssue
46
+ incidents: RuntimeIssue[]
47
+ recovery?: {
48
+ action: RuntimeRecoveryAction
49
+ startedAt: string
50
+ busy: boolean
51
+ lastExitCode?: number
52
+ }
53
+ legacy: {
54
+ failureLines: string[]
55
+ failureCommand?: string
56
+ failureStage?: string
57
+ }
58
+ }
59
+
60
+ export type DevRuntimeReportKind =
61
+ | 'global-error'
62
+ | 'window-error'
63
+ | 'unhandled-rejection'
64
+ | 'chunk-load-error'
65
+ | 'request-error'
66
+
67
+ export type DevRuntimeReport = {
68
+ kind: DevRuntimeReportKind
69
+ message: string
70
+ digest?: string
71
+ path?: string
72
+ stack?: string
73
+ timestamp?: string
74
+ }
75
+
76
+ export const DEV_RUNTIME_TOKEN_HEADER = 'x-om-dev-runtime-token'
77
+ export const DEV_RUNTIME_TOKEN_META_NAME = 'om-dev-runtime-token'
78
+ export const DEV_RUNTIME_BANNER_META_NAME = 'om-dev-runtime-banner'
79
+ export const DEV_RUNTIME_LOGS_URL_META_NAME = 'om-dev-runtime-logs-url'
80
+ // Kebab-case on purpose: module ids are snake_case, so this segment can never
81
+ // collide with a module's `/api/<module_id>/...` routes. A leading-underscore
82
+ // path is not an option — Next.js treats `_folder` as a private folder and drops
83
+ // it from the route tree entirely.
84
+ export const DEV_RUNTIME_STATUS_PATH = '/api/dev-runtime/status'
85
+ export const DEV_RUNTIME_DIAGNOSTICS_PATH = '/api/dev-runtime/diagnostics'
86
+ export const DEV_RUNTIME_ACTIONS_PATH = '/api/dev-runtime/actions'
87
+ export const DEV_RUNTIME_LOGS_PATH = '/api/dev-runtime/logs'
88
+
89
+ export type DevRuntimeLogLine = {
90
+ seq: number
91
+ at: string
92
+ generation: number
93
+ source: string
94
+ text: string
95
+ }
96
+
97
+ export type DevRuntimeLogSnapshot = {
98
+ generation: number
99
+ lines: DevRuntimeLogLine[]
100
+ nextCursor: number
101
+ }