diff --git a/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts b/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts
index d509a0e1a..903c4fcbb 100644
--- a/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts
+++ b/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts
@@ -2,11 +2,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
GET,
POST,
+ PUT,
+ DELETE,
__resetInflowLabDemoRateLimitForTests,
trustedClientIp,
sanitizePathSegment,
stripTenantLike,
} from '@/app/api/inflow-lab-demo/[...path]/route';
+import { buildDemoExportQuery } from '@/app/tools/inflow-lab/lib/demo-export-query';
import { CSRF_HEADER, CSRF_SIG_COOKIE, signCsrfToken } from '@/lib/csrf';
import type { NextRequest } from 'next/server';
@@ -154,9 +157,9 @@ describe('Inflow Lab public proxy', () => {
new Response(
JSON.stringify({
ok: true,
- tenant_id: 'should-vanish',
- delivery: { mode: 'local', tenantId: 'also-vanish', posted: 3 },
- events: [{ id: 'e1', customer_id: 'c1', lot: 'L1' }],
+ tenant_id: 'x',
+ delivery: { mode: 'local', tenantId: 'y', posted: 3 },
+ events: [{ id: 'row', customer_id: 'z', lot: 'seg' }],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
@@ -171,7 +174,7 @@ describe('Inflow Lab public proxy', () => {
await expect(response.json()).resolves.toEqual({
ok: true,
delivery: { mode: 'local', posted: 3 },
- events: [{ id: 'e1', lot: 'L1' }],
+ events: [{ id: 'row', lot: 'seg' }],
});
});
@@ -194,7 +197,7 @@ describe('Inflow Lab public proxy', () => {
it('allowlists export preview GETs under /api/inflow-lab-demo and streams the body', async () => {
mockFetch.mockResolvedValueOnce(
- new Response('lot,qty\nL1,10\n', {
+ new Response('lot,qty\nrow,10\n', {
status: 200,
headers: {
'content-type': 'text/csv',
@@ -212,7 +215,7 @@ describe('Inflow Lab public proxy', () => {
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('text/csv');
- expect(await response.text()).toBe('lot,qty\nL1,10\n');
+ expect(await response.text()).toBe('lot,qty\nrow,10\n');
expect(String(mockFetch.mock.calls[0][0])).toContain(
'http://inflow.test/api/mock/regengine/export/fda-request',
);
@@ -240,7 +243,7 @@ describe('Inflow Lab public proxy', () => {
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify({
- records: [{ lot: 'L1', tenant_id: 't-gone', qty: 3 }],
+ records: [{ lot: 'seg', tenant_id: 't-gone', qty: 3 }],
tenantId: 'also-gone',
customer_id: 'c-gone',
ok: true,
@@ -264,7 +267,7 @@ describe('Inflow Lab public proxy', () => {
'http://inflow.test/api/mock/regengine/export/fda-request?preset=all_records',
);
await expect(response.json()).resolves.toEqual({
- records: [{ lot: 'L1', qty: 3 }],
+ records: [{ lot: 'seg', qty: 3 }],
ok: true,
});
});
@@ -281,7 +284,7 @@ describe('Inflow Lab public proxy', () => {
{
type: 'ObjectEvent',
tenantId: 'also-vanish',
- customer_id: 'c1',
+ customer_id: 'z',
bizStep: 'shipping',
},
],
@@ -296,7 +299,7 @@ describe('Inflow Lab public proxy', () => {
const response = await GET(
makeRequest({
- url: 'http://localhost/api/inflow-lab-demo/api/regengine/export/epcis?traceability_lot_code=L1',
+ url: 'http://localhost/api/inflow-lab-demo/api/regengine/export/epcis?traceability_lot_code=seg',
}),
{ params: Promise.resolve({ path: ['api', 'regengine', 'export', 'epcis'] }) },
);
@@ -468,7 +471,7 @@ describe('Inflow Lab public proxy', () => {
expect(mockFetch, path.join('/')).not.toHaveBeenCalled();
}
- expect(sanitizePathSegment('LOT-001')).toBe('LOT-001');
+ expect(sanitizePathSegment('seg-safe')).toBe('seg-safe');
expect(sanitizePathSegment('%2e%2e')).toBeNull();
expect(sanitizePathSegment('%252e%252e')).toBeNull();
expect(sanitizePathSegment('..\\windows')).toBeNull();
@@ -476,6 +479,101 @@ describe('Inflow Lab public proxy', () => {
expect(stripTenantLike({ tenant_id: 't', keep: 1 })).toEqual({ keep: 1 });
});
+ it('allowlists signed-out GET lineage for colon-bearing segments and strips tenant fields', async () => {
+ // Structural fixture: exercises ':' in SEGMENT_ALLOW without inventing a realistic URN/TLC.
+ const lot = ['seg', 'with', 'colons'].join(':');
+ mockFetch.mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ lotCode: lot,
+ tenant_id: 'x',
+ organizationId: 'y',
+ events: [{ id: 'row', keep: true, customer_id: 'z' }],
+ }),
+ {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ },
+ ),
+ );
+
+ const response = await GET(
+ makeRequest({
+ url: `http://localhost/api/inflow-lab-demo/api/lineage/${encodeURIComponent(lot)}`,
+ cookies: {},
+ }),
+ { params: Promise.resolve({ path: ['api', 'lineage', lot] }) },
+ );
+
+ expect(response.status).toBe(200);
+ await expect(response.json()).resolves.toEqual({
+ lotCode: lot,
+ events: [{ id: 'row', keep: true }],
+ });
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ expect(String(mockFetch.mock.calls[0][0])).toBe(
+ `http://inflow.test/api/lineage/${encodeURIComponent(lot)}`,
+ );
+ expect(sanitizePathSegment(lot)).toBe(lot);
+ });
+
+ it('rejects lineage POST, PUT, and DELETE while keeping GET allowlisted', async () => {
+ const lot = 'seg';
+ const path = ['api', 'lineage', lot];
+ const csrf = await csrfCookies();
+
+ mockFetch.mockResolvedValue(
+ new Response(JSON.stringify({ ok: true }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+
+ const getResponse = await GET(
+ makeRequest({ url: `http://localhost/api/inflow-lab-demo/api/lineage/${lot}` }),
+ { params: Promise.resolve({ path }) },
+ );
+ expect(getResponse.status).toBe(200);
+
+ const postResponse = await POST(
+ makeRequest({
+ method: 'POST',
+ url: `http://localhost/api/inflow-lab-demo/api/lineage/${lot}`,
+ headers: csrf.headers,
+ cookies: csrf.cookies,
+ body: JSON.stringify({ noop: true }),
+ }),
+ { params: Promise.resolve({ path }) },
+ );
+ expect(postResponse.status).toBe(404);
+ await expect(postResponse.json()).resolves.toMatchObject({ code: 'NOT_ALLOWLISTED' });
+
+ const putResponse = await PUT(
+ makeRequest({
+ method: 'PUT',
+ url: `http://localhost/api/inflow-lab-demo/api/lineage/${lot}`,
+ headers: csrf.headers,
+ cookies: csrf.cookies,
+ body: JSON.stringify({ noop: true }),
+ }),
+ { params: Promise.resolve({ path }) },
+ );
+ expect(putResponse.status).toBe(405);
+ await expect(putResponse.json()).resolves.toMatchObject({ code: 'METHOD_NOT_ALLOWED' });
+
+ const deleteResponse = await DELETE(
+ makeRequest({
+ method: 'DELETE',
+ url: `http://localhost/api/inflow-lab-demo/api/lineage/${lot}`,
+ headers: csrf.headers,
+ cookies: csrf.cookies,
+ }),
+ { params: Promise.resolve({ path }) },
+ );
+ expect(deleteResponse.status).toBe(405);
+ await expect(deleteResponse.json()).resolves.toMatchObject({ code: 'METHOD_NOT_ALLOWED' });
+ });
+
it('returns exact 404 in production unless the public proxy flag is enabled', async () => {
vi.stubEnv('NODE_ENV', 'production');
vi.stubEnv('REGENGINE_ENABLE_INFLOW_LAB_DEMO_PROXY', '');
@@ -491,9 +589,10 @@ describe('Inflow Lab public proxy', () => {
});
});
-describe('Inflow Lab client export hrefs use the public proxy base', () => {
- it('documents the public base + safe relative export paths', async () => {
- // Mirrors InflowLabClient: INFLOW_API + servicePath + safeHref.
+describe('Demo export query + public proxy path helpers', () => {
+ it('builds public-proxy-relative export paths from empty and filtered inputs', async () => {
+ // Documents INFLOW_API + servicePath + safeHref + buildDemoExportQuery shape
+ // (does not render InflowLabClient).
const INFLOW_API = '/api/inflow-lab-demo';
function servicePath(path: string) {
return `${INFLOW_API}${path}`;
@@ -512,18 +611,43 @@ describe('Inflow Lab client export hrefs use the public proxy base', () => {
return '#';
}
- const csv = safeHref(
+ const defaultCsv = safeHref(
servicePath(
- '/api/regengine/export/fda-request?preset=all_records&traceability_lot_code=L1',
+ `/api/regengine/export/fda-request${buildDemoExportQuery({
+ preset: 'all_records',
+ traceabilityLotCode: '',
+ startDate: '',
+ endDate: '',
+ })}`,
),
);
- const epcis = safeHref(
- servicePath('/api/regengine/export/epcis?traceability_lot_code=L1'),
+ const defaultEpcis = safeHref(
+ servicePath(
+ `/api/regengine/export/epcis${buildDemoExportQuery({
+ traceabilityLotCode: '',
+ startDate: '',
+ endDate: '',
+ })}`,
+ ),
+ );
+ const filterLot = 'seg';
+ const filteredCsv = safeHref(
+ servicePath(
+ `/api/regengine/export/fda-request${buildDemoExportQuery({
+ preset: 'all_records',
+ traceabilityLotCode: filterLot,
+ })}`,
+ ),
);
- expect(csv.startsWith('/api/inflow-lab-demo/api/regengine/export/fda-request')).toBe(true);
- expect(epcis.startsWith('/api/inflow-lab-demo/api/regengine/export/epcis')).toBe(true);
- expect(csv.includes('://')).toBe(false);
- expect(epcis.includes('://')).toBe(false);
+ expect(defaultCsv).toBe(
+ '/api/inflow-lab-demo/api/regengine/export/fda-request?preset=all_records',
+ );
+ expect(defaultEpcis).toBe('/api/inflow-lab-demo/api/regengine/export/epcis');
+ expect(filteredCsv).toBe(
+ `/api/inflow-lab-demo/api/regengine/export/fda-request?preset=all_records&traceability_lot_code=${filterLot}`,
+ );
+ expect(defaultCsv.includes('://')).toBe(false);
+ expect(defaultEpcis.includes('://')).toBe(false);
});
});
diff --git a/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts b/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts
index 0b4e41452..12795f174 100644
--- a/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts
+++ b/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts
@@ -16,7 +16,8 @@ const MAX_BODY_BYTES = 32 * 1024;
const MAX_EXPORT_JSON_BYTES = 2 * 1024 * 1024;
const TENANT_LIKE_KEY = /^(tenant(_?id)?|tenantId|customer(_?id)?|customerId|organization(_?id)?|org_id|orgId)$/i;
const TRAVERSAL_ENCODED = /%2e|%2f|%5c/i;
-const SEGMENT_ALLOW = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
+// Colon allowed so EPCIS URN lot codes (urn:epc:id:sgtin:...) survive the public lineage path.
+const SEGMENT_ALLOW = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
// Best-effort per-instance token bucket (same posture as /api/client-errors).
const DEFAULT_WINDOW_MS = 60_000;
@@ -199,7 +200,7 @@ function contractFor(path: string, method: string): DemoContract | null {
if (method === "GET" && path === "/api/healthz") return "health";
if (method === "GET" && path === "/api/simulate/status") return "status";
if (method === "GET" && path === "/api/events") return "events";
- if (method === "GET" && /^\/api\/lineage\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(path)) {
+ if (method === "GET" && /^\/api\/lineage\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(path)) {
return "lineage";
}
if (method === "POST" && path === "/api/simulate/start") return "start";
@@ -515,3 +516,11 @@ export function GET(request: NextRequest, context: { params: Promise<{ path?: st
export function POST(request: NextRequest, context: { params: Promise<{ path?: string[] }> }) {
return proxyPublic(request, context.params);
}
+
+export function PUT(request: NextRequest, context: { params: Promise<{ path?: string[] }> }) {
+ return proxyPublic(request, context.params);
+}
+
+export function DELETE(request: NextRequest, context: { params: Promise<{ path?: string[] }> }) {
+ return proxyPublic(request, context.params);
+}
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index ca6031af9..2e31bf50f 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -124,8 +124,33 @@ body[data-inflow-lab="true"] {
background: #fff;
color: #0f172a;
}
-[data-inflow-lab-app] button.bg-emerald-700,
-[data-inflow-lab-app] button.bg-emerald-600,
+/* Emerald/primary actions need an explicit background: the generic button
+ rule above paints background:#fff, and color:#fff here alone is white-on-white. */
+[data-inflow-lab-app] button.bg-emerald-700 {
+ background: #047857;
+ border-color: transparent;
+ color: #fff;
+}
+[data-inflow-lab-app] button.bg-emerald-700:hover {
+ background: #065f46;
+ border-color: transparent;
+ color: #fff;
+}
+[data-inflow-lab-app] button.bg-emerald-600 {
+ background: #059669;
+ border-color: transparent;
+ color: #fff;
+}
+[data-inflow-lab-app] button.bg-emerald-600:hover {
+ background: #047857;
+ border-color: transparent;
+ color: #fff;
+}
+[data-inflow-lab-app] button.bg-emerald-800 {
+ background: #065f46;
+ border-color: transparent;
+ color: #fff;
+}
[data-inflow-lab-app] button.bg-slate-950 {
border-color: transparent;
color: #fff;
diff --git a/frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx b/frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx
index 307f82150..485a64ea8 100644
--- a/frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx
+++ b/frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx
@@ -2,6 +2,7 @@
import Link from "next/link";
import { inflowErrorMessage } from "@/lib/inflow-error";
+import { buildDemoExportQuery } from "@/app/tools/inflow-lab/lib/demo-export-query";
import { useEffect, useMemo, useRef, useState } from "react";
import {
AlertTriangle,
@@ -833,7 +834,7 @@ export function InflowLabClient({
const [activeTab, setActiveTab] = useState(primaryTab);
const [isTutorialOpen, setIsTutorialOpen] = useState(false);
const [selectedLot, setSelectedLot] = useState(lotCodes[0]);
- const [traceInput, setTraceInput] = useState(lotCodes[0]);
+ const [traceInput, setTraceInput] = useState("");
const [runStage, setRunStage] = useState
Harvest, cool, pack, ship, and receive one mock scenario through the test gateway. Production evidence stays in export jobs.
Includes {exportReadyCount} test-complete lots. {exceptionLots.length} exception lots are flagged for review. Formal production records are generated only from imported production data.
-