@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
@@ -1,10 +1,13 @@
1
- import { Resend } from "resend";
2
1
  import React from "react";
3
2
  import { appendFile, mkdir } from "node:fs/promises";
4
3
  import { dirname, join } from "node:path";
5
4
  import { tmpdir } from "node:os";
6
5
  import { parseBooleanWithDefault } from "../boolean.js";
7
- import { resolveDefaultEmailFromAddress } from "./config.js";
6
+ import {
7
+ isEmailDeliveryDisabled,
8
+ resolveDefaultEmailFromAddress
9
+ } from "./config.js";
10
+ import { getRegisteredEmailTransport } from "./transport.js";
8
11
  const DEFAULT_TEST_EMAIL_CAPTURE_PATH = join(tmpdir(), "open-mercato-email-capture.jsonl");
9
12
  function resolveTestEmailCapturePath() {
10
13
  return process.env.OM_TEST_EMAIL_CAPTURE_PATH?.trim() || DEFAULT_TEST_EMAIL_CAPTURE_PATH;
@@ -56,30 +59,30 @@ async function captureEmailForTests(options) {
56
59
  await appendFile(capturePath, `${JSON.stringify(record)}
57
60
  `, "utf8");
58
61
  }
59
- async function sendEmail({ to, subject, react, from, replyTo, attachments }) {
60
- const emailDisabled = parseBooleanWithDefault(process.env.OM_DISABLE_EMAIL_DELIVERY, false) || parseBooleanWithDefault(process.env.OM_TEST_MODE, false);
61
- await captureEmailForTests({ to, subject, react, from, replyTo, attachments });
62
- if (emailDisabled) return;
63
- const apiKey = process.env.RESEND_API_KEY;
64
- if (!apiKey) throw new Error("RESEND_API_KEY is not set");
65
- const resend = new Resend(apiKey);
66
- const fromAddr = from || resolveDefaultEmailFromAddress();
62
+ async function sendEmail(options) {
63
+ await captureEmailForTests(options);
64
+ if (isEmailDeliveryDisabled()) return;
65
+ const fromAddr = options.from || resolveDefaultEmailFromAddress();
67
66
  if (!fromAddr) {
68
67
  throw new Error("EMAIL_FROM_NOT_CONFIGURED: set NOTIFICATIONS_EMAIL_FROM, EMAIL_FROM, or ADMIN_EMAIL");
69
68
  }
70
- const payload = {
71
- to,
72
- subject,
73
- from: fromAddr,
74
- react,
75
- ...replyTo ? { reply_to: replyTo } : {},
76
- ...attachments?.length ? { attachments } : {}
77
- };
78
- const result = await resend.emails.send(payload);
79
- const errorMessage = typeof result?.error === "string" ? result.error : typeof result?.error?.message === "string" ? result.error.message : null;
80
- if (errorMessage) {
81
- throw new Error(`RESEND_SEND_FAILED: ${errorMessage}`);
69
+ const transport = getRegisteredEmailTransport();
70
+ if (!transport) {
71
+ throw new Error("EMAIL_TRANSPORT_NOT_CONFIGURED: enable an outbound email provider module");
82
72
  }
73
+ await transport.send({
74
+ to: options.to,
75
+ subject: options.subject,
76
+ react: options.react,
77
+ html: options.html,
78
+ text: options.text,
79
+ from: fromAddr,
80
+ fromIsInstanceDefault: !options.from,
81
+ replyTo: options.replyTo,
82
+ attachments: options.attachments,
83
+ tenantId: options.tenantId,
84
+ organizationId: options.organizationId
85
+ });
83
86
  }
84
87
  export {
85
88
  sendEmail
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/email/send.ts"],
4
- "sourcesContent": ["import { Resend } from 'resend'\nimport React from 'react'\nimport { appendFile, mkdir } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport { tmpdir } from 'node:os'\nimport { parseBooleanWithDefault } from '../boolean'\nimport { resolveDefaultEmailFromAddress } from './config'\n\nexport type SendEmailOptions = {\n to: string\n subject: string\n react: React.ReactElement\n from?: string\n replyTo?: string\n attachments?: Array<{\n filename: string\n content: string\n contentType?: string\n }>\n}\n\ntype CapturedEmail = {\n to: string\n subject: string\n from: string | null\n replyTo: string | null\n links: string[]\n text: string\n capturedAt: string\n}\n\ntype ReactElementProps = {\n href?: unknown\n children?: unknown\n}\n\nconst DEFAULT_TEST_EMAIL_CAPTURE_PATH = join(tmpdir(), 'open-mercato-email-capture.jsonl')\n\nfunction resolveTestEmailCapturePath(): string {\n return process.env.OM_TEST_EMAIL_CAPTURE_PATH?.trim() || DEFAULT_TEST_EMAIL_CAPTURE_PATH\n}\n\nfunction readElementProps(node: React.ReactElement): ReactElementProps {\n return node.props as ReactElementProps\n}\n\nfunction collectEmailLinks(node: unknown, links: string[] = []): string[] {\n if (node == null || typeof node === 'boolean') return links\n if (Array.isArray(node)) {\n for (const child of node) collectEmailLinks(child, links)\n return links\n }\n if (React.isValidElement(node)) {\n const props = readElementProps(node)\n if (typeof props.href === 'string' && props.href.length > 0) links.push(props.href)\n collectEmailLinks(props.children, links)\n }\n return links\n}\n\nfunction collectEmailText(node: unknown, parts: string[] = []): string[] {\n if (node == null || typeof node === 'boolean') return parts\n if (typeof node === 'string' || typeof node === 'number') {\n parts.push(String(node))\n return parts\n }\n if (Array.isArray(node)) {\n for (const child of node) collectEmailText(child, parts)\n return parts\n }\n if (React.isValidElement(node)) {\n collectEmailText(readElementProps(node).children, parts)\n }\n return parts\n}\n\nasync function captureEmailForTests(options: SendEmailOptions): Promise<void> {\n if (!parseBooleanWithDefault(process.env.OM_TEST_MODE, false)) return\n\n const capturePath = resolveTestEmailCapturePath()\n const record: CapturedEmail = {\n to: options.to,\n subject: options.subject,\n from: options.from ?? resolveDefaultEmailFromAddress() ?? null,\n replyTo: options.replyTo ?? null,\n links: collectEmailLinks(options.react),\n text: collectEmailText(options.react).join(' ').replace(/\\s+/g, ' ').trim(),\n capturedAt: new Date().toISOString(),\n }\n\n await mkdir(dirname(capturePath), { recursive: true })\n await appendFile(capturePath, `${JSON.stringify(record)}\\n`, 'utf8')\n}\n\nexport async function sendEmail({ to, subject, react, from, replyTo, attachments }: SendEmailOptions) {\n const emailDisabled =\n parseBooleanWithDefault(process.env.OM_DISABLE_EMAIL_DELIVERY, false) ||\n parseBooleanWithDefault(process.env.OM_TEST_MODE, false)\n\n await captureEmailForTests({ to, subject, react, from, replyTo, attachments })\n\n if (emailDisabled) return\n\n const apiKey = process.env.RESEND_API_KEY\n if (!apiKey) throw new Error('RESEND_API_KEY is not set')\n const resend = new Resend(apiKey)\n const fromAddr = from || resolveDefaultEmailFromAddress()\n if (!fromAddr) {\n throw new Error('EMAIL_FROM_NOT_CONFIGURED: set NOTIFICATIONS_EMAIL_FROM, EMAIL_FROM, or ADMIN_EMAIL')\n }\n const payload = {\n to,\n subject,\n from: fromAddr,\n react,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(attachments?.length ? { attachments } : {}),\n }\n const result = await resend.emails.send(payload)\n const errorMessage =\n typeof (result as any)?.error === 'string'\n ? (result as any).error\n : typeof (result as any)?.error?.message === 'string'\n ? (result as any).error.message\n : null\n if (errorMessage) {\n throw new Error(`RESEND_SEND_FAILED: ${errorMessage}`)\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import React from 'react'\nimport { appendFile, mkdir } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport { tmpdir } from 'node:os'\nimport { parseBooleanWithDefault } from '../boolean'\nimport {\n isEmailDeliveryDisabled,\n resolveDefaultEmailFromAddress,\n} from './config'\nimport { getRegisteredEmailTransport } from './transport'\n\nexport type EmailAttachment = {\n filename: string\n content: string\n contentType?: string\n}\n\nexport type SendEmailOptions = {\n to: string\n subject: string\n react?: React.ReactElement\n html?: string\n text?: string\n from?: string\n replyTo?: string\n attachments?: EmailAttachment[]\n tenantId?: string\n organizationId?: string | null\n}\n\nexport type ResolvedEmailPayload = {\n to: string\n subject: string\n react?: React.ReactElement\n html?: string\n text?: string\n from: string\n /**\n * True when `from` was filled in from the instance-wide environment defaults rather than chosen by\n * the caller. Transports use this to decide whether a tenant's own configured sender may take\n * precedence: `from` is never empty by the time it reaches a transport, so without this flag a\n * per-tenant sender is unreachable. Absent means \"caller chose it\" for older transports.\n */\n fromIsInstanceDefault?: boolean\n replyTo?: string\n attachments?: EmailAttachment[]\n tenantId?: string\n organizationId?: string | null\n}\n\ntype CapturedEmail = {\n to: string\n subject: string\n from: string | null\n replyTo: string | null\n links: string[]\n text: string\n capturedAt: string\n}\n\ntype ReactElementProps = {\n href?: unknown\n children?: unknown\n}\n\nconst DEFAULT_TEST_EMAIL_CAPTURE_PATH = join(tmpdir(), 'open-mercato-email-capture.jsonl')\n\nfunction resolveTestEmailCapturePath(): string {\n return process.env.OM_TEST_EMAIL_CAPTURE_PATH?.trim() || DEFAULT_TEST_EMAIL_CAPTURE_PATH\n}\n\nfunction readElementProps(node: React.ReactElement): ReactElementProps {\n return node.props as ReactElementProps\n}\n\nfunction collectEmailLinks(node: unknown, links: string[] = []): string[] {\n if (node == null || typeof node === 'boolean') return links\n if (Array.isArray(node)) {\n for (const child of node) collectEmailLinks(child, links)\n return links\n }\n if (React.isValidElement(node)) {\n const props = readElementProps(node)\n if (typeof props.href === 'string' && props.href.length > 0) links.push(props.href)\n collectEmailLinks(props.children, links)\n }\n return links\n}\n\nfunction collectEmailText(node: unknown, parts: string[] = []): string[] {\n if (node == null || typeof node === 'boolean') return parts\n if (typeof node === 'string' || typeof node === 'number') {\n parts.push(String(node))\n return parts\n }\n if (Array.isArray(node)) {\n for (const child of node) collectEmailText(child, parts)\n return parts\n }\n if (React.isValidElement(node)) {\n collectEmailText(readElementProps(node).children, parts)\n }\n return parts\n}\n\nasync function captureEmailForTests(options: SendEmailOptions): Promise<void> {\n if (!parseBooleanWithDefault(process.env.OM_TEST_MODE, false)) return\n\n const capturePath = resolveTestEmailCapturePath()\n const record: CapturedEmail = {\n to: options.to,\n subject: options.subject,\n from: options.from ?? resolveDefaultEmailFromAddress() ?? null,\n replyTo: options.replyTo ?? null,\n links: collectEmailLinks(options.react),\n text: collectEmailText(options.react).join(' ').replace(/\\s+/g, ' ').trim(),\n capturedAt: new Date().toISOString(),\n }\n\n await mkdir(dirname(capturePath), { recursive: true })\n await appendFile(capturePath, `${JSON.stringify(record)}\\n`, 'utf8')\n}\n\nexport async function sendEmail(options: SendEmailOptions): Promise<void> {\n await captureEmailForTests(options)\n if (isEmailDeliveryDisabled()) return\n\n const fromAddr = options.from || resolveDefaultEmailFromAddress()\n if (!fromAddr) {\n throw new Error('EMAIL_FROM_NOT_CONFIGURED: set NOTIFICATIONS_EMAIL_FROM, EMAIL_FROM, or ADMIN_EMAIL')\n }\n\n const transport = getRegisteredEmailTransport()\n if (!transport) {\n throw new Error('EMAIL_TRANSPORT_NOT_CONFIGURED: enable an outbound email provider module')\n }\n\n await transport.send({\n to: options.to,\n subject: options.subject,\n react: options.react,\n html: options.html,\n text: options.text,\n from: fromAddr,\n fromIsInstanceDefault: !options.from,\n replyTo: options.replyTo,\n attachments: options.attachments,\n tenantId: options.tenantId,\n organizationId: options.organizationId,\n })\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,19 @@
1
+ const EMAIL_TRANSPORT_REGISTRY = /* @__PURE__ */ Symbol.for("open-mercato.email.transport");
2
+ function emailTransportRoot() {
3
+ return globalThis;
4
+ }
5
+ function registerEmailTransport(transport) {
6
+ emailTransportRoot()[EMAIL_TRANSPORT_REGISTRY] = transport;
7
+ }
8
+ function getRegisteredEmailTransport() {
9
+ return emailTransportRoot()[EMAIL_TRANSPORT_REGISTRY] ?? null;
10
+ }
11
+ function clearRegisteredEmailTransportForTests() {
12
+ emailTransportRoot()[EMAIL_TRANSPORT_REGISTRY] = null;
13
+ }
14
+ export {
15
+ clearRegisteredEmailTransportForTests,
16
+ getRegisteredEmailTransport,
17
+ registerEmailTransport
18
+ };
19
+ //# sourceMappingURL=transport.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/email/transport.ts"],
4
+ "sourcesContent": ["import type { ResolvedEmailPayload } from './send'\n\nexport type EmailTransport = {\n id: string\n send: (payload: ResolvedEmailPayload) => Promise<void>\n isConfigured?: () => boolean\n}\n\nconst EMAIL_TRANSPORT_REGISTRY = Symbol.for('open-mercato.email.transport')\n\ntype EmailTransportRegistryGlobal = typeof globalThis & {\n [EMAIL_TRANSPORT_REGISTRY]?: EmailTransport | null\n}\n\nfunction emailTransportRoot(): EmailTransportRegistryGlobal {\n return globalThis as EmailTransportRegistryGlobal\n}\n\nexport function registerEmailTransport(transport: EmailTransport): void {\n emailTransportRoot()[EMAIL_TRANSPORT_REGISTRY] = transport\n}\n\nexport function getRegisteredEmailTransport(): EmailTransport | null {\n return emailTransportRoot()[EMAIL_TRANSPORT_REGISTRY] ?? null\n}\n\nexport function clearRegisteredEmailTransportForTests(): void {\n emailTransportRoot()[EMAIL_TRANSPORT_REGISTRY] = null\n}\n"],
5
+ "mappings": "AAQA,MAAM,2BAA2B,uBAAO,IAAI,8BAA8B;AAM1E,SAAS,qBAAmD;AAC1D,SAAO;AACT;AAEO,SAAS,uBAAuB,WAAiC;AACtE,qBAAmB,EAAE,wBAAwB,IAAI;AACnD;AAEO,SAAS,8BAAqD;AACnE,SAAO,mBAAmB,EAAE,wBAAwB,KAAK;AAC3D;AAEO,SAAS,wCAA8C;AAC5D,qBAAmB,EAAE,wBAAwB,IAAI;AACnD;",
6
+ "names": []
7
+ }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.7.1-develop.7150.1.c1941e0c22";
1
+ const APP_VERSION = "0.7.1-develop.7152.1.a69e92f9c9";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/version.ts"],
4
- "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.7.1-develop.7150.1.c1941e0c22';\nexport const appVersion = APP_VERSION;\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.7.1-develop.7152.1.a69e92f9c9';\nexport const appVersion = APP_VERSION;\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.7.1-develop.7150.1.c1941e0c22",
3
+ "version": "0.7.1-develop.7152.1.a69e92f9c9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -113,7 +113,7 @@
113
113
  "@mikro-orm/core": "^7.1.8",
114
114
  "@mikro-orm/decorators": "^7.1.8",
115
115
  "@mikro-orm/postgresql": "^7.1.8",
116
- "@open-mercato/cache": "0.7.1-develop.7150.1.c1941e0c22",
116
+ "@open-mercato/cache": "0.7.1-develop.7152.1.a69e92f9c9",
117
117
  "@types/html-to-text": "^9.0.4",
118
118
  "@types/sanitize-html": "^2.16.1",
119
119
  "dotenv": "^17.4.2",
@@ -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
+ })