@pablo2410/core-server 0.9.2 → 0.9.4

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/dist/index.js CHANGED
@@ -342,7 +342,7 @@ var KNOWLEDGE = [
342
342
  {
343
343
  id: "live-services",
344
344
  question: "Which services are available right now?",
345
- answer: "Live today: OEE Manager, the SQDCP Dashboard, Policy Deployment, and Action Manager. In development and coming soon: OplyticsConnect, Quality Manager, Safety Manager, and Certification Manager \u2014 existing customers get early access and preferential pricing when they launch.",
345
+ answer: "Live today: OEE Manager, the SQDCP Dashboard, Policy Deployment, and Action Manager.",
346
346
  tags: [
347
347
  "live",
348
348
  "available",
@@ -400,13 +400,14 @@ var KNOWLEDGE = [
400
400
  {
401
401
  id: "oee-data-collection",
402
402
  question: "Does OEE data have to be entered by hand?",
403
- answer: "No \u2014 OEE Manager can connect directly to PLCs and sensors via SmartConnect for automated data collection, eliminating manual-entry errors. Manual entry is still available where automation isn't in place.",
403
+ answer: "No \u2014 machines and other systems can send live data to OEE Manager automatically through its REST API or webhooks, eliminating manual-entry errors. Manual entry is still available where automation isn't in place.",
404
404
  tags: [
405
405
  "oee",
406
406
  "data",
407
407
  "collection",
408
408
  "automated",
409
- "smartconnect",
409
+ "api",
410
+ "webhook",
410
411
  "plc",
411
412
  "sensor",
412
413
  "manual"
@@ -493,57 +494,11 @@ var KNOWLEDGE = [
493
494
  "closure"
494
495
  ]
495
496
  },
496
- // ── Coming soon ────────────────────────────────────────────────────────────
497
- {
498
- id: "safety-manager",
499
- question: "Do you have a Safety Manager?",
500
- answer: "Safety Manager is coming soon. It will cover mobile-first incident reporting (with photos and witness statements), hazard identification and risk assessment, behavioural safety observations, and a compliance dashboard. Existing customers get early access at launch.",
501
- tags: [
502
- "safety",
503
- "manager",
504
- "incident",
505
- "hazard",
506
- "risk",
507
- "observation",
508
- "compliance",
509
- "coming"
510
- ]
511
- },
512
- {
513
- id: "quality-manager",
514
- question: "Do you have a Quality Manager?",
515
- answer: "Quality Manager is coming soon. It will handle non-conformance tracking from detection to disposition, a structured CAPA workflow with root-cause tools, internal/external audit scheduling, and quality metrics (first-pass yield, defect rates, cost of quality). Existing customers get early access at launch.",
516
- tags: [
517
- "quality",
518
- "manager",
519
- "ncr",
520
- "capa",
521
- "non-conformance",
522
- "audit",
523
- "coming"
524
- ]
525
- },
526
- {
527
- id: "certification-manager",
528
- question: "Can it help with ISO certification?",
529
- answer: "Certification Manager is coming soon. It will manage standards like ISO 9001, ISO 14001, and IATF 16949 in one place, with version-controlled document control, a complete audit trail, and compliance/gap tracking. Existing customers get early access at launch.",
530
- tags: [
531
- "certification",
532
- "iso",
533
- "9001",
534
- "14001",
535
- "iatf",
536
- "16949",
537
- "standards",
538
- "document",
539
- "audit",
540
- "coming"
541
- ]
542
- },
497
+ // ── Integrations ───────────────────────────────────────────────────────────
543
498
  {
544
499
  id: "connect-integrations",
545
500
  question: "Can Oplytics connect to our machines and systems?",
546
- answer: "Live OEE data already flows in via SmartConnect (PLCs and sensors). The standalone OplyticsConnect service is coming soon and adds broader machine connectivity \u2014 PLCs, SCADA, and industrial sensors over OPC-UA, MQTT, Modbus and more \u2014 with zero-code configuration and edge processing.",
501
+ answer: "Yes \u2014 machines and other systems can send live data to OEE Manager automatically through its REST API or webhooks. For a particular protocol, machine type, or business system, get in touch via /contact so the team can scope your setup.",
547
502
  tags: [
548
503
  "connect",
549
504
  "integration",
@@ -555,7 +510,8 @@ var KNOWLEDGE = [
555
510
  "mqtt",
556
511
  "modbus",
557
512
  "iot",
558
- "smartconnect"
513
+ "api",
514
+ "webhook"
559
515
  ]
560
516
  },
561
517
  {
@@ -567,7 +523,7 @@ var KNOWLEDGE = [
567
523
  {
568
524
  id: "protocols",
569
525
  question: "Which industrial protocols are supported?",
570
- answer: "Machine connectivity supports common industrial protocols including OPC-UA, MQTT, and Modbus, connecting to PLCs, SCADA systems, and sensors. Configuration is visual and zero-code, with edge processing and local buffering so no data is lost during connectivity drops.",
526
+ answer: "Today OEE Manager takes live machine data through a REST API (data sent by HTTP POST) and webhooks. If your equipment uses a specific industrial protocol such as OPC-UA, MQTT, or Modbus, get in touch via /contact so the team can confirm the best way to connect it.",
571
527
  tags: [
572
528
  "protocol",
573
529
  "opcua",
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/constants/sqdcp.ts","../src/metering.ts","../src/llm.ts","../src/ai/personas.ts","../src/ai/knowledge.ts","../src/ai/supportEngine.ts","../src/securityHeaders.ts"],"sourcesContent":["export const SQDCP_PILLARS = [\n \"safety\",\n \"quality\",\n \"delivery\",\n \"cost\",\n \"people\",\n] as const;\nexport type SqdcpPillar = (typeof SQDCP_PILLARS)[number];\nexport const SQDCP_PILLAR_COLORS: Record<SqdcpPillar, string> = {\n safety: \"#ef4444\",\n quality: \"#3b82f6\",\n delivery: \"#f59e0b\",\n cost: \"#10b981\",\n people: \"#a855f7\",\n};\nexport const SQDCP_PILLAR_LABELS: Record<SqdcpPillar, string> = {\n safety: \"Safety\",\n quality: \"Quality\",\n delivery: \"Delivery\",\n cost: \"Cost\",\n people: \"People\",\n};\n","/**\n * Metering + cost-control primitives for the AI layer.\n *\n * Every LLM call funnels through `createLLMClient` (see llm.ts), which is the one\n * place we capture token usage and enforce budgets. This module holds the\n * storage-agnostic types and pure helpers; core-server only *emits* usage and\n * *asks* a guard — each app decides where usage lands and how budgets are stored\n * (typically the Business Hub ledger).\n */\n\n/** Per-1k-token pricing used to estimate cost from token counts. */\nexport interface TokenCostRate {\n inputPer1k: number;\n outputPer1k: number;\n}\n\n/**\n * Default rate (USD per 1k tokens). Was priced for gemini-2.5-flash;\n * llm.ts's MODEL moved to gemini-3.5-flash (2.5-flash was deprecated for new\n * API keys) but this rate was NOT re-verified against 3.5-flash's actual\n * published pricing — treat cost figures as stale until confirmed.\n * Override per app via createLLMClient options once real pricing is known.\n */\nexport const DEFAULT_COST_RATE: TokenCostRate = {\n inputPer1k: 0.000075, // $0.075 / 1M input tokens (gemini-2.5-flash rate, UNVERIFIED for 3.5)\n outputPer1k: 0.0003, // $0.30 / 1M output tokens (gemini-2.5-flash rate, UNVERIFIED for 3.5)\n};\n\n/** Per-call context that varies request-to-request (set via InvokeParams.meta). */\nexport interface CallMeta {\n /** Feature/surface label, e.g. \"opi\", \"ai-advisor\", \"insights\". */\n label?: string;\n /** \"marketing\" | \"operational\" | a feature name. */\n mode?: string;\n /** Authenticated user, when known (operational surfaces). */\n user?: { id?: string; role?: string; enterpriseId?: string | number };\n /** Current page/route (page-awareness + per-surface attribution). */\n page?: string;\n}\n\n/** Emitted after every successful call. The metering sink persists this. */\nexport interface UsageEvent extends CallMeta {\n /** App identifier, e.g. \"marketing-site\", \"businesshub\", \"oee\". */\n app: string;\n model: string;\n promptTokens: number;\n completionTokens: number;\n totalTokens: number;\n estCostUsd: number;\n /** True when token counts were estimated (provider returned no usage block). */\n estimated: boolean;\n /** ISO timestamp. */\n at: string;\n}\n\n/** Asked BEFORE a call, so over-budget / disabled traffic never spends. */\nexport interface GuardRequest extends CallMeta {\n app: string;\n /** Rough prompt-token estimate, for budget math before the call. */\n estPromptTokens: number;\n}\n\nexport interface GuardResult {\n allowed: boolean;\n /** User-facing reason when blocked (kill-switch, budget exceeded, rate limit). */\n reason?: string;\n}\n\n/** Thrown by invokeLLM when a guard blocks the call. */\nexport class LLMBudgetError extends Error {\n readonly code = \"LLM_BUDGET_BLOCKED\";\n constructor(reason: string) {\n super(reason);\n this.name = \"LLMBudgetError\";\n }\n}\n\n/** Rough token estimate (~4 chars/token) — fallback when no usage is returned. */\nexport function estimateTokens(text: string): number {\n if (!text) return 0;\n return Math.ceil(text.length / 4);\n}\n\n/** Estimate prompt tokens across a set of message strings. */\nexport function estimateMessageTokens(contents: string[]): number {\n return contents.reduce((sum, c) => sum + estimateTokens(c), 0);\n}\n\n/** Compute estimated USD cost from token counts. */\nexport function computeCostUsd(\n promptTokens: number,\n completionTokens: number,\n rate: TokenCostRate = DEFAULT_COST_RATE\n): number {\n const cost =\n (promptTokens / 1000) * rate.inputPer1k +\n (completionTokens / 1000) * rate.outputPer1k;\n // Round to 6 dp — sub-cent precision without float noise.\n return Math.round(cost * 1e6) / 1e6;\n}\n","/**\n * LLM helper — calls Google's Gemini API via its OpenAI-compatible endpoint.\n *\n * Example:\n * const invokeLLM = createLLMClient(ENV);\n * const result = await invokeLLM({\n * messages: [\n * { role: \"system\", content: \"You are a helpful assistant.\" },\n * { role: \"user\", content: \"Hello, world!\" },\n * ],\n * });\n */\nimport type { CoreEnv } from \"./types\";\nimport {\n type CallMeta,\n type TokenCostRate,\n type UsageEvent,\n type GuardRequest,\n type GuardResult,\n DEFAULT_COST_RATE,\n LLMBudgetError,\n computeCostUsd,\n estimateMessageTokens,\n estimateTokens,\n} from \"./metering\";\n\nexport type Role = \"system\" | \"user\" | \"assistant\" | \"tool\" | \"function\";\n\nexport type TextContent = {\n type: \"text\";\n text: string;\n};\n\nexport type ImageContent = {\n type: \"image_url\";\n image_url: {\n url: string;\n detail?: \"auto\" | \"low\" | \"high\";\n };\n};\n\nexport type FileContent = {\n type: \"file_url\";\n file_url: {\n url: string;\n mime_type?:\n | \"audio/mpeg\"\n | \"audio/wav\"\n | \"application/pdf\"\n | \"audio/mp4\"\n | \"video/mp4\";\n };\n};\n\nexport type MessageContent = string | TextContent | ImageContent | FileContent;\n\nexport type Message = {\n role: Role;\n name?: string;\n tool_call_id?: string;\n content: MessageContent | MessageContent[];\n};\n\nexport type Tool = {\n type: \"function\";\n function: {\n name: string;\n description?: string;\n parameters?: Record<string, unknown>;\n };\n};\n\ntype ToolChoiceExplicit = {\n type: \"function\";\n function: { name: string };\n};\n\nexport type ToolChoice =\n \"none\" | \"auto\" | \"required\" | { name: string } | ToolChoiceExplicit;\n\ntype JsonSchema = {\n name: string;\n schema: Record<string, unknown>;\n strict?: boolean;\n};\n\nexport type ResponseFormat =\n | { type: \"text\" }\n | { type: \"json_object\" }\n | { type: \"json_schema\"; json_schema: JsonSchema };\n\nexport type OutputSchema = {\n name: string;\n schema: Record<string, unknown>;\n strict?: boolean;\n};\n\nexport type InvokeParams = {\n messages: Message[];\n tools?: Tool[];\n toolChoice?: ToolChoice;\n tool_choice?: ToolChoice;\n outputSchema?: OutputSchema;\n output_schema?: OutputSchema;\n responseFormat?: ResponseFormat;\n response_format?: ResponseFormat;\n /** Per-call attribution for metering/guarding (label, mode, user, page). */\n meta?: CallMeta;\n};\n\n/** Optional metering + cost-control hooks, set once per app. */\nexport type LLMClientOptions = {\n /** App identifier recorded on every usage event, e.g. \"marketing-site\". */\n app?: string;\n /** Pricing used to estimate cost from tokens. Defaults to gemini-3.5-flash (rate unverified, see metering.ts). */\n costRate?: TokenCostRate;\n /** Metering sink — called after each successful call. Errors are swallowed. */\n onUsage?: (event: UsageEvent) => void | Promise<void>;\n /** Budget/kill-switch check — called before each call; block = no spend. */\n guard?: (req: GuardRequest) => GuardResult | Promise<GuardResult>;\n};\n\nexport type InvokeResult = {\n id: string;\n object: string;\n created: number;\n model: string;\n choices: Array<{\n index: number;\n message: {\n role: string;\n content: string | null;\n tool_calls?: Array<{\n id: string;\n type: \"function\";\n function: { name: string; arguments: string };\n }>;\n };\n finish_reason: string;\n }>;\n usage?: {\n prompt_tokens: number;\n completion_tokens: number;\n total_tokens: number;\n };\n};\n\nconst ensureArray = (\n value: MessageContent | MessageContent[]\n): MessageContent[] => (Array.isArray(value) ? value : [value]);\n\nconst normalizeContentPart = (\n part: MessageContent\n): TextContent | ImageContent | FileContent => {\n if (typeof part === \"string\") {\n return { type: \"text\", text: part };\n }\n if (part.type === \"text\") return part;\n if (part.type === \"image_url\") return part;\n if (part.type === \"file_url\") return part;\n throw new Error(\"Unsupported message content part\");\n};\n\nconst normalizeMessage = (message: Message) => {\n const { role, name, tool_call_id } = message;\n if (role === \"tool\" || role === \"function\") {\n const content = ensureArray(message.content)\n .map(part => (typeof part === \"string\" ? part : JSON.stringify(part)))\n .join(\"\\n\");\n return { role, name, tool_call_id, content };\n }\n const contentParts = ensureArray(message.content).map(normalizeContentPart);\n if (contentParts.length === 1 && contentParts[0].type === \"text\") {\n return { role, name, content: contentParts[0].text };\n }\n return { role, name, content: contentParts };\n};\n\nconst normalizeToolChoice = (\n toolChoice: ToolChoice | undefined,\n tools: Tool[] | undefined\n): \"none\" | \"auto\" | ToolChoiceExplicit | undefined => {\n if (!toolChoice) return undefined;\n if (toolChoice === \"none\" || toolChoice === \"auto\") return toolChoice;\n if (toolChoice === \"required\") {\n if (!tools || tools.length === 0) {\n throw new Error(\n \"tool_choice 'required' was provided but no tools were configured\"\n );\n }\n if (tools.length > 1) {\n throw new Error(\n \"tool_choice 'required' needs a single tool or specify the tool name explicitly\"\n );\n }\n return { type: \"function\", function: { name: tools[0].function.name } };\n }\n if (\"name\" in toolChoice) {\n return { type: \"function\", function: { name: toolChoice.name } };\n }\n return toolChoice;\n};\n\nconst normalizeResponseFormat = ({\n responseFormat,\n response_format,\n outputSchema,\n output_schema,\n}: {\n responseFormat?: ResponseFormat;\n response_format?: ResponseFormat;\n outputSchema?: OutputSchema;\n output_schema?: OutputSchema;\n}):\n | { type: \"json_schema\"; json_schema: JsonSchema }\n | { type: \"text\" }\n | { type: \"json_object\" }\n | undefined => {\n const explicitFormat = responseFormat || response_format;\n if (explicitFormat) {\n if (\n explicitFormat.type === \"json_schema\" &&\n !explicitFormat.json_schema?.schema\n ) {\n throw new Error(\n \"responseFormat json_schema requires a defined schema object\"\n );\n }\n return explicitFormat;\n }\n const schema = outputSchema || output_schema;\n if (!schema) return undefined;\n if (!schema.name || !schema.schema) {\n throw new Error(\"outputSchema requires both name and schema\");\n }\n return {\n type: \"json_schema\",\n json_schema: {\n name: schema.name,\n schema: schema.schema,\n ...(typeof schema.strict === \"boolean\" ? { strict: schema.strict } : {}),\n },\n };\n};\n\nconst MODEL = \"gemini-3.5-flash\";\n\n/** Flatten a message's content to plain text for token estimation. */\nfunction messageToText(content: unknown): string {\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n return content\n .map(part => {\n if (typeof part === \"string\") return part;\n if (part && typeof part === \"object\" && \"text\" in part) {\n return String((part as { text?: unknown }).text ?? \"\");\n }\n return \"\";\n })\n .join(\" \");\n }\n return \"\";\n}\n\nexport function createLLMClient(env: CoreEnv, options: LLMClientOptions = {}) {\n const {\n app = \"unknown\",\n costRate = DEFAULT_COST_RATE,\n onUsage,\n guard,\n } = options;\n\n const resolveApiUrl = () =>\n env.geminiApiUrl && env.geminiApiUrl.trim().length > 0\n ? `${env.geminiApiUrl.replace(/\\/$/, \"\")}/chat/completions`\n : \"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions\";\n\n const assertApiKey = () => {\n if (!env.geminiApiKey) {\n throw new Error(\"GEMINI_API_KEY is not configured\");\n }\n };\n\n return async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {\n assertApiKey();\n\n const {\n messages,\n tools,\n toolChoice,\n tool_choice,\n outputSchema,\n output_schema,\n responseFormat,\n response_format,\n meta = {},\n } = params;\n\n const estPromptTokens = estimateMessageTokens(\n messages.map(m => messageToText(m.content))\n );\n\n // ── Cost control: ask the guard before spending anything ──\n if (guard) {\n const verdict = await guard({ app, estPromptTokens, ...meta });\n if (!verdict.allowed) {\n throw new LLMBudgetError(\n verdict.reason ?? \"AI is temporarily unavailable.\"\n );\n }\n }\n\n const payload: Record<string, unknown> = {\n model: MODEL,\n messages: messages.map(normalizeMessage),\n };\n\n if (tools && tools.length > 0) {\n payload.tools = tools;\n }\n\n const normalizedToolChoice = normalizeToolChoice(\n toolChoice || tool_choice,\n tools\n );\n if (normalizedToolChoice) {\n payload.tool_choice = normalizedToolChoice;\n }\n\n payload.max_tokens = 32768;\n\n const normalizedResponseFormat = normalizeResponseFormat({\n responseFormat,\n response_format,\n outputSchema,\n output_schema,\n });\n if (normalizedResponseFormat) {\n payload.response_format = normalizedResponseFormat;\n }\n\n const response = await fetch(resolveApiUrl(), {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${env.geminiApiKey}`,\n },\n body: JSON.stringify(payload),\n });\n\n if (!response.ok) {\n const errorText = await response.text();\n throw new Error(\n `LLM invoke failed: ${response.status} ${response.statusText} – ${errorText}`\n );\n }\n\n const result = (await response.json()) as InvokeResult;\n\n // ── Metering: capture usage and emit to the sink (never breaks the call) ──\n if (onUsage) {\n try {\n const provided = result.usage;\n const estimated = !provided;\n const promptTokens = provided?.prompt_tokens ?? estPromptTokens;\n const completionTokens =\n provided?.completion_tokens ??\n estimateTokens(messageToText(result.choices?.[0]?.message?.content));\n const totalTokens =\n provided?.total_tokens ?? promptTokens + completionTokens;\n\n const event: UsageEvent = {\n app,\n model: result.model ?? MODEL,\n promptTokens,\n completionTokens,\n totalTokens,\n estCostUsd: computeCostUsd(promptTokens, completionTokens, costRate),\n estimated,\n at: new Date().toISOString(),\n ...meta,\n };\n\n await onUsage(event);\n } catch (err) {\n // Metering must never take down a working chat.\n console.warn(\"[core-server] onUsage sink failed:\", err);\n }\n }\n\n return result;\n };\n}\n","/**\n * Personas for the shared Oplytics AI assistant.\n *\n * One brain, two voices: the same knowledge base is wrapped in a customer-facing\n * persona (\"Opi\" on the marketing site) or an operational persona (the in-app\n * \"AI Support Engineer\" in the portal / subdomains). The mode selects the overlay.\n */\n\nexport type AssistantMode = \"marketing\" | \"operational\";\n\n/** Identity + tone shared by every surface. */\nconst BASE_PERSONA = `\nYou are the Oplytics AI assistant — a knowledgeable, friendly guide to the Oplytics operational-excellence platform.\nYour personality is warm, approachable, and genuinely helpful, while staying clear, professional, and competent — especially on operational safety and platform technicals.\n\nShared guidelines:\n- Use a warm, professional tone: clear, concise, and easy to follow. Avoid heavy slang, forced catchphrases, or over-familiarity — be friendly without overdoing it.\n- For anything safety- or incident-related, stay serious and accurate while keeping the warm tone — prioritise accuracy over speed.\n- If you don't know something, be honest and guide the user to the right place rather than inventing an answer.\n- Prefer the supplied Oplytics knowledge below over your own assumptions. If the knowledge doesn't cover it, say so plainly.\n`.trim();\n\n/**\n * Real, live Oplytics pages, injected into every persona so Opi can cite an\n * actual URL instead of inventing one. Copied word for word from Portal's\n * OPLYTICS_FACTS (oplytics-portal server/routers/ai.ts, portal#259) so the two\n * Opis give the same answer. UPDATE THIS LIST whenever a public page is added\n * to or removed from the marketing site (routes: marketing-site\n * client/src/App.tsx), and keep Portal's copy in step.\n */\nexport const OPLYTICS_VERIFIED_LINKS = `\nReal Oplytics links. Only cite links from this list. Never invent, guess or modify a URL.\n- Home / product overview: https://oplytics.digital\n- Pricing: https://oplytics.digital/pricing\n- Contact / book a demo: https://oplytics.digital/contact\n- About: https://oplytics.digital/about\n- Opi and the AI platform: https://oplytics.digital/ai\n- The Obeya Room: https://oplytics.digital/obeya\n- Why Oplytics: https://oplytics.digital/why-us\n- Resources: https://oplytics.digital/resources\n- Terms of Service: https://oplytics.digital/terms\n- Privacy Policy: https://oplytics.digital/privacy\n- Cookie Policy: https://oplytics.digital/cookies\n- Acceptable Use Policy: https://oplytics.digital/aup\n- Data Processing Agreement (DPA): https://oplytics.digital/dpa\n- Service Level Agreement (SLA): https://oplytics.digital/sla\nIf asked for a page that is not in this list (for example careers), say there is no link for it and describe where to look instead, such as the Contact page or the relevant area of the app. Do not make up a link.\n`.trim();\n\n/** Customer-facing overlay — Opi on the public marketing site. */\nconst MARKETING_OVERLAY = `\nYou are \"Opi\", talking to a prospective customer on the public Oplytics marketing site.\n- Focus on features, benefits, outcomes, and helping them book a demo or get in touch.\n- Be persuasive but never pushy or salesy; lead with how Oplytics solves their problem.\n- Never discuss competitor products by name.\n- For specific pricing, direct the user to the Contact page (https://oplytics.digital/contact) rather than quoting figures.\n- You do not have access to any account or operational data — you are pre-sales only.\n`.trim();\n\n/** Authenticated overlay — the in-app AI Support Engineer. */\nconst OPERATIONAL_OVERLAY = `\nYou are the Oplytics AI Support Engineer, supporting a verified, logged-in user inside the platform.\n- Tailor responses to their role and enterprise context (supplied below when available).\n- Help with deep operational support: platform setup, training, troubleshooting, data interpretation, and specific tasks.\n- For safety-related queries, always prioritise accuracy and cite the correct workflow.\n- You may reference platform features and the user's context, but never expose data the user shouldn't see.\n`.trim();\n\nconst OVERLAYS: Record<AssistantMode, string> = {\n marketing: MARKETING_OVERLAY,\n operational: OPERATIONAL_OVERLAY,\n};\n\nexport interface BuildSystemPromptParams {\n mode: AssistantMode;\n /** Pre-formatted knowledge block (see formatKnowledge) injected into the prompt. */\n knowledge?: string;\n /** Free-form runtime context (current page, user/role, enterprise, etc.). */\n context?: string;\n}\n\n/** Compose the full system prompt: base identity + mode overlay + verified links + knowledge + context. */\nexport function buildSystemPrompt({\n mode,\n knowledge,\n context,\n}: BuildSystemPromptParams): string {\n const sections = [BASE_PERSONA, OVERLAYS[mode], OPLYTICS_VERIFIED_LINKS];\n\n if (knowledge && knowledge.trim().length > 0) {\n sections.push(`Relevant Oplytics knowledge:\\n${knowledge.trim()}`);\n }\n\n if (context && context.trim().length > 0) {\n sections.push(`Current context:\\n${context.trim()}`);\n }\n\n return sections.join(\"\\n\\n\");\n}\n","/**\n * Shared Oplytics knowledge base + a RAG-ready retrieval interface.\n *\n * Phase 2a ships the knowledge as version-controlled data injected into the\n * system prompt, behind a `Retriever` interface. Phase 2b can swap the static\n * retriever for a vector-store-backed one WITHOUT changing any consumer — the\n * signature is the contract.\n *\n * This is a deliberately small seed. Expand `KNOWLEDGE` over time (it's the one\n * source of truth shared by Opi and the in-app AI Support Engineer).\n */\nimport type { AssistantMode } from \"./personas\";\n\nexport interface KnowledgeItem {\n id: string;\n question: string;\n answer: string;\n /** Lowercase keywords/tags used by the static retriever's scoring. */\n tags: string[];\n /** If set, this item is only surfaced in the listed modes. Default: both. */\n modes?: AssistantMode[];\n}\n\nexport interface RetrieveOptions {\n mode: AssistantMode;\n /** Max items to return. Default 4. */\n limit?: number;\n}\n\n/**\n * A retriever maps a user query to the most relevant knowledge items.\n * Async so a future vector/embedding implementation can drop in unchanged.\n */\nexport type Retriever = (\n query: string,\n options: RetrieveOptions\n) => Promise<KnowledgeItem[]> | KnowledgeItem[];\n\n/** Seed knowledge. Sourced from the Oplytics solution set; extend freely. */\nexport const KNOWLEDGE: KnowledgeItem[] = [\n // ── Platform ──────────────────────────────────────────────────────────────\n {\n id: \"platform-overview\",\n question: \"What does Oplytics do?\",\n answer:\n \"Oplytics is an integrated operational-excellence platform for manufacturers. Its modules share data, context, and AI insights so individual tools compound into one advantage instead of working in silos. Four services are live today (OEE Manager, the SQDCP Dashboard, Policy Deployment, and Action Manager), with more on the way.\",\n tags: [\n \"platform\",\n \"overview\",\n \"what\",\n \"oplytics\",\n \"modules\",\n \"manufacturing\",\n ],\n },\n {\n id: \"who-for\",\n question: \"Who is Oplytics for?\",\n answer:\n \"Manufacturers and factory operations teams running continuous improvement, lean, and daily management — from the shop floor to senior leadership. It suits anyone replacing whiteboards, spreadsheets, and disconnected point tools with one connected system.\",\n tags: [\n \"who\",\n \"for\",\n \"audience\",\n \"manufacturer\",\n \"factory\",\n \"lean\",\n \"operations\",\n ],\n },\n {\n id: \"live-services\",\n question: \"Which services are available right now?\",\n answer:\n \"Live today: OEE Manager, the SQDCP Dashboard, Policy Deployment, and Action Manager. In development and coming soon: OplyticsConnect, Quality Manager, Safety Manager, and Certification Manager — existing customers get early access and preferential pricing when they launch.\",\n tags: [\n \"live\",\n \"available\",\n \"services\",\n \"roadmap\",\n \"coming\",\n \"soon\",\n \"status\",\n ],\n },\n {\n id: \"integrated-platform\",\n question: \"Why one platform instead of separate tools?\",\n answer:\n \"Every module shares the same data and context, so an action raised on a tier board, a loss in OEE, and a strategic objective all connect. That shared backbone is what lets the AI surface patterns no single tool could see alone.\",\n tags: [\"integrated\", \"platform\", \"connected\", \"modules\", \"data\", \"why\"],\n },\n {\n id: \"methodologies\",\n question: \"What improvement methodologies does it support?\",\n answer:\n \"Best-practice CI is built into the workflows: Lean, Kaizen, and PDCA throughout, plus Hoshin Kanri (with X-matrix and catchball) for strategy deployment and the SQDCP framework for daily management.\",\n tags: [\n \"lean\",\n \"kaizen\",\n \"pdca\",\n \"hoshin\",\n \"methodology\",\n \"ci\",\n \"continuous\",\n \"improvement\",\n ],\n },\n {\n id: \"mobile-desktop\",\n question: \"Can we use it on mobile?\",\n answer:\n \"Yes — Oplytics works on desktop and mobile, so boards, actions, and reports are accessible from any device, on the floor or off-site.\",\n tags: [\"mobile\", \"desktop\", \"device\", \"tablet\", \"phone\", \"access\"],\n },\n // ── OEE Manager (live) ─────────────────────────────────────────────────────\n {\n id: \"oee-manager\",\n question: \"How does OEE Manager work?\",\n answer:\n \"OEE Manager tracks Overall Equipment Effectiveness — availability, performance, and quality — in real time across every machine and line. It categorises downtime and speed losses, builds Pareto/loss analysis, and trends OEE by shift, line, product, and operator so teams target the biggest losses first.\",\n tags: [\n \"oee\",\n \"manager\",\n \"downtime\",\n \"loss\",\n \"availability\",\n \"performance\",\n \"quality\",\n \"tracking\",\n \"equipment\",\n ],\n },\n {\n id: \"oee-data-collection\",\n question: \"Does OEE data have to be entered by hand?\",\n answer:\n \"No — OEE Manager can connect directly to PLCs and sensors via SmartConnect for automated data collection, eliminating manual-entry errors. Manual entry is still available where automation isn't in place.\",\n tags: [\n \"oee\",\n \"data\",\n \"collection\",\n \"automated\",\n \"smartconnect\",\n \"plc\",\n \"sensor\",\n \"manual\",\n ],\n },\n {\n id: \"oee-targets-handover\",\n question: \"Can we set OEE targets and run shift handovers?\",\n answer:\n \"Yes. Set OEE targets by line, product, and shift with live visual indicators against target, and generate digital shift-handover reports with OEE summaries, open actions, and key events from the previous shift.\",\n tags: [\"oee\", \"target\", \"shift\", \"handover\", \"report\", \"goal\"],\n },\n // ── SQDCP Dashboard (live) ─────────────────────────────────────────────────\n {\n id: \"sqdcp\",\n question: \"What is the SQDCP Dashboard?\",\n answer:\n \"SQDCP replaces physical whiteboards with real-time digital tier boards covering Safety, Quality, Delivery, Cost, and People. Boards are accessible from any device and keep daily management consistent across shifts and sites.\",\n tags: [\n \"sqdcp\",\n \"tier\",\n \"board\",\n \"dashboard\",\n \"daily\",\n \"management\",\n \"whiteboard\",\n ],\n },\n {\n id: \"sqdcp-pillars\",\n question: \"What does SQDCP track?\",\n answer:\n \"The five pillars: Safety (incidents, near-misses, observations), Quality (first-pass yield, scrap, complaints), Delivery (on-time delivery, schedule adherence), Cost (operational cost and waste), and People (attendance, training, skills).\",\n tags: [\n \"sqdcp\",\n \"safety\",\n \"quality\",\n \"delivery\",\n \"cost\",\n \"people\",\n \"pillars\",\n \"metrics\",\n ],\n },\n // ── Policy Deployment (live) ───────────────────────────────────────────────\n {\n id: \"policy-deployment\",\n question: \"What is Policy Deployment?\",\n answer:\n \"Policy Deployment cascades strategy through every level using Hoshin Kanri: define 3-5 year breakthrough objectives and annual priorities, link them in a visual X-matrix, deploy via the catchball process, and review progress on a monthly/quarterly cadence with bowling charts.\",\n tags: [\n \"policy\",\n \"deployment\",\n \"hoshin\",\n \"kanri\",\n \"strategy\",\n \"xmatrix\",\n \"catchball\",\n \"objectives\",\n ],\n },\n {\n id: \"catchball\",\n question: \"What is the catchball process?\",\n answer:\n \"Catchball is how objectives are aligned top-down and bottom-up: leadership proposes goals, teams feed back what's achievable, and the two iterate until aligned. Oplytics facilitates this digitally so strategy doesn't get lost between management layers.\",\n tags: [\n \"catchball\",\n \"hoshin\",\n \"alignment\",\n \"objectives\",\n \"policy\",\n \"deployment\",\n ],\n },\n // ── Action Manager (live) ──────────────────────────────────────────────────\n {\n id: \"action-manager\",\n question: \"What is Action Manager?\",\n answer:\n \"Action Manager is one unified action register: capture actions from any source (audits, incidents, meetings, tier boards), assign them with owners and due dates, auto-escalate overdue items, and track status from open to verified closure. Analytics show completion rates, ageing, and source breakdown.\",\n tags: [\n \"action\",\n \"manager\",\n \"tasks\",\n \"improvement\",\n \"ci\",\n \"register\",\n \"escalation\",\n \"closure\",\n ],\n },\n // ── Coming soon ────────────────────────────────────────────────────────────\n {\n id: \"safety-manager\",\n question: \"Do you have a Safety Manager?\",\n answer:\n \"Safety Manager is coming soon. It will cover mobile-first incident reporting (with photos and witness statements), hazard identification and risk assessment, behavioural safety observations, and a compliance dashboard. Existing customers get early access at launch.\",\n tags: [\n \"safety\",\n \"manager\",\n \"incident\",\n \"hazard\",\n \"risk\",\n \"observation\",\n \"compliance\",\n \"coming\",\n ],\n },\n {\n id: \"quality-manager\",\n question: \"Do you have a Quality Manager?\",\n answer:\n \"Quality Manager is coming soon. It will handle non-conformance tracking from detection to disposition, a structured CAPA workflow with root-cause tools, internal/external audit scheduling, and quality metrics (first-pass yield, defect rates, cost of quality). Existing customers get early access at launch.\",\n tags: [\n \"quality\",\n \"manager\",\n \"ncr\",\n \"capa\",\n \"non-conformance\",\n \"audit\",\n \"coming\",\n ],\n },\n {\n id: \"certification-manager\",\n question: \"Can it help with ISO certification?\",\n answer:\n \"Certification Manager is coming soon. It will manage standards like ISO 9001, ISO 14001, and IATF 16949 in one place, with version-controlled document control, a complete audit trail, and compliance/gap tracking. Existing customers get early access at launch.\",\n tags: [\n \"certification\",\n \"iso\",\n \"9001\",\n \"14001\",\n \"iatf\",\n \"16949\",\n \"standards\",\n \"document\",\n \"audit\",\n \"coming\",\n ],\n },\n {\n id: \"connect-integrations\",\n question: \"Can Oplytics connect to our machines and systems?\",\n answer:\n \"Live OEE data already flows in via SmartConnect (PLCs and sensors). The standalone OplyticsConnect service is coming soon and adds broader machine connectivity — PLCs, SCADA, and industrial sensors over OPC-UA, MQTT, Modbus and more — with zero-code configuration and edge processing.\",\n tags: [\n \"connect\",\n \"integration\",\n \"machine\",\n \"plc\",\n \"scada\",\n \"sensor\",\n \"opcua\",\n \"mqtt\",\n \"modbus\",\n \"iot\",\n \"smartconnect\",\n ],\n },\n {\n id: \"erp-integration\",\n question: \"Can it integrate with our ERP or other systems?\",\n answer:\n \"Oplytics focuses on shop-floor connectivity (machines, PLCs, sensors) today. For ERP or other business-system integrations, the best step is to get in touch via /contact so the team can scope your specific setup.\",\n tags: [\"erp\", \"integration\", \"system\", \"api\", \"data\", \"contact\"],\n },\n {\n id: \"protocols\",\n question: \"Which industrial protocols are supported?\",\n answer:\n \"Machine connectivity supports common industrial protocols including OPC-UA, MQTT, and Modbus, connecting to PLCs, SCADA systems, and sensors. Configuration is visual and zero-code, with edge processing and local buffering so no data is lost during connectivity drops.\",\n tags: [\n \"protocol\",\n \"opcua\",\n \"mqtt\",\n \"modbus\",\n \"plc\",\n \"scada\",\n \"edge\",\n \"connectivity\",\n ],\n },\n // ── AI ─────────────────────────────────────────────────────────────────────\n {\n id: \"integrated-ai\",\n question: \"How does the AI help?\",\n answer:\n \"AI runs across every module — surfacing patterns, predicting issues, and recommending actions no single tool could spot alone — and the assistant helps users navigate, answers questions, and suggests next steps.\",\n tags: [\n \"ai\",\n \"insights\",\n \"predict\",\n \"assistant\",\n \"recommend\",\n \"intelligence\",\n ],\n },\n {\n id: \"ai-assistant\",\n question: \"Who are you / what can you help with?\",\n answer:\n \"I'm Opi, the Oplytics AI guide. I can explain what the platform and each module do, point you to the right place, and help you book a demo or get in touch.\",\n tags: [\"opi\", \"assistant\", \"help\", \"who\", \"guide\", \"bot\"],\n },\n // ── Pricing, demos, getting started (marketing) ────────────────────────────\n {\n id: \"pricing\",\n question: \"How much does it cost?\",\n answer:\n \"Pricing is tailored to your sites and the modules you use, so figures aren't published. The best next step is to get in touch via the contact page (/contact) and the team will put together a quote.\",\n tags: [\"pricing\", \"price\", \"cost\", \"quote\", \"how\", \"much\", \"plan\"],\n modes: [\"marketing\"],\n },\n {\n id: \"plans\",\n question: \"What plans are available?\",\n answer:\n \"There are tiers to suit different stages — an Early Beta Partner programme, Starter, Professional, and Enterprise — covering different usage limits and capabilities like custom integrations and SLAs. All four live services are included; reach out via /contact to find the right fit.\",\n tags: [\n \"plan\",\n \"tier\",\n \"starter\",\n \"professional\",\n \"enterprise\",\n \"beta\",\n \"package\",\n ],\n modes: [\"marketing\"],\n },\n {\n id: \"beta\",\n question: \"Is there a beta or early-adopter option?\",\n answer:\n \"Yes — the Early Beta Partner programme is for teams who want to get started early and shape the platform. Get in touch via /contact to learn more.\",\n tags: [\"beta\", \"early\", \"partner\", \"adopter\", \"trial\", \"pilot\"],\n modes: [\"marketing\"],\n },\n {\n id: \"demo\",\n question: \"Can I see a demo?\",\n answer:\n \"Absolutely — you can book a demo or try the live module demos. Head to the contact page (/contact) to get set up.\",\n tags: [\"demo\", \"trial\", \"try\", \"book\", \"see\", \"show\"],\n modes: [\"marketing\"],\n },\n {\n id: \"contact\",\n question: \"How do I get in touch?\",\n answer:\n \"Head to the contact page at /contact — tell the team a little about your operation and they'll take it from there, whether that's a demo, a quote, or answering questions.\",\n tags: [\"contact\", \"touch\", \"reach\", \"sales\", \"talk\", \"email\"],\n modes: [\"marketing\"],\n },\n {\n id: \"getting-started\",\n question: \"How do we get started?\",\n answer:\n \"Start with a conversation: get in touch via /contact for a demo and onboarding. The team will help you stand up the live services for your sites and connect your data.\",\n tags: [\"start\", \"started\", \"onboarding\", \"begin\", \"setup\", \"trial\"],\n },\n];\n\n/** Lowercase word tokens, length >= 3. */\nfunction tokenize(text: string): string[] {\n return (text.toLowerCase().match(/[a-z0-9]+/g) ?? []).filter(\n w => w.length >= 3\n );\n}\n\n/** Score an item against the query tokens (tag hits weighted over text hits). */\nfunction scoreItem(item: KnowledgeItem, queryTokens: string[]): number {\n if (queryTokens.length === 0) return 0;\n const haystack = tokenize(`${item.question} ${item.answer}`);\n let score = 0;\n for (const token of queryTokens) {\n if (item.tags.includes(token)) score += 3;\n if (haystack.includes(token)) score += 1;\n }\n return score;\n}\n\n/**\n * Build a static, keyword-scored retriever over the supplied knowledge.\n * Drop-in replaceable by a vector retriever implementing the same `Retriever`.\n */\nexport function createStaticRetriever(\n knowledge: KnowledgeItem[] = KNOWLEDGE\n): Retriever {\n return (query, { mode, limit = 4 }) => {\n const queryTokens = tokenize(query);\n const eligible = knowledge.filter(k => !k.modes || k.modes.includes(mode));\n\n const scored = eligible\n .map(item => ({ item, score: scoreItem(item, queryTokens) }))\n .filter(s => s.score > 0)\n .sort((a, b) => b.score - a.score)\n .slice(0, limit)\n .map(s => s.item);\n\n // Cold-start / no keyword hits: fall back to the first few eligible items\n // so the assistant always has some grounding to work from.\n if (scored.length === 0) return eligible.slice(0, limit);\n return scored;\n };\n}\n\n/** Render retrieved items into a compact block for the system prompt. */\nexport function formatKnowledge(items: KnowledgeItem[]): string {\n return items.map(i => `Q: ${i.question}\\nA: ${i.answer}`).join(\"\\n\\n\");\n}\n","/**\n * createSupportEngine — the shared chat brain for Opi (marketing) and the in-app\n * AI Support Engineer (operational). Composes persona + retrieved knowledge +\n * runtime context, then calls the existing LLM client.\n *\n * const engine = createSupportEngine(ENV);\n * const { content } = await engine.chat({ mode: \"marketing\", messages });\n */\nimport type { CoreEnv } from \"../types\";\nimport { createLLMClient } from \"../llm\";\nimport type { Message, LLMClientOptions } from \"../llm\";\nimport { buildSystemPrompt, type AssistantMode } from \"./personas\";\nimport {\n createStaticRetriever,\n formatKnowledge,\n type Retriever,\n} from \"./knowledge\";\n\n/** Lightweight, mode-agnostic chat message (system prompt is added internally). */\nexport interface ChatMessage {\n role: \"user\" | \"assistant\";\n content: string;\n}\n\n/** Optional runtime context to ground the assistant. */\nexport interface SupportContext {\n /** Current page / route the user is on (page-awareness). */\n page?: string;\n /** Authenticated user details (operational mode). */\n user?: { name?: string; role?: string; enterpriseId?: string | number };\n /** Any extra free-form context to append. */\n extra?: string;\n}\n\nexport interface ChatParams {\n mode: AssistantMode;\n messages: ChatMessage[];\n context?: SupportContext;\n}\n\nexport interface ChatResult {\n content: string;\n}\n\nexport interface SupportEngineOptions {\n /**\n * Knowledge retriever. Defaults to the bundled static keyword retriever.\n * Swap for a vector-store retriever later with zero consumer changes.\n */\n retrieve?: Retriever;\n /** Items to retrieve per turn. Default 4. */\n knowledgeLimit?: number;\n /** Metering + cost-control hooks (app, onUsage sink, budget guard, costRate). */\n metering?: LLMClientOptions;\n}\n\nfunction renderContext(mode: AssistantMode, context?: SupportContext): string {\n const lines: string[] = [\n `Mode: ${mode === \"marketing\" ? \"Public Marketing Site\" : \"Authenticated Portal\"}`,\n ];\n if (context?.page) lines.push(`Page: ${context.page}`);\n if (context?.user?.name) lines.push(`User: ${context.user.name}`);\n if (context?.user?.role) lines.push(`Role: ${context.user.role}`);\n if (context?.user?.enterpriseId != null)\n lines.push(`Enterprise ID: ${context.user.enterpriseId}`);\n if (context?.extra) lines.push(context.extra);\n return lines.join(\"\\n\");\n}\n\nexport function createSupportEngine(\n env: CoreEnv,\n options: SupportEngineOptions = {}\n) {\n const invokeLLM = createLLMClient(env, options.metering);\n const retrieve = options.retrieve ?? createStaticRetriever();\n const knowledgeLimit = options.knowledgeLimit ?? 4;\n\n async function chat({\n mode,\n messages,\n context,\n }: ChatParams): Promise<ChatResult> {\n const lastUser = [...messages].reverse().find(m => m.role === \"user\");\n const query = lastUser?.content ?? \"\";\n\n const items = await retrieve(query, { mode, limit: knowledgeLimit });\n const knowledge = formatKnowledge(items);\n\n const systemPrompt = buildSystemPrompt({\n mode,\n knowledge,\n context: renderContext(mode, context),\n });\n\n const llmMessages: Message[] = [\n { role: \"system\", content: systemPrompt },\n ...messages.map(m => ({ role: m.role, content: m.content })),\n ];\n\n const result = await invokeLLM({\n messages: llmMessages,\n meta: {\n label: \"support-engine\",\n mode,\n page: context?.page,\n user: context?.user\n ? { role: context.user.role, enterpriseId: context.user.enterpriseId }\n : undefined,\n },\n });\n const raw = result.choices[0]?.message?.content;\n const content = typeof raw === \"string\" ? raw : JSON.stringify(raw ?? \"\");\n\n return { content };\n }\n\n return { chat };\n}\n\nexport type SupportEngine = ReturnType<typeof createSupportEngine>;\n","/**\n * Security response headers for every Oplytics Express app.\n *\n * Set once, at the top of the middleware chain (before any route), so every\n * response — API, SPA shell, static asset, redirect — carries them. Replaces\n * the \"no Helmet, no CSP, no HSTS at the app layer\" gap flagged in the\n * 2026-08 ISO 27001 gap analysis (RISK-09). Cloudflare sits in front but was\n * verified to inject none of these.\n *\n * Deliberately dependency-free — Helmet for a fixed set of static headers is\n * just `res.setHeader` calls, and core-server already keeps its dependency\n * surface at zero.\n *\n * The Content-Security-Policy is sent **report-only** by default: browsers\n * evaluate it and report violations but block nothing, so a too-tight policy\n * can't take the SPA down. Once the report stream is clean for a given app,\n * pass `cspMode: \"enforce\"` to switch it to a blocking policy.\n */\nimport type { Request, Response, NextFunction } from \"express\";\n\nexport interface SecurityHeadersOptions {\n /**\n * Extra origins the app's browser code legitimately talks to, beyond the\n * platform defaults (self, the portal API host, Google Fonts). Used for\n * `connect-src` / `img-src` / `script-src` as appropriate — e.g. an\n * analytics endpoint or a CDN. Origins only (`https://host[:port]`).\n */\n extraConnectSrc?: string[];\n extraImgSrc?: string[];\n extraScriptSrc?: string[];\n extraStyleSrc?: string[];\n /**\n * \"report-only\" (default) sends `Content-Security-Policy-Report-Only`;\n * \"enforce\" sends a blocking `Content-Security-Policy`; \"off\" omits CSP\n * entirely (the other headers still apply).\n */\n cspMode?: \"report-only\" | \"enforce\" | \"off\";\n /**\n * HSTS max-age in seconds. Default 180 days. Set 0 to omit the header\n * (e.g. while still testing HTTPS). `includeSubDomains` is always set when\n * the header is present; `preload` is opt-in via `hstsPreload`.\n */\n hstsMaxAge?: number;\n hstsPreload?: boolean;\n}\n\nconst DAY = 86_400;\n\n/** Fonts + the shared portal API origin every app in the platform uses. */\nconst GOOGLE_FONTS_CSS = \"https://fonts.googleapis.com\";\nconst GOOGLE_FONTS_FILES = \"https://fonts.gstatic.com\";\n\nfunction buildCsp(\n opts: SecurityHeadersOptions,\n cspMode: \"report-only\" | \"enforce\"\n): string {\n const connect = [\n \"'self'\",\n \"https://portal.oplyticsdigital.net\",\n ...(opts.extraConnectSrc ?? []),\n ];\n const img = [\"'self'\", \"data:\", \"blob:\", ...(opts.extraImgSrc ?? [])];\n const script = [\"'self'\", ...(opts.extraScriptSrc ?? [])];\n // Vite's runtime and several UI libs inject <style> tags at runtime, so\n // style-src needs 'unsafe-inline'. This is low-risk (style injection can't\n // exfiltrate) and is the standard trade-off for a bundled SPA.\n const style = [\n \"'self'\",\n \"'unsafe-inline'\",\n GOOGLE_FONTS_CSS,\n ...(opts.extraStyleSrc ?? []),\n ];\n const font = [\"'self'\", GOOGLE_FONTS_FILES, \"data:\"];\n\n const directives = [\n \"default-src 'self'\",\n `connect-src ${connect.join(\" \")}`,\n `img-src ${img.join(\" \")}`,\n `script-src ${script.join(\" \")}`,\n `style-src ${style.join(\" \")}`,\n `font-src ${font.join(\" \")}`,\n \"object-src 'none'\",\n \"base-uri 'self'\",\n \"frame-ancestors 'none'\",\n \"form-action 'self'\",\n ];\n // upgrade-insecure-requests is an ENFORCEMENT directive — per spec it has\n // no effect when the policy is delivered report-only, and Chrome logs a\n // console warning (\"...is ignored when delivered in a report-only\n // policy\") on every single page load telling you exactly that. Fleet-wide\n // since RISK-09 shipped, because every app currently runs the report-only\n // default — caught via the nightly E2E's console-error assertion\n // (oplytics-subdomains Action Manager, 2026-09-10). Only include it once\n // the policy actually enforces.\n if (cspMode === \"enforce\") directives.push(\"upgrade-insecure-requests\");\n\n return directives.join(\"; \");\n}\n\n/**\n * Express middleware that stamps the platform security headers on every\n * response. Mount it before the body parser:\n *\n * const app = express();\n * app.use(securityHeaders({ extraScriptSrc: [ANALYTICS_ORIGIN] }));\n * app.use(express.json());\n */\nexport function securityHeaders(opts: SecurityHeadersOptions = {}) {\n const cspMode = opts.cspMode ?? \"report-only\";\n const hstsMaxAge = opts.hstsMaxAge ?? 180 * DAY;\n const csp = cspMode === \"off\" ? null : buildCsp(opts, cspMode);\n const cspHeader =\n cspMode === \"enforce\"\n ? \"Content-Security-Policy\"\n : \"Content-Security-Policy-Report-Only\";\n\n return function securityHeadersMiddleware(\n _req: Request,\n res: Response,\n next: NextFunction\n ): void {\n // Express advertises itself by default — remove the fingerprint.\n res.removeHeader(\"X-Powered-By\");\n\n res.setHeader(\"X-Content-Type-Options\", \"nosniff\");\n res.setHeader(\"X-Frame-Options\", \"DENY\");\n res.setHeader(\"Referrer-Policy\", \"strict-origin-when-cross-origin\");\n res.setHeader(\"X-Permitted-Cross-Domain-Policies\", \"none\");\n res.setHeader(\"Cross-Origin-Opener-Policy\", \"same-origin\");\n res.setHeader(\"Cross-Origin-Resource-Policy\", \"same-site\");\n res.setHeader(\n \"Permissions-Policy\",\n \"camera=(), microphone=(), geolocation=(), payment=()\"\n );\n\n if (hstsMaxAge > 0) {\n const preload = opts.hstsPreload ? \"; preload\" : \"\";\n res.setHeader(\n \"Strict-Transport-Security\",\n `max-age=${hstsMaxAge}; includeSubDomains${preload}`\n );\n }\n\n if (csp) res.setHeader(cspHeader, csp);\n\n next();\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/constants/sqdcp.ts","../src/metering.ts","../src/llm.ts","../src/ai/personas.ts","../src/ai/knowledge.ts","../src/ai/supportEngine.ts","../src/securityHeaders.ts"],"sourcesContent":["export const SQDCP_PILLARS = [\n \"safety\",\n \"quality\",\n \"delivery\",\n \"cost\",\n \"people\",\n] as const;\nexport type SqdcpPillar = (typeof SQDCP_PILLARS)[number];\nexport const SQDCP_PILLAR_COLORS: Record<SqdcpPillar, string> = {\n safety: \"#ef4444\",\n quality: \"#3b82f6\",\n delivery: \"#f59e0b\",\n cost: \"#10b981\",\n people: \"#a855f7\",\n};\nexport const SQDCP_PILLAR_LABELS: Record<SqdcpPillar, string> = {\n safety: \"Safety\",\n quality: \"Quality\",\n delivery: \"Delivery\",\n cost: \"Cost\",\n people: \"People\",\n};\n","/**\n * Metering + cost-control primitives for the AI layer.\n *\n * Every LLM call funnels through `createLLMClient` (see llm.ts), which is the one\n * place we capture token usage and enforce budgets. This module holds the\n * storage-agnostic types and pure helpers; core-server only *emits* usage and\n * *asks* a guard — each app decides where usage lands and how budgets are stored\n * (typically the Business Hub ledger).\n */\n\n/** Per-1k-token pricing used to estimate cost from token counts. */\nexport interface TokenCostRate {\n inputPer1k: number;\n outputPer1k: number;\n}\n\n/**\n * Default rate (USD per 1k tokens). Was priced for gemini-2.5-flash;\n * llm.ts's MODEL moved to gemini-3.5-flash (2.5-flash was deprecated for new\n * API keys) but this rate was NOT re-verified against 3.5-flash's actual\n * published pricing — treat cost figures as stale until confirmed.\n * Override per app via createLLMClient options once real pricing is known.\n */\nexport const DEFAULT_COST_RATE: TokenCostRate = {\n inputPer1k: 0.000075, // $0.075 / 1M input tokens (gemini-2.5-flash rate, UNVERIFIED for 3.5)\n outputPer1k: 0.0003, // $0.30 / 1M output tokens (gemini-2.5-flash rate, UNVERIFIED for 3.5)\n};\n\n/** Per-call context that varies request-to-request (set via InvokeParams.meta). */\nexport interface CallMeta {\n /** Feature/surface label, e.g. \"opi\", \"ai-advisor\", \"insights\". */\n label?: string;\n /** \"marketing\" | \"operational\" | a feature name. */\n mode?: string;\n /** Authenticated user, when known (operational surfaces). */\n user?: { id?: string; role?: string; enterpriseId?: string | number };\n /** Current page/route (page-awareness + per-surface attribution). */\n page?: string;\n}\n\n/** Emitted after every successful call. The metering sink persists this. */\nexport interface UsageEvent extends CallMeta {\n /** App identifier, e.g. \"marketing-site\", \"businesshub\", \"oee\". */\n app: string;\n model: string;\n promptTokens: number;\n completionTokens: number;\n totalTokens: number;\n estCostUsd: number;\n /** True when token counts were estimated (provider returned no usage block). */\n estimated: boolean;\n /** ISO timestamp. */\n at: string;\n}\n\n/** Asked BEFORE a call, so over-budget / disabled traffic never spends. */\nexport interface GuardRequest extends CallMeta {\n app: string;\n /** Rough prompt-token estimate, for budget math before the call. */\n estPromptTokens: number;\n}\n\nexport interface GuardResult {\n allowed: boolean;\n /** User-facing reason when blocked (kill-switch, budget exceeded, rate limit). */\n reason?: string;\n}\n\n/** Thrown by invokeLLM when a guard blocks the call. */\nexport class LLMBudgetError extends Error {\n readonly code = \"LLM_BUDGET_BLOCKED\";\n constructor(reason: string) {\n super(reason);\n this.name = \"LLMBudgetError\";\n }\n}\n\n/** Rough token estimate (~4 chars/token) — fallback when no usage is returned. */\nexport function estimateTokens(text: string): number {\n if (!text) return 0;\n return Math.ceil(text.length / 4);\n}\n\n/** Estimate prompt tokens across a set of message strings. */\nexport function estimateMessageTokens(contents: string[]): number {\n return contents.reduce((sum, c) => sum + estimateTokens(c), 0);\n}\n\n/** Compute estimated USD cost from token counts. */\nexport function computeCostUsd(\n promptTokens: number,\n completionTokens: number,\n rate: TokenCostRate = DEFAULT_COST_RATE\n): number {\n const cost =\n (promptTokens / 1000) * rate.inputPer1k +\n (completionTokens / 1000) * rate.outputPer1k;\n // Round to 6 dp — sub-cent precision without float noise.\n return Math.round(cost * 1e6) / 1e6;\n}\n","/**\n * LLM helper — calls Google's Gemini API via its OpenAI-compatible endpoint.\n *\n * Example:\n * const invokeLLM = createLLMClient(ENV);\n * const result = await invokeLLM({\n * messages: [\n * { role: \"system\", content: \"You are a helpful assistant.\" },\n * { role: \"user\", content: \"Hello, world!\" },\n * ],\n * });\n */\nimport type { CoreEnv } from \"./types\";\nimport {\n type CallMeta,\n type TokenCostRate,\n type UsageEvent,\n type GuardRequest,\n type GuardResult,\n DEFAULT_COST_RATE,\n LLMBudgetError,\n computeCostUsd,\n estimateMessageTokens,\n estimateTokens,\n} from \"./metering\";\n\nexport type Role = \"system\" | \"user\" | \"assistant\" | \"tool\" | \"function\";\n\nexport type TextContent = {\n type: \"text\";\n text: string;\n};\n\nexport type ImageContent = {\n type: \"image_url\";\n image_url: {\n url: string;\n detail?: \"auto\" | \"low\" | \"high\";\n };\n};\n\nexport type FileContent = {\n type: \"file_url\";\n file_url: {\n url: string;\n mime_type?:\n | \"audio/mpeg\"\n | \"audio/wav\"\n | \"application/pdf\"\n | \"audio/mp4\"\n | \"video/mp4\";\n };\n};\n\nexport type MessageContent = string | TextContent | ImageContent | FileContent;\n\nexport type Message = {\n role: Role;\n name?: string;\n tool_call_id?: string;\n content: MessageContent | MessageContent[];\n};\n\nexport type Tool = {\n type: \"function\";\n function: {\n name: string;\n description?: string;\n parameters?: Record<string, unknown>;\n };\n};\n\ntype ToolChoiceExplicit = {\n type: \"function\";\n function: { name: string };\n};\n\nexport type ToolChoice =\n \"none\" | \"auto\" | \"required\" | { name: string } | ToolChoiceExplicit;\n\ntype JsonSchema = {\n name: string;\n schema: Record<string, unknown>;\n strict?: boolean;\n};\n\nexport type ResponseFormat =\n | { type: \"text\" }\n | { type: \"json_object\" }\n | { type: \"json_schema\"; json_schema: JsonSchema };\n\nexport type OutputSchema = {\n name: string;\n schema: Record<string, unknown>;\n strict?: boolean;\n};\n\nexport type InvokeParams = {\n messages: Message[];\n tools?: Tool[];\n toolChoice?: ToolChoice;\n tool_choice?: ToolChoice;\n outputSchema?: OutputSchema;\n output_schema?: OutputSchema;\n responseFormat?: ResponseFormat;\n response_format?: ResponseFormat;\n /** Per-call attribution for metering/guarding (label, mode, user, page). */\n meta?: CallMeta;\n};\n\n/** Optional metering + cost-control hooks, set once per app. */\nexport type LLMClientOptions = {\n /** App identifier recorded on every usage event, e.g. \"marketing-site\". */\n app?: string;\n /** Pricing used to estimate cost from tokens. Defaults to gemini-3.5-flash (rate unverified, see metering.ts). */\n costRate?: TokenCostRate;\n /** Metering sink — called after each successful call. Errors are swallowed. */\n onUsage?: (event: UsageEvent) => void | Promise<void>;\n /** Budget/kill-switch check — called before each call; block = no spend. */\n guard?: (req: GuardRequest) => GuardResult | Promise<GuardResult>;\n};\n\nexport type InvokeResult = {\n id: string;\n object: string;\n created: number;\n model: string;\n choices: Array<{\n index: number;\n message: {\n role: string;\n content: string | null;\n tool_calls?: Array<{\n id: string;\n type: \"function\";\n function: { name: string; arguments: string };\n }>;\n };\n finish_reason: string;\n }>;\n usage?: {\n prompt_tokens: number;\n completion_tokens: number;\n total_tokens: number;\n };\n};\n\nconst ensureArray = (\n value: MessageContent | MessageContent[]\n): MessageContent[] => (Array.isArray(value) ? value : [value]);\n\nconst normalizeContentPart = (\n part: MessageContent\n): TextContent | ImageContent | FileContent => {\n if (typeof part === \"string\") {\n return { type: \"text\", text: part };\n }\n if (part.type === \"text\") return part;\n if (part.type === \"image_url\") return part;\n if (part.type === \"file_url\") return part;\n throw new Error(\"Unsupported message content part\");\n};\n\nconst normalizeMessage = (message: Message) => {\n const { role, name, tool_call_id } = message;\n if (role === \"tool\" || role === \"function\") {\n const content = ensureArray(message.content)\n .map(part => (typeof part === \"string\" ? part : JSON.stringify(part)))\n .join(\"\\n\");\n return { role, name, tool_call_id, content };\n }\n const contentParts = ensureArray(message.content).map(normalizeContentPart);\n if (contentParts.length === 1 && contentParts[0].type === \"text\") {\n return { role, name, content: contentParts[0].text };\n }\n return { role, name, content: contentParts };\n};\n\nconst normalizeToolChoice = (\n toolChoice: ToolChoice | undefined,\n tools: Tool[] | undefined\n): \"none\" | \"auto\" | ToolChoiceExplicit | undefined => {\n if (!toolChoice) return undefined;\n if (toolChoice === \"none\" || toolChoice === \"auto\") return toolChoice;\n if (toolChoice === \"required\") {\n if (!tools || tools.length === 0) {\n throw new Error(\n \"tool_choice 'required' was provided but no tools were configured\"\n );\n }\n if (tools.length > 1) {\n throw new Error(\n \"tool_choice 'required' needs a single tool or specify the tool name explicitly\"\n );\n }\n return { type: \"function\", function: { name: tools[0].function.name } };\n }\n if (\"name\" in toolChoice) {\n return { type: \"function\", function: { name: toolChoice.name } };\n }\n return toolChoice;\n};\n\nconst normalizeResponseFormat = ({\n responseFormat,\n response_format,\n outputSchema,\n output_schema,\n}: {\n responseFormat?: ResponseFormat;\n response_format?: ResponseFormat;\n outputSchema?: OutputSchema;\n output_schema?: OutputSchema;\n}):\n | { type: \"json_schema\"; json_schema: JsonSchema }\n | { type: \"text\" }\n | { type: \"json_object\" }\n | undefined => {\n const explicitFormat = responseFormat || response_format;\n if (explicitFormat) {\n if (\n explicitFormat.type === \"json_schema\" &&\n !explicitFormat.json_schema?.schema\n ) {\n throw new Error(\n \"responseFormat json_schema requires a defined schema object\"\n );\n }\n return explicitFormat;\n }\n const schema = outputSchema || output_schema;\n if (!schema) return undefined;\n if (!schema.name || !schema.schema) {\n throw new Error(\"outputSchema requires both name and schema\");\n }\n return {\n type: \"json_schema\",\n json_schema: {\n name: schema.name,\n schema: schema.schema,\n ...(typeof schema.strict === \"boolean\" ? { strict: schema.strict } : {}),\n },\n };\n};\n\nconst MODEL = \"gemini-3.5-flash\";\n\n/** Flatten a message's content to plain text for token estimation. */\nfunction messageToText(content: unknown): string {\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n return content\n .map(part => {\n if (typeof part === \"string\") return part;\n if (part && typeof part === \"object\" && \"text\" in part) {\n return String((part as { text?: unknown }).text ?? \"\");\n }\n return \"\";\n })\n .join(\" \");\n }\n return \"\";\n}\n\nexport function createLLMClient(env: CoreEnv, options: LLMClientOptions = {}) {\n const {\n app = \"unknown\",\n costRate = DEFAULT_COST_RATE,\n onUsage,\n guard,\n } = options;\n\n const resolveApiUrl = () =>\n env.geminiApiUrl && env.geminiApiUrl.trim().length > 0\n ? `${env.geminiApiUrl.replace(/\\/$/, \"\")}/chat/completions`\n : \"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions\";\n\n const assertApiKey = () => {\n if (!env.geminiApiKey) {\n throw new Error(\"GEMINI_API_KEY is not configured\");\n }\n };\n\n return async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {\n assertApiKey();\n\n const {\n messages,\n tools,\n toolChoice,\n tool_choice,\n outputSchema,\n output_schema,\n responseFormat,\n response_format,\n meta = {},\n } = params;\n\n const estPromptTokens = estimateMessageTokens(\n messages.map(m => messageToText(m.content))\n );\n\n // ── Cost control: ask the guard before spending anything ──\n if (guard) {\n const verdict = await guard({ app, estPromptTokens, ...meta });\n if (!verdict.allowed) {\n throw new LLMBudgetError(\n verdict.reason ?? \"AI is temporarily unavailable.\"\n );\n }\n }\n\n const payload: Record<string, unknown> = {\n model: MODEL,\n messages: messages.map(normalizeMessage),\n };\n\n if (tools && tools.length > 0) {\n payload.tools = tools;\n }\n\n const normalizedToolChoice = normalizeToolChoice(\n toolChoice || tool_choice,\n tools\n );\n if (normalizedToolChoice) {\n payload.tool_choice = normalizedToolChoice;\n }\n\n payload.max_tokens = 32768;\n\n const normalizedResponseFormat = normalizeResponseFormat({\n responseFormat,\n response_format,\n outputSchema,\n output_schema,\n });\n if (normalizedResponseFormat) {\n payload.response_format = normalizedResponseFormat;\n }\n\n const response = await fetch(resolveApiUrl(), {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${env.geminiApiKey}`,\n },\n body: JSON.stringify(payload),\n });\n\n if (!response.ok) {\n const errorText = await response.text();\n throw new Error(\n `LLM invoke failed: ${response.status} ${response.statusText} – ${errorText}`\n );\n }\n\n const result = (await response.json()) as InvokeResult;\n\n // ── Metering: capture usage and emit to the sink (never breaks the call) ──\n if (onUsage) {\n try {\n const provided = result.usage;\n const estimated = !provided;\n const promptTokens = provided?.prompt_tokens ?? estPromptTokens;\n const completionTokens =\n provided?.completion_tokens ??\n estimateTokens(messageToText(result.choices?.[0]?.message?.content));\n const totalTokens =\n provided?.total_tokens ?? promptTokens + completionTokens;\n\n const event: UsageEvent = {\n app,\n model: result.model ?? MODEL,\n promptTokens,\n completionTokens,\n totalTokens,\n estCostUsd: computeCostUsd(promptTokens, completionTokens, costRate),\n estimated,\n at: new Date().toISOString(),\n ...meta,\n };\n\n await onUsage(event);\n } catch (err) {\n // Metering must never take down a working chat.\n console.warn(\"[core-server] onUsage sink failed:\", err);\n }\n }\n\n return result;\n };\n}\n","/**\n * Personas for the shared Oplytics AI assistant.\n *\n * One brain, two voices: the same knowledge base is wrapped in a customer-facing\n * persona (\"Opi\" on the marketing site) or an operational persona (the in-app\n * \"AI Support Engineer\" in the portal / subdomains). The mode selects the overlay.\n */\n\nexport type AssistantMode = \"marketing\" | \"operational\";\n\n/** Identity + tone shared by every surface. */\nconst BASE_PERSONA = `\nYou are the Oplytics AI assistant — a knowledgeable, friendly guide to the Oplytics operational-excellence platform.\nYour personality is warm, approachable, and genuinely helpful, while staying clear, professional, and competent — especially on operational safety and platform technicals.\n\nShared guidelines:\n- Use a warm, professional tone: clear, concise, and easy to follow. Avoid heavy slang, forced catchphrases, or over-familiarity — be friendly without overdoing it.\n- For anything safety- or incident-related, stay serious and accurate while keeping the warm tone — prioritise accuracy over speed.\n- If you don't know something, be honest and guide the user to the right place rather than inventing an answer.\n- Prefer the supplied Oplytics knowledge below over your own assumptions. If the knowledge doesn't cover it, say so plainly.\n`.trim();\n\n/**\n * Real, live Oplytics pages, injected into every persona so Opi can cite an\n * actual URL instead of inventing one. Copied word for word from Portal's\n * OPLYTICS_FACTS (oplytics-portal server/routers/ai.ts, portal#259) so the two\n * Opis give the same answer. UPDATE THIS LIST whenever a public page is added\n * to or removed from the marketing site (routes: marketing-site\n * client/src/App.tsx), and keep Portal's copy in step.\n */\nexport const OPLYTICS_VERIFIED_LINKS = `\nReal Oplytics links. Only cite links from this list. Never invent, guess or modify a URL.\n- Home / product overview: https://oplytics.digital\n- Pricing: https://oplytics.digital/pricing\n- Contact / book a demo: https://oplytics.digital/contact\n- About: https://oplytics.digital/about\n- Opi and the AI platform: https://oplytics.digital/ai\n- The Obeya Room: https://oplytics.digital/obeya\n- Why Oplytics: https://oplytics.digital/why-us\n- Resources: https://oplytics.digital/resources\n- Terms of Service: https://oplytics.digital/terms\n- Privacy Policy: https://oplytics.digital/privacy\n- Cookie Policy: https://oplytics.digital/cookies\n- Acceptable Use Policy: https://oplytics.digital/aup\n- Data Processing Agreement (DPA): https://oplytics.digital/dpa\n- Service Level Agreement (SLA): https://oplytics.digital/sla\nIf asked for a page that is not in this list (for example careers), say there is no link for it and describe where to look instead, such as the Contact page or the relevant area of the app. Do not make up a link.\n`.trim();\n\n/** Customer-facing overlay — Opi on the public marketing site. */\nconst MARKETING_OVERLAY = `\nYou are \"Opi\", talking to a prospective customer on the public Oplytics marketing site.\n- Focus on features, benefits, outcomes, and helping them book a demo or get in touch.\n- Be persuasive but never pushy or salesy; lead with how Oplytics solves their problem.\n- Never discuss competitor products by name.\n- For specific pricing, direct the user to the Contact page (https://oplytics.digital/contact) rather than quoting figures.\n- You do not have access to any account or operational data — you are pre-sales only.\n`.trim();\n\n/** Authenticated overlay — the in-app AI Support Engineer. */\nconst OPERATIONAL_OVERLAY = `\nYou are the Oplytics AI Support Engineer, supporting a verified, logged-in user inside the platform.\n- Tailor responses to their role and enterprise context (supplied below when available).\n- Help with deep operational support: platform setup, training, troubleshooting, data interpretation, and specific tasks.\n- For safety-related queries, always prioritise accuracy and cite the correct workflow.\n- You may reference platform features and the user's context, but never expose data the user shouldn't see.\n`.trim();\n\nconst OVERLAYS: Record<AssistantMode, string> = {\n marketing: MARKETING_OVERLAY,\n operational: OPERATIONAL_OVERLAY,\n};\n\nexport interface BuildSystemPromptParams {\n mode: AssistantMode;\n /** Pre-formatted knowledge block (see formatKnowledge) injected into the prompt. */\n knowledge?: string;\n /** Free-form runtime context (current page, user/role, enterprise, etc.). */\n context?: string;\n}\n\n/** Compose the full system prompt: base identity + mode overlay + verified links + knowledge + context. */\nexport function buildSystemPrompt({\n mode,\n knowledge,\n context,\n}: BuildSystemPromptParams): string {\n const sections = [BASE_PERSONA, OVERLAYS[mode], OPLYTICS_VERIFIED_LINKS];\n\n if (knowledge && knowledge.trim().length > 0) {\n sections.push(`Relevant Oplytics knowledge:\\n${knowledge.trim()}`);\n }\n\n if (context && context.trim().length > 0) {\n sections.push(`Current context:\\n${context.trim()}`);\n }\n\n return sections.join(\"\\n\\n\");\n}\n","/**\n * Shared Oplytics knowledge base + a RAG-ready retrieval interface.\n *\n * Phase 2a ships the knowledge as version-controlled data injected into the\n * system prompt, behind a `Retriever` interface. Phase 2b can swap the static\n * retriever for a vector-store-backed one WITHOUT changing any consumer — the\n * signature is the contract.\n *\n * This is a deliberately small seed. Expand `KNOWLEDGE` over time (it's the one\n * source of truth shared by Opi and the in-app AI Support Engineer).\n */\nimport type { AssistantMode } from \"./personas\";\n\nexport interface KnowledgeItem {\n id: string;\n question: string;\n answer: string;\n /** Lowercase keywords/tags used by the static retriever's scoring. */\n tags: string[];\n /** If set, this item is only surfaced in the listed modes. Default: both. */\n modes?: AssistantMode[];\n}\n\nexport interface RetrieveOptions {\n mode: AssistantMode;\n /** Max items to return. Default 4. */\n limit?: number;\n}\n\n/**\n * A retriever maps a user query to the most relevant knowledge items.\n * Async so a future vector/embedding implementation can drop in unchanged.\n */\nexport type Retriever = (\n query: string,\n options: RetrieveOptions\n) => Promise<KnowledgeItem[]> | KnowledgeItem[];\n\n/** Seed knowledge. Sourced from the Oplytics solution set; extend freely. */\nexport const KNOWLEDGE: KnowledgeItem[] = [\n // ── Platform ──────────────────────────────────────────────────────────────\n {\n id: \"platform-overview\",\n question: \"What does Oplytics do?\",\n answer:\n \"Oplytics is an integrated operational-excellence platform for manufacturers. Its modules share data, context, and AI insights so individual tools compound into one advantage instead of working in silos. Four services are live today (OEE Manager, the SQDCP Dashboard, Policy Deployment, and Action Manager), with more on the way.\",\n tags: [\n \"platform\",\n \"overview\",\n \"what\",\n \"oplytics\",\n \"modules\",\n \"manufacturing\",\n ],\n },\n {\n id: \"who-for\",\n question: \"Who is Oplytics for?\",\n answer:\n \"Manufacturers and factory operations teams running continuous improvement, lean, and daily management — from the shop floor to senior leadership. It suits anyone replacing whiteboards, spreadsheets, and disconnected point tools with one connected system.\",\n tags: [\n \"who\",\n \"for\",\n \"audience\",\n \"manufacturer\",\n \"factory\",\n \"lean\",\n \"operations\",\n ],\n },\n {\n id: \"live-services\",\n question: \"Which services are available right now?\",\n answer:\n \"Live today: OEE Manager, the SQDCP Dashboard, Policy Deployment, and Action Manager.\",\n tags: [\n \"live\",\n \"available\",\n \"services\",\n \"roadmap\",\n \"coming\",\n \"soon\",\n \"status\",\n ],\n },\n {\n id: \"integrated-platform\",\n question: \"Why one platform instead of separate tools?\",\n answer:\n \"Every module shares the same data and context, so an action raised on a tier board, a loss in OEE, and a strategic objective all connect. That shared backbone is what lets the AI surface patterns no single tool could see alone.\",\n tags: [\"integrated\", \"platform\", \"connected\", \"modules\", \"data\", \"why\"],\n },\n {\n id: \"methodologies\",\n question: \"What improvement methodologies does it support?\",\n answer:\n \"Best-practice CI is built into the workflows: Lean, Kaizen, and PDCA throughout, plus Hoshin Kanri (with X-matrix and catchball) for strategy deployment and the SQDCP framework for daily management.\",\n tags: [\n \"lean\",\n \"kaizen\",\n \"pdca\",\n \"hoshin\",\n \"methodology\",\n \"ci\",\n \"continuous\",\n \"improvement\",\n ],\n },\n {\n id: \"mobile-desktop\",\n question: \"Can we use it on mobile?\",\n answer:\n \"Yes — Oplytics works on desktop and mobile, so boards, actions, and reports are accessible from any device, on the floor or off-site.\",\n tags: [\"mobile\", \"desktop\", \"device\", \"tablet\", \"phone\", \"access\"],\n },\n // ── OEE Manager (live) ─────────────────────────────────────────────────────\n {\n id: \"oee-manager\",\n question: \"How does OEE Manager work?\",\n answer:\n \"OEE Manager tracks Overall Equipment Effectiveness — availability, performance, and quality — in real time across every machine and line. It categorises downtime and speed losses, builds Pareto/loss analysis, and trends OEE by shift, line, product, and operator so teams target the biggest losses first.\",\n tags: [\n \"oee\",\n \"manager\",\n \"downtime\",\n \"loss\",\n \"availability\",\n \"performance\",\n \"quality\",\n \"tracking\",\n \"equipment\",\n ],\n },\n {\n id: \"oee-data-collection\",\n question: \"Does OEE data have to be entered by hand?\",\n answer:\n \"No — machines and other systems can send live data to OEE Manager automatically through its REST API or webhooks, eliminating manual-entry errors. Manual entry is still available where automation isn't in place.\",\n tags: [\n \"oee\",\n \"data\",\n \"collection\",\n \"automated\",\n \"api\",\n \"webhook\",\n \"plc\",\n \"sensor\",\n \"manual\",\n ],\n },\n {\n id: \"oee-targets-handover\",\n question: \"Can we set OEE targets and run shift handovers?\",\n answer:\n \"Yes. Set OEE targets by line, product, and shift with live visual indicators against target, and generate digital shift-handover reports with OEE summaries, open actions, and key events from the previous shift.\",\n tags: [\"oee\", \"target\", \"shift\", \"handover\", \"report\", \"goal\"],\n },\n // ── SQDCP Dashboard (live) ─────────────────────────────────────────────────\n {\n id: \"sqdcp\",\n question: \"What is the SQDCP Dashboard?\",\n answer:\n \"SQDCP replaces physical whiteboards with real-time digital tier boards covering Safety, Quality, Delivery, Cost, and People. Boards are accessible from any device and keep daily management consistent across shifts and sites.\",\n tags: [\n \"sqdcp\",\n \"tier\",\n \"board\",\n \"dashboard\",\n \"daily\",\n \"management\",\n \"whiteboard\",\n ],\n },\n {\n id: \"sqdcp-pillars\",\n question: \"What does SQDCP track?\",\n answer:\n \"The five pillars: Safety (incidents, near-misses, observations), Quality (first-pass yield, scrap, complaints), Delivery (on-time delivery, schedule adherence), Cost (operational cost and waste), and People (attendance, training, skills).\",\n tags: [\n \"sqdcp\",\n \"safety\",\n \"quality\",\n \"delivery\",\n \"cost\",\n \"people\",\n \"pillars\",\n \"metrics\",\n ],\n },\n // ── Policy Deployment (live) ───────────────────────────────────────────────\n {\n id: \"policy-deployment\",\n question: \"What is Policy Deployment?\",\n answer:\n \"Policy Deployment cascades strategy through every level using Hoshin Kanri: define 3-5 year breakthrough objectives and annual priorities, link them in a visual X-matrix, deploy via the catchball process, and review progress on a monthly/quarterly cadence with bowling charts.\",\n tags: [\n \"policy\",\n \"deployment\",\n \"hoshin\",\n \"kanri\",\n \"strategy\",\n \"xmatrix\",\n \"catchball\",\n \"objectives\",\n ],\n },\n {\n id: \"catchball\",\n question: \"What is the catchball process?\",\n answer:\n \"Catchball is how objectives are aligned top-down and bottom-up: leadership proposes goals, teams feed back what's achievable, and the two iterate until aligned. Oplytics facilitates this digitally so strategy doesn't get lost between management layers.\",\n tags: [\n \"catchball\",\n \"hoshin\",\n \"alignment\",\n \"objectives\",\n \"policy\",\n \"deployment\",\n ],\n },\n // ── Action Manager (live) ──────────────────────────────────────────────────\n {\n id: \"action-manager\",\n question: \"What is Action Manager?\",\n answer:\n \"Action Manager is one unified action register: capture actions from any source (audits, incidents, meetings, tier boards), assign them with owners and due dates, auto-escalate overdue items, and track status from open to verified closure. Analytics show completion rates, ageing, and source breakdown.\",\n tags: [\n \"action\",\n \"manager\",\n \"tasks\",\n \"improvement\",\n \"ci\",\n \"register\",\n \"escalation\",\n \"closure\",\n ],\n },\n // ── Integrations ───────────────────────────────────────────────────────────\n {\n id: \"connect-integrations\",\n question: \"Can Oplytics connect to our machines and systems?\",\n answer:\n \"Yes — machines and other systems can send live data to OEE Manager automatically through its REST API or webhooks. For a particular protocol, machine type, or business system, get in touch via /contact so the team can scope your setup.\",\n tags: [\n \"connect\",\n \"integration\",\n \"machine\",\n \"plc\",\n \"scada\",\n \"sensor\",\n \"opcua\",\n \"mqtt\",\n \"modbus\",\n \"iot\",\n \"api\",\n \"webhook\",\n ],\n },\n {\n id: \"erp-integration\",\n question: \"Can it integrate with our ERP or other systems?\",\n answer:\n \"Oplytics focuses on shop-floor connectivity (machines, PLCs, sensors) today. For ERP or other business-system integrations, the best step is to get in touch via /contact so the team can scope your specific setup.\",\n tags: [\"erp\", \"integration\", \"system\", \"api\", \"data\", \"contact\"],\n },\n {\n id: \"protocols\",\n question: \"Which industrial protocols are supported?\",\n answer:\n \"Today OEE Manager takes live machine data through a REST API (data sent by HTTP POST) and webhooks. If your equipment uses a specific industrial protocol such as OPC-UA, MQTT, or Modbus, get in touch via /contact so the team can confirm the best way to connect it.\",\n tags: [\n \"protocol\",\n \"opcua\",\n \"mqtt\",\n \"modbus\",\n \"plc\",\n \"scada\",\n \"edge\",\n \"connectivity\",\n ],\n },\n // ── AI ─────────────────────────────────────────────────────────────────────\n {\n id: \"integrated-ai\",\n question: \"How does the AI help?\",\n answer:\n \"AI runs across every module — surfacing patterns, predicting issues, and recommending actions no single tool could spot alone — and the assistant helps users navigate, answers questions, and suggests next steps.\",\n tags: [\n \"ai\",\n \"insights\",\n \"predict\",\n \"assistant\",\n \"recommend\",\n \"intelligence\",\n ],\n },\n {\n id: \"ai-assistant\",\n question: \"Who are you / what can you help with?\",\n answer:\n \"I'm Opi, the Oplytics AI guide. I can explain what the platform and each module do, point you to the right place, and help you book a demo or get in touch.\",\n tags: [\"opi\", \"assistant\", \"help\", \"who\", \"guide\", \"bot\"],\n },\n // ── Pricing, demos, getting started (marketing) ────────────────────────────\n {\n id: \"pricing\",\n question: \"How much does it cost?\",\n answer:\n \"Pricing is tailored to your sites and the modules you use, so figures aren't published. The best next step is to get in touch via the contact page (/contact) and the team will put together a quote.\",\n tags: [\"pricing\", \"price\", \"cost\", \"quote\", \"how\", \"much\", \"plan\"],\n modes: [\"marketing\"],\n },\n {\n id: \"plans\",\n question: \"What plans are available?\",\n answer:\n \"There are tiers to suit different stages — an Early Beta Partner programme, Starter, Professional, and Enterprise — covering different usage limits and capabilities like custom integrations and SLAs. All four live services are included; reach out via /contact to find the right fit.\",\n tags: [\n \"plan\",\n \"tier\",\n \"starter\",\n \"professional\",\n \"enterprise\",\n \"beta\",\n \"package\",\n ],\n modes: [\"marketing\"],\n },\n {\n id: \"beta\",\n question: \"Is there a beta or early-adopter option?\",\n answer:\n \"Yes — the Early Beta Partner programme is for teams who want to get started early and shape the platform. Get in touch via /contact to learn more.\",\n tags: [\"beta\", \"early\", \"partner\", \"adopter\", \"trial\", \"pilot\"],\n modes: [\"marketing\"],\n },\n {\n id: \"demo\",\n question: \"Can I see a demo?\",\n answer:\n \"Absolutely — you can book a demo or try the live module demos. Head to the contact page (/contact) to get set up.\",\n tags: [\"demo\", \"trial\", \"try\", \"book\", \"see\", \"show\"],\n modes: [\"marketing\"],\n },\n {\n id: \"contact\",\n question: \"How do I get in touch?\",\n answer:\n \"Head to the contact page at /contact — tell the team a little about your operation and they'll take it from there, whether that's a demo, a quote, or answering questions.\",\n tags: [\"contact\", \"touch\", \"reach\", \"sales\", \"talk\", \"email\"],\n modes: [\"marketing\"],\n },\n {\n id: \"getting-started\",\n question: \"How do we get started?\",\n answer:\n \"Start with a conversation: get in touch via /contact for a demo and onboarding. The team will help you stand up the live services for your sites and connect your data.\",\n tags: [\"start\", \"started\", \"onboarding\", \"begin\", \"setup\", \"trial\"],\n },\n];\n\n/** Lowercase word tokens, length >= 3. */\nfunction tokenize(text: string): string[] {\n return (text.toLowerCase().match(/[a-z0-9]+/g) ?? []).filter(\n w => w.length >= 3\n );\n}\n\n/** Score an item against the query tokens (tag hits weighted over text hits). */\nfunction scoreItem(item: KnowledgeItem, queryTokens: string[]): number {\n if (queryTokens.length === 0) return 0;\n const haystack = tokenize(`${item.question} ${item.answer}`);\n let score = 0;\n for (const token of queryTokens) {\n if (item.tags.includes(token)) score += 3;\n if (haystack.includes(token)) score += 1;\n }\n return score;\n}\n\n/**\n * Build a static, keyword-scored retriever over the supplied knowledge.\n * Drop-in replaceable by a vector retriever implementing the same `Retriever`.\n */\nexport function createStaticRetriever(\n knowledge: KnowledgeItem[] = KNOWLEDGE\n): Retriever {\n return (query, { mode, limit = 4 }) => {\n const queryTokens = tokenize(query);\n const eligible = knowledge.filter(k => !k.modes || k.modes.includes(mode));\n\n const scored = eligible\n .map(item => ({ item, score: scoreItem(item, queryTokens) }))\n .filter(s => s.score > 0)\n .sort((a, b) => b.score - a.score)\n .slice(0, limit)\n .map(s => s.item);\n\n // Cold-start / no keyword hits: fall back to the first few eligible items\n // so the assistant always has some grounding to work from.\n if (scored.length === 0) return eligible.slice(0, limit);\n return scored;\n };\n}\n\n/** Render retrieved items into a compact block for the system prompt. */\nexport function formatKnowledge(items: KnowledgeItem[]): string {\n return items.map(i => `Q: ${i.question}\\nA: ${i.answer}`).join(\"\\n\\n\");\n}\n","/**\n * createSupportEngine — the shared chat brain for Opi (marketing) and the in-app\n * AI Support Engineer (operational). Composes persona + retrieved knowledge +\n * runtime context, then calls the existing LLM client.\n *\n * const engine = createSupportEngine(ENV);\n * const { content } = await engine.chat({ mode: \"marketing\", messages });\n */\nimport type { CoreEnv } from \"../types\";\nimport { createLLMClient } from \"../llm\";\nimport type { Message, LLMClientOptions } from \"../llm\";\nimport { buildSystemPrompt, type AssistantMode } from \"./personas\";\nimport {\n createStaticRetriever,\n formatKnowledge,\n type Retriever,\n} from \"./knowledge\";\n\n/** Lightweight, mode-agnostic chat message (system prompt is added internally). */\nexport interface ChatMessage {\n role: \"user\" | \"assistant\";\n content: string;\n}\n\n/** Optional runtime context to ground the assistant. */\nexport interface SupportContext {\n /** Current page / route the user is on (page-awareness). */\n page?: string;\n /** Authenticated user details (operational mode). */\n user?: { name?: string; role?: string; enterpriseId?: string | number };\n /** Any extra free-form context to append. */\n extra?: string;\n}\n\nexport interface ChatParams {\n mode: AssistantMode;\n messages: ChatMessage[];\n context?: SupportContext;\n}\n\nexport interface ChatResult {\n content: string;\n}\n\nexport interface SupportEngineOptions {\n /**\n * Knowledge retriever. Defaults to the bundled static keyword retriever.\n * Swap for a vector-store retriever later with zero consumer changes.\n */\n retrieve?: Retriever;\n /** Items to retrieve per turn. Default 4. */\n knowledgeLimit?: number;\n /** Metering + cost-control hooks (app, onUsage sink, budget guard, costRate). */\n metering?: LLMClientOptions;\n}\n\nfunction renderContext(mode: AssistantMode, context?: SupportContext): string {\n const lines: string[] = [\n `Mode: ${mode === \"marketing\" ? \"Public Marketing Site\" : \"Authenticated Portal\"}`,\n ];\n if (context?.page) lines.push(`Page: ${context.page}`);\n if (context?.user?.name) lines.push(`User: ${context.user.name}`);\n if (context?.user?.role) lines.push(`Role: ${context.user.role}`);\n if (context?.user?.enterpriseId != null)\n lines.push(`Enterprise ID: ${context.user.enterpriseId}`);\n if (context?.extra) lines.push(context.extra);\n return lines.join(\"\\n\");\n}\n\nexport function createSupportEngine(\n env: CoreEnv,\n options: SupportEngineOptions = {}\n) {\n const invokeLLM = createLLMClient(env, options.metering);\n const retrieve = options.retrieve ?? createStaticRetriever();\n const knowledgeLimit = options.knowledgeLimit ?? 4;\n\n async function chat({\n mode,\n messages,\n context,\n }: ChatParams): Promise<ChatResult> {\n const lastUser = [...messages].reverse().find(m => m.role === \"user\");\n const query = lastUser?.content ?? \"\";\n\n const items = await retrieve(query, { mode, limit: knowledgeLimit });\n const knowledge = formatKnowledge(items);\n\n const systemPrompt = buildSystemPrompt({\n mode,\n knowledge,\n context: renderContext(mode, context),\n });\n\n const llmMessages: Message[] = [\n { role: \"system\", content: systemPrompt },\n ...messages.map(m => ({ role: m.role, content: m.content })),\n ];\n\n const result = await invokeLLM({\n messages: llmMessages,\n meta: {\n label: \"support-engine\",\n mode,\n page: context?.page,\n user: context?.user\n ? { role: context.user.role, enterpriseId: context.user.enterpriseId }\n : undefined,\n },\n });\n const raw = result.choices[0]?.message?.content;\n const content = typeof raw === \"string\" ? raw : JSON.stringify(raw ?? \"\");\n\n return { content };\n }\n\n return { chat };\n}\n\nexport type SupportEngine = ReturnType<typeof createSupportEngine>;\n","/**\n * Security response headers for every Oplytics Express app.\n *\n * Set once, at the top of the middleware chain (before any route), so every\n * response — API, SPA shell, static asset, redirect — carries them. Replaces\n * the \"no Helmet, no CSP, no HSTS at the app layer\" gap flagged in the\n * 2026-08 ISO 27001 gap analysis (RISK-09). Cloudflare sits in front but was\n * verified to inject none of these.\n *\n * Deliberately dependency-free — Helmet for a fixed set of static headers is\n * just `res.setHeader` calls, and core-server already keeps its dependency\n * surface at zero.\n *\n * The Content-Security-Policy is sent **report-only** by default: browsers\n * evaluate it and report violations but block nothing, so a too-tight policy\n * can't take the SPA down. Once the report stream is clean for a given app,\n * pass `cspMode: \"enforce\"` to switch it to a blocking policy.\n */\nimport type { Request, Response, NextFunction } from \"express\";\n\nexport interface SecurityHeadersOptions {\n /**\n * Extra origins the app's browser code legitimately talks to, beyond the\n * platform defaults (self, the portal API host, Google Fonts). Used for\n * `connect-src` / `img-src` / `script-src` as appropriate — e.g. an\n * analytics endpoint or a CDN. Origins only (`https://host[:port]`).\n */\n extraConnectSrc?: string[];\n extraImgSrc?: string[];\n extraScriptSrc?: string[];\n extraStyleSrc?: string[];\n /**\n * \"report-only\" (default) sends `Content-Security-Policy-Report-Only`;\n * \"enforce\" sends a blocking `Content-Security-Policy`; \"off\" omits CSP\n * entirely (the other headers still apply).\n */\n cspMode?: \"report-only\" | \"enforce\" | \"off\";\n /**\n * HSTS max-age in seconds. Default 180 days. Set 0 to omit the header\n * (e.g. while still testing HTTPS). `includeSubDomains` is always set when\n * the header is present; `preload` is opt-in via `hstsPreload`.\n */\n hstsMaxAge?: number;\n hstsPreload?: boolean;\n}\n\nconst DAY = 86_400;\n\n/** Fonts + the shared portal API origin every app in the platform uses. */\nconst GOOGLE_FONTS_CSS = \"https://fonts.googleapis.com\";\nconst GOOGLE_FONTS_FILES = \"https://fonts.gstatic.com\";\n\nfunction buildCsp(\n opts: SecurityHeadersOptions,\n cspMode: \"report-only\" | \"enforce\"\n): string {\n const connect = [\n \"'self'\",\n \"https://portal.oplyticsdigital.net\",\n ...(opts.extraConnectSrc ?? []),\n ];\n const img = [\"'self'\", \"data:\", \"blob:\", ...(opts.extraImgSrc ?? [])];\n const script = [\"'self'\", ...(opts.extraScriptSrc ?? [])];\n // Vite's runtime and several UI libs inject <style> tags at runtime, so\n // style-src needs 'unsafe-inline'. This is low-risk (style injection can't\n // exfiltrate) and is the standard trade-off for a bundled SPA.\n const style = [\n \"'self'\",\n \"'unsafe-inline'\",\n GOOGLE_FONTS_CSS,\n ...(opts.extraStyleSrc ?? []),\n ];\n const font = [\"'self'\", GOOGLE_FONTS_FILES, \"data:\"];\n\n const directives = [\n \"default-src 'self'\",\n `connect-src ${connect.join(\" \")}`,\n `img-src ${img.join(\" \")}`,\n `script-src ${script.join(\" \")}`,\n `style-src ${style.join(\" \")}`,\n `font-src ${font.join(\" \")}`,\n \"object-src 'none'\",\n \"base-uri 'self'\",\n \"frame-ancestors 'none'\",\n \"form-action 'self'\",\n ];\n // upgrade-insecure-requests is an ENFORCEMENT directive — per spec it has\n // no effect when the policy is delivered report-only, and Chrome logs a\n // console warning (\"...is ignored when delivered in a report-only\n // policy\") on every single page load telling you exactly that. Fleet-wide\n // since RISK-09 shipped, because every app currently runs the report-only\n // default — caught via the nightly E2E's console-error assertion\n // (oplytics-subdomains Action Manager, 2026-09-10). Only include it once\n // the policy actually enforces.\n if (cspMode === \"enforce\") directives.push(\"upgrade-insecure-requests\");\n\n return directives.join(\"; \");\n}\n\n/**\n * Express middleware that stamps the platform security headers on every\n * response. Mount it before the body parser:\n *\n * const app = express();\n * app.use(securityHeaders({ extraScriptSrc: [ANALYTICS_ORIGIN] }));\n * app.use(express.json());\n */\nexport function securityHeaders(opts: SecurityHeadersOptions = {}) {\n const cspMode = opts.cspMode ?? \"report-only\";\n const hstsMaxAge = opts.hstsMaxAge ?? 180 * DAY;\n const csp = cspMode === \"off\" ? null : buildCsp(opts, cspMode);\n const cspHeader =\n cspMode === \"enforce\"\n ? \"Content-Security-Policy\"\n : \"Content-Security-Policy-Report-Only\";\n\n return function securityHeadersMiddleware(\n _req: Request,\n res: Response,\n next: NextFunction\n ): void {\n // Express advertises itself by default — remove the fingerprint.\n res.removeHeader(\"X-Powered-By\");\n\n res.setHeader(\"X-Content-Type-Options\", \"nosniff\");\n res.setHeader(\"X-Frame-Options\", \"DENY\");\n res.setHeader(\"Referrer-Policy\", \"strict-origin-when-cross-origin\");\n res.setHeader(\"X-Permitted-Cross-Domain-Policies\", \"none\");\n res.setHeader(\"Cross-Origin-Opener-Policy\", \"same-origin\");\n res.setHeader(\"Cross-Origin-Resource-Policy\", \"same-site\");\n res.setHeader(\n \"Permissions-Policy\",\n \"camera=(), microphone=(), geolocation=(), payment=()\"\n );\n\n if (hstsMaxAge > 0) {\n const preload = opts.hstsPreload ? \"; preload\" : \"\";\n res.setHeader(\n \"Strict-Transport-Security\",\n `max-age=${hstsMaxAge}; includeSubDomains${preload}`\n );\n }\n\n if (csp) res.setHeader(cspHeader, csp);\n\n next();\n };\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,93 @@
1
+ import * as _trpc_server from '@trpc/server';
2
+ import { RoleType } from '@pablo2410/shared-ui/rbac';
3
+
4
+ /** The user fields isolation needs. Each app's session user must have these. */
5
+ interface IsolationUser {
6
+ role: string;
7
+ enterpriseId?: number | null;
8
+ }
9
+ /** The context fields isolation needs. Each app's tRPC context must have these. */
10
+ interface IsolationContext<TUser extends IsolationUser = IsolationUser> {
11
+ user: TUser | null;
12
+ }
13
+ /** Anything owned by one enterprise (a row from the DB or the Portal API). */
14
+ interface EnterpriseOwned {
15
+ enterpriseId?: number | null;
16
+ }
17
+ /**
18
+ * True when `userRole` is a known role at or above `minimumRole`.
19
+ * Unlike a bare indexOf comparison, an unknown role on either side is never
20
+ * treated as meeting the bar.
21
+ */
22
+ declare function meetsMinimumRole(userRole: string | null | undefined, minimumRole: RoleType): boolean;
23
+ /**
24
+ * The caller's enterprise, or null. Never a default tenant: a missing,
25
+ * zero, negative or non-integer enterpriseId is null.
26
+ */
27
+ declare function resolveEnterpriseId(user: IsolationUser | null | undefined): number | null;
28
+ /** Like resolveEnterpriseId, but throws UNAUTHORIZED / FORBIDDEN instead of null. */
29
+ declare function requireEnterpriseId(user: IsolationUser | null | undefined): number;
30
+ /**
31
+ * Keep only the rows owned by `enterpriseId`. A null/invalid enterprise gets no
32
+ * rows; a row with no enterpriseId is never included.
33
+ */
34
+ declare function scopeToEnterprise<T extends EnterpriseOwned>(rows: readonly T[], enterpriseId: number | null | undefined): T[];
35
+ interface OwnershipMessages {
36
+ /** Message when the record does not exist. Default "Not found". */
37
+ notFound?: string;
38
+ /** Message when the record belongs to another enterprise. */
39
+ forbidden?: string;
40
+ }
41
+ /**
42
+ * Return `record` if it belongs to `enterpriseId`; otherwise throw.
43
+ * Missing record → NOT_FOUND. Other enterprise, a record with no enterpriseId,
44
+ * or a caller with no enterprise → FORBIDDEN.
45
+ */
46
+ declare function assertEnterpriseOwnership<T extends EnterpriseOwned>(record: T | null | undefined, enterpriseId: number | null | undefined, messages?: OwnershipMessages): T;
47
+ /**
48
+ * Build the standard procedures for an app whose session user is `TUser`.
49
+ * Attach each one to the app's own tRPC instance with `t.procedure.concat(...)`.
50
+ *
51
+ * | Procedure | Auth | Enterprise | Min role |
52
+ * |------------------------------|:----:|:----------:|-------------------|
53
+ * | protectedProcedure | Yes | No | any |
54
+ * | enterpriseProcedure | Yes | Yes | any |
55
+ * | enterpriseSuperuserProcedure | Yes | Yes | superuser+ |
56
+ * | enterpriseAdminProcedure | Yes | Yes | enterprise_admin+ |
57
+ * | platformAdminProcedure | Yes | No | platform_admin |
58
+ * | enterpriseRoleProcedure(r) | Yes | Yes | r |
59
+ * | roleProcedure(r) | Yes | No | r |
60
+ *
61
+ * Enterprise procedures add `ctx.enterpriseId: number` and narrow `ctx.user`
62
+ * to non-null. Checks run in order: user, then enterprise, then role.
63
+ */
64
+ declare function createIsolationProcedures<TUser extends IsolationUser = IsolationUser>(): {
65
+ protectedProcedure: _trpc_server.TRPCProcedureBuilder<IsolationContext<TUser>, object, {
66
+ user: TUser;
67
+ }, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, false>;
68
+ enterpriseProcedure: _trpc_server.TRPCProcedureBuilder<IsolationContext<TUser>, object, {
69
+ enterpriseId: number;
70
+ user: TUser;
71
+ }, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, false>;
72
+ enterpriseSuperuserProcedure: _trpc_server.TRPCProcedureBuilder<IsolationContext<TUser>, object, {
73
+ enterpriseId: number;
74
+ user: TUser;
75
+ }, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, false>;
76
+ enterpriseAdminProcedure: _trpc_server.TRPCProcedureBuilder<IsolationContext<TUser>, object, {
77
+ enterpriseId: number;
78
+ user: TUser;
79
+ }, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, false>;
80
+ platformAdminProcedure: _trpc_server.TRPCProcedureBuilder<IsolationContext<TUser>, object, {
81
+ user: TUser;
82
+ }, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, false>;
83
+ enterpriseRoleProcedure: (minimumRole: RoleType, message?: string) => _trpc_server.TRPCProcedureBuilder<IsolationContext<TUser>, object, {
84
+ enterpriseId: number;
85
+ user: TUser;
86
+ }, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, false>;
87
+ roleProcedure: (minimumRole: RoleType, message?: string) => _trpc_server.TRPCProcedureBuilder<IsolationContext<TUser>, object, {
88
+ user: TUser;
89
+ }, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, false>;
90
+ };
91
+ type IsolationProcedures<TUser extends IsolationUser = IsolationUser> = ReturnType<typeof createIsolationProcedures<TUser>>;
92
+
93
+ export { type EnterpriseOwned, type IsolationContext, type IsolationProcedures, type IsolationUser, type OwnershipMessages, assertEnterpriseOwnership, createIsolationProcedures, meetsMinimumRole, requireEnterpriseId, resolveEnterpriseId, scopeToEnterprise };
@@ -0,0 +1,116 @@
1
+ // src/isolation.ts
2
+ import { initTRPC, TRPCError } from "@trpc/server";
3
+ import {
4
+ NO_ENTERPRISE_ERR_MSG,
5
+ NOT_ADMIN_ERR_MSG,
6
+ NOT_PLATFORM_ADMIN_ERR_MSG,
7
+ NOT_SUPERUSER_ERR_MSG,
8
+ ROLE_HIERARCHY,
9
+ UNAUTHED_ERR_MSG
10
+ } from "@pablo2410/shared-ui/rbac";
11
+ function isEnterpriseId(value) {
12
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
13
+ }
14
+ function meetsMinimumRole(userRole, minimumRole) {
15
+ const user = ROLE_HIERARCHY.indexOf(userRole);
16
+ const min = ROLE_HIERARCHY.indexOf(minimumRole);
17
+ return user >= 0 && min >= 0 && user >= min;
18
+ }
19
+ function resolveEnterpriseId(user) {
20
+ const id = user?.enterpriseId;
21
+ return isEnterpriseId(id) ? id : null;
22
+ }
23
+ function requireEnterpriseId(user) {
24
+ if (!user) {
25
+ throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
26
+ }
27
+ const id = resolveEnterpriseId(user);
28
+ if (id === null) {
29
+ throw new TRPCError({ code: "FORBIDDEN", message: NO_ENTERPRISE_ERR_MSG });
30
+ }
31
+ return id;
32
+ }
33
+ function scopeToEnterprise(rows, enterpriseId) {
34
+ if (!isEnterpriseId(enterpriseId)) return [];
35
+ return rows.filter((row) => row.enterpriseId === enterpriseId);
36
+ }
37
+ function assertEnterpriseOwnership(record, enterpriseId, messages = {}) {
38
+ if (record === null || record === void 0) {
39
+ throw new TRPCError({
40
+ code: "NOT_FOUND",
41
+ message: messages.notFound ?? "Not found"
42
+ });
43
+ }
44
+ if (!isEnterpriseId(enterpriseId) || record.enterpriseId !== enterpriseId) {
45
+ throw new TRPCError({
46
+ code: "FORBIDDEN",
47
+ message: messages.forbidden ?? "Access denied: this record belongs to another enterprise"
48
+ });
49
+ }
50
+ return record;
51
+ }
52
+ function createIsolationProcedures() {
53
+ const t = initTRPC.context().create();
54
+ const requireUser = t.middleware(({ ctx, next }) => {
55
+ if (!ctx.user) {
56
+ throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
57
+ }
58
+ return next({ ctx: { user: ctx.user } });
59
+ });
60
+ const requireEnterprise = t.middleware(({ ctx, next }) => {
61
+ const enterpriseId = requireEnterpriseId(ctx.user);
62
+ return next({ ctx: { user: ctx.user, enterpriseId } });
63
+ });
64
+ function requireRole(minimumRole, message) {
65
+ if (!ROLE_HIERARCHY.includes(minimumRole)) {
66
+ throw new Error(`Unknown role for isolation check: ${minimumRole}`);
67
+ }
68
+ return t.middleware(({ ctx, next }) => {
69
+ if (!ctx.user) {
70
+ throw new TRPCError({
71
+ code: "UNAUTHORIZED",
72
+ message: UNAUTHED_ERR_MSG
73
+ });
74
+ }
75
+ if (!meetsMinimumRole(ctx.user.role, minimumRole)) {
76
+ throw new TRPCError({ code: "FORBIDDEN", message });
77
+ }
78
+ return next();
79
+ });
80
+ }
81
+ const protectedProcedure = t.procedure.use(requireUser);
82
+ const enterpriseProcedure = t.procedure.use(requireEnterprise);
83
+ function enterpriseRoleProcedure(minimumRole, message = NOT_ADMIN_ERR_MSG) {
84
+ return enterpriseProcedure.use(requireRole(minimumRole, message));
85
+ }
86
+ function roleProcedure(minimumRole, message = NOT_ADMIN_ERR_MSG) {
87
+ return protectedProcedure.use(requireRole(minimumRole, message));
88
+ }
89
+ return {
90
+ protectedProcedure,
91
+ enterpriseProcedure,
92
+ enterpriseSuperuserProcedure: enterpriseRoleProcedure(
93
+ "superuser",
94
+ NOT_SUPERUSER_ERR_MSG
95
+ ),
96
+ enterpriseAdminProcedure: enterpriseRoleProcedure(
97
+ "enterprise_admin",
98
+ NOT_ADMIN_ERR_MSG
99
+ ),
100
+ platformAdminProcedure: roleProcedure(
101
+ "platform_admin",
102
+ NOT_PLATFORM_ADMIN_ERR_MSG
103
+ ),
104
+ enterpriseRoleProcedure,
105
+ roleProcedure
106
+ };
107
+ }
108
+ export {
109
+ assertEnterpriseOwnership,
110
+ createIsolationProcedures,
111
+ meetsMinimumRole,
112
+ requireEnterpriseId,
113
+ resolveEnterpriseId,
114
+ scopeToEnterprise
115
+ };
116
+ //# sourceMappingURL=isolation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/isolation.ts"],"sourcesContent":["/**\n * Enterprise Isolation — the one enforced tenant-scoping path for every service.\n *\n * Replaces the per-app copies of `requireEnterprise` / `enterpriseProcedure` and\n * the hand-written `rows.filter(r => r.enterpriseId === id)` and \"does this\n * record belong to the caller?\" checks. Roles and error messages come from\n * `@pablo2410/shared-ui/rbac`, the platform's single role model.\n *\n * Every check fails closed: no user → UNAUTHORIZED; no (valid) enterprise →\n * FORBIDDEN / no rows; an unknown role never passes a role check; a record with\n * no enterpriseId never matches, even against a caller with none.\n *\n * Quick example (each app keeps its own `t`; the procedures are concatenated\n * onto it, so the app's transformer, context and error formatter still apply):\n *\n * import { createIsolationProcedures } from \"@pablo2410/core-server/isolation\";\n * const iso = createIsolationProcedures<SessionUser>();\n * export const enterpriseProcedure = t.procedure.concat(iso.enterpriseProcedure);\n * // in a resolver: ctx.enterpriseId is a number, ctx.user is non-null\n * listPlans: enterpriseProcedure.query(async ({ ctx }) =>\n * scopeToEnterprise(await getPlans(), ctx.enterpriseId)),\n */\nimport { initTRPC, TRPCError } from \"@trpc/server\";\nimport {\n NO_ENTERPRISE_ERR_MSG,\n NOT_ADMIN_ERR_MSG,\n NOT_PLATFORM_ADMIN_ERR_MSG,\n NOT_SUPERUSER_ERR_MSG,\n ROLE_HIERARCHY,\n UNAUTHED_ERR_MSG,\n type RoleType,\n} from \"@pablo2410/shared-ui/rbac\";\n\n/** The user fields isolation needs. Each app's session user must have these. */\nexport interface IsolationUser {\n role: string;\n enterpriseId?: number | null;\n}\n\n/** The context fields isolation needs. Each app's tRPC context must have these. */\nexport interface IsolationContext<TUser extends IsolationUser = IsolationUser> {\n user: TUser | null;\n}\n\n/** Anything owned by one enterprise (a row from the DB or the Portal API). */\nexport interface EnterpriseOwned {\n enterpriseId?: number | null;\n}\n\n/** Only positive integers are real enterprise IDs. */\nfunction isEnterpriseId(value: unknown): value is number {\n return typeof value === \"number\" && Number.isInteger(value) && value > 0;\n}\n\n/**\n * True when `userRole` is a known role at or above `minimumRole`.\n * Unlike a bare indexOf comparison, an unknown role on either side is never\n * treated as meeting the bar.\n */\nexport function meetsMinimumRole(\n userRole: string | null | undefined,\n minimumRole: RoleType\n): boolean {\n const user = ROLE_HIERARCHY.indexOf(userRole as RoleType);\n const min = ROLE_HIERARCHY.indexOf(minimumRole);\n return user >= 0 && min >= 0 && user >= min;\n}\n\n/**\n * The caller's enterprise, or null. Never a default tenant: a missing,\n * zero, negative or non-integer enterpriseId is null.\n */\nexport function resolveEnterpriseId(\n user: IsolationUser | null | undefined\n): number | null {\n const id = user?.enterpriseId;\n return isEnterpriseId(id) ? id : null;\n}\n\n/** Like resolveEnterpriseId, but throws UNAUTHORIZED / FORBIDDEN instead of null. */\nexport function requireEnterpriseId(\n user: IsolationUser | null | undefined\n): number {\n if (!user) {\n throw new TRPCError({ code: \"UNAUTHORIZED\", message: UNAUTHED_ERR_MSG });\n }\n const id = resolveEnterpriseId(user);\n if (id === null) {\n throw new TRPCError({ code: \"FORBIDDEN\", message: NO_ENTERPRISE_ERR_MSG });\n }\n return id;\n}\n\n/**\n * Keep only the rows owned by `enterpriseId`. A null/invalid enterprise gets no\n * rows; a row with no enterpriseId is never included.\n */\nexport function scopeToEnterprise<T extends EnterpriseOwned>(\n rows: readonly T[],\n enterpriseId: number | null | undefined\n): T[] {\n if (!isEnterpriseId(enterpriseId)) return [];\n return rows.filter(row => row.enterpriseId === enterpriseId);\n}\n\nexport interface OwnershipMessages {\n /** Message when the record does not exist. Default \"Not found\". */\n notFound?: string;\n /** Message when the record belongs to another enterprise. */\n forbidden?: string;\n}\n\n/**\n * Return `record` if it belongs to `enterpriseId`; otherwise throw.\n * Missing record → NOT_FOUND. Other enterprise, a record with no enterpriseId,\n * or a caller with no enterprise → FORBIDDEN.\n */\nexport function assertEnterpriseOwnership<T extends EnterpriseOwned>(\n record: T | null | undefined,\n enterpriseId: number | null | undefined,\n messages: OwnershipMessages = {}\n): T {\n if (record === null || record === undefined) {\n throw new TRPCError({\n code: \"NOT_FOUND\",\n message: messages.notFound ?? \"Not found\",\n });\n }\n if (!isEnterpriseId(enterpriseId) || record.enterpriseId !== enterpriseId) {\n throw new TRPCError({\n code: \"FORBIDDEN\",\n message:\n messages.forbidden ??\n \"Access denied: this record belongs to another enterprise\",\n });\n }\n return record;\n}\n\n/**\n * Build the standard procedures for an app whose session user is `TUser`.\n * Attach each one to the app's own tRPC instance with `t.procedure.concat(...)`.\n *\n * | Procedure | Auth | Enterprise | Min role |\n * |------------------------------|:----:|:----------:|-------------------|\n * | protectedProcedure | Yes | No | any |\n * | enterpriseProcedure | Yes | Yes | any |\n * | enterpriseSuperuserProcedure | Yes | Yes | superuser+ |\n * | enterpriseAdminProcedure | Yes | Yes | enterprise_admin+ |\n * | platformAdminProcedure | Yes | No | platform_admin |\n * | enterpriseRoleProcedure(r) | Yes | Yes | r |\n * | roleProcedure(r) | Yes | No | r |\n *\n * Enterprise procedures add `ctx.enterpriseId: number` and narrow `ctx.user`\n * to non-null. Checks run in order: user, then enterprise, then role.\n */\nexport function createIsolationProcedures<\n TUser extends IsolationUser = IsolationUser,\n>() {\n const t = initTRPC.context<IsolationContext<TUser>>().create();\n\n const requireUser = t.middleware(({ ctx, next }) => {\n if (!ctx.user) {\n throw new TRPCError({ code: \"UNAUTHORIZED\", message: UNAUTHED_ERR_MSG });\n }\n return next({ ctx: { user: ctx.user } });\n });\n\n const requireEnterprise = t.middleware(({ ctx, next }) => {\n const enterpriseId = requireEnterpriseId(ctx.user);\n return next({ ctx: { user: ctx.user as TUser, enterpriseId } });\n });\n\n function requireRole(minimumRole: RoleType, message: string) {\n if (!ROLE_HIERARCHY.includes(minimumRole)) {\n throw new Error(`Unknown role for isolation check: ${minimumRole}`);\n }\n return t.middleware(({ ctx, next }) => {\n if (!ctx.user) {\n throw new TRPCError({\n code: \"UNAUTHORIZED\",\n message: UNAUTHED_ERR_MSG,\n });\n }\n if (!meetsMinimumRole(ctx.user.role, minimumRole)) {\n throw new TRPCError({ code: \"FORBIDDEN\", message });\n }\n return next();\n });\n }\n\n const protectedProcedure = t.procedure.use(requireUser);\n const enterpriseProcedure = t.procedure.use(requireEnterprise);\n\n /** Enterprise procedure with a custom minimum role (keeps an app's existing threshold). */\n function enterpriseRoleProcedure(\n minimumRole: RoleType,\n message: string = NOT_ADMIN_ERR_MSG\n ) {\n return enterpriseProcedure.use(requireRole(minimumRole, message));\n }\n\n /** Signed-in procedure with a minimum role and no enterprise (cross-enterprise admin). */\n function roleProcedure(\n minimumRole: RoleType,\n message: string = NOT_ADMIN_ERR_MSG\n ) {\n return protectedProcedure.use(requireRole(minimumRole, message));\n }\n\n return {\n protectedProcedure,\n enterpriseProcedure,\n enterpriseSuperuserProcedure: enterpriseRoleProcedure(\n \"superuser\",\n NOT_SUPERUSER_ERR_MSG\n ),\n enterpriseAdminProcedure: enterpriseRoleProcedure(\n \"enterprise_admin\",\n NOT_ADMIN_ERR_MSG\n ),\n platformAdminProcedure: roleProcedure(\n \"platform_admin\",\n NOT_PLATFORM_ADMIN_ERR_MSG\n ),\n enterpriseRoleProcedure,\n roleProcedure,\n };\n}\n\nexport type IsolationProcedures<TUser extends IsolationUser = IsolationUser> =\n ReturnType<typeof createIsolationProcedures<TUser>>;\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablo2410/core-server",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "Shared server utilities for Oplytics.digital subdomains",
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,6 +15,10 @@
15
15
  "./hierarchy": {
16
16
  "types": "./dist/hierarchy.d.ts",
17
17
  "import": "./dist/hierarchy.js"
18
+ },
19
+ "./isolation": {
20
+ "types": "./dist/isolation.d.ts",
21
+ "import": "./dist/isolation.js"
18
22
  }
19
23
  },
20
24
  "files": [