@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.
- 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/email/config.js +20 -0
- package/dist/lib/email/config.js.map +2 -2
- package/dist/lib/email/send.js +25 -22
- package/dist/lib/email/send.js.map +2 -2
- package/dist/lib/email/transport.js +19 -0
- package/dist/lib/email/transport.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
- package/src/lib/email/__tests__/send.test.ts +140 -69
- package/src/lib/email/config.ts +26 -1
- package/src/lib/email/send.ts +59 -37
- 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
|
+
}
|