This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@app/config",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "This app's configuration: the deployment contract per process, the app brand and the feature flags.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@conusai-kit/brand": "catalog:kit",
|
||||
"@conusai-kit/core": "catalog:kit",
|
||||
"@conusai-kit/platform": "catalog:kit",
|
||||
"@conusai-sdk/node-sdk": "catalog:",
|
||||
"@conusai-sdk/observe-node": "catalog:",
|
||||
"pg": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.6.5",
|
||||
"@types/pg": "catalog:",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The one AI setting of this app: which model the platform's AI gateway is asked for. The gateway's
|
||||
// token and URL are the deployment contract (@conusai-kit/platform); the model is a choice, so it has
|
||||
// a default that works and a variable that changes it, per environment, without a rebuild.
|
||||
|
||||
/** A model the ConusAI AI gateway serves; the extraction prompt of `modules/documents` is written against this family. */
|
||||
export const DEFAULT_AI_MODEL = "anthropic/claude-sonnet-5"
|
||||
|
||||
export const AI_MODEL_VAR = "AI_MODEL"
|
||||
|
||||
/** The model id the app asks the gateway for: `AI_MODEL` when set, otherwise the default. */
|
||||
export function aiModelOf(env: Record<string, string | undefined> = process.env): string {
|
||||
const chosen = env[AI_MODEL_VAR]?.trim()
|
||||
return chosen ? chosen : DEFAULT_AI_MODEL
|
||||
}
|
||||
|
||||
// The knowledge module's deployment choices: which search backend serves it, which embeddings
|
||||
// model the vector backend asks for, and the vector size the store is built with.
|
||||
//
|
||||
// KNOWLEDGE_BACKEND "vectors" (default): chunks and queries are embedded through the AI
|
||||
// gateway and stored in the app's vector store (KNOWLEDGE_VECTOR_STORE
|
||||
// below: a linked Qdrant, or pgvector when chosen explicitly).
|
||||
// "platform": the platform's managed documents-index service does the
|
||||
// indexing and the retrieval.
|
||||
// EMBEDDING_MODEL the gateway's embeddings model for the vectors backend (required when the
|
||||
// backend is vectors; AI_EMBEDDING_MODEL overrides).
|
||||
// VECTOR_DIMENSIONS the embedding size the vector store is built with; the pgvector column and
|
||||
// the Qdrant collection both take it (default 1536).
|
||||
|
||||
import { z } from "zod"
|
||||
|
||||
export const KNOWLEDGE_BACKEND_VAR = "KNOWLEDGE_BACKEND"
|
||||
|
||||
export const KNOWLEDGE_BACKENDS = ["vectors", "platform"] as const
|
||||
export type KnowledgeBackendChoice = (typeof KNOWLEDGE_BACKENDS)[number]
|
||||
|
||||
export const knowledgeBackendSchema = z.enum(KNOWLEDGE_BACKENDS)
|
||||
|
||||
/** Which search backend this deployment binds; "vectors" unless the operator says otherwise. */
|
||||
export function knowledgeBackendOf(env: Record<string, string | undefined> = process.env): KnowledgeBackendChoice {
|
||||
const raw = env[KNOWLEDGE_BACKEND_VAR]?.trim().toLowerCase()
|
||||
if (raw === undefined || raw === "") return "vectors"
|
||||
const parsed = knowledgeBackendSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`${KNOWLEDGE_BACKEND_VAR} must be one of ${KNOWLEDGE_BACKENDS.join(", ")}`)
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
export const EMBEDDING_MODEL_VAR = "AI_EMBEDDING_MODEL"
|
||||
|
||||
/** The embeddings model the vector backend asks the gateway for. */
|
||||
export function embeddingModelOf(env: Record<string, string | undefined> = process.env): string | undefined {
|
||||
const chosen = env[EMBEDDING_MODEL_VAR]?.trim()
|
||||
return chosen ? chosen : undefined
|
||||
}
|
||||
|
||||
export const VECTOR_DIMENSIONS_VAR = "VECTOR_DIMENSIONS"
|
||||
|
||||
export const DEFAULT_VECTOR_DIMENSIONS = 1536
|
||||
|
||||
/** The vector size the store is built with; must match the embeddings model's output. */
|
||||
export function vectorDimensionsOf(env: Record<string, string | undefined> = process.env): number {
|
||||
const raw = env[VECTOR_DIMENSIONS_VAR]?.trim()
|
||||
if (raw === undefined || raw === "") return DEFAULT_VECTOR_DIMENSIONS
|
||||
const value = Number(raw)
|
||||
if (!Number.isInteger(value) || value < 1 || value > 16_000) {
|
||||
throw new Error(`${VECTOR_DIMENSIONS_VAR} must be an integer between 1 and 16000`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Where the vectors backend keeps its vectors (decision 2026-10-25, managed-AI closeout):
|
||||
//
|
||||
// KNOWLEDGE_VECTOR_STORE unset (default): Qdrant when a Qdrant service is linked (QDRANT_URL),
|
||||
// otherwise NO store — the knowledge module reports itself unavailable
|
||||
// with the reason below, and the migrate job creates nothing.
|
||||
// "pgvector": the app's own Postgres, opted into explicitly. The
|
||||
// platform's default managed Postgres image does not ship the extension;
|
||||
// pgvector needs a Postgres created from the pgvector image.
|
||||
// "qdrant": Qdrant, required — unavailable until QDRANT_URL is set.
|
||||
//
|
||||
// pgvector used to be the silent default whenever no Qdrant was linked, so a fresh app on the
|
||||
// default managed Postgres failed its migrate job (`extension "vector" is not available`) before
|
||||
// anyone had asked for document AI. Enabling document AI links a managed Qdrant, which is the
|
||||
// product path; pgvector stays a deliberate choice for databases that offer it.
|
||||
|
||||
export const KNOWLEDGE_VECTOR_STORE_VAR = "KNOWLEDGE_VECTOR_STORE"
|
||||
|
||||
export const VECTOR_STORES = ["qdrant", "pgvector"] as const
|
||||
export type VectorStoreChoice = (typeof VECTOR_STORES)[number]
|
||||
|
||||
const vectorStoreSchema = z.enum(VECTOR_STORES)
|
||||
|
||||
/** Why the vectors backend has no store, for the knowledge status a person reads. */
|
||||
export const NO_VECTOR_STORE_REASON =
|
||||
"no vector store is configured: enable document AI to link a managed Qdrant, set KNOWLEDGE_VECTOR_STORE=pgvector on a Postgres that has the pgvector extension, or set KNOWLEDGE_BACKEND=platform"
|
||||
|
||||
/**
|
||||
* The vector store this deployment binds, or null when the vectors backend has none (or the
|
||||
* backend is the platform's). Explicit pgvector only: never inferred from a missing Qdrant.
|
||||
*/
|
||||
export function vectorStoreOf(environment: Record<string, string | undefined> = process.env): VectorStoreChoice | null {
|
||||
if (knowledgeBackendOf(environment) !== "vectors") return null
|
||||
const raw = environment[KNOWLEDGE_VECTOR_STORE_VAR]?.trim().toLowerCase()
|
||||
const linkedQdrant = Boolean(environment.QDRANT_URL?.trim())
|
||||
if (raw === undefined || raw === "") return linkedQdrant ? "qdrant" : null
|
||||
const parsed = vectorStoreSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`${KNOWLEDGE_VECTOR_STORE_VAR} must be one of ${VECTOR_STORES.join(", ")}`)
|
||||
}
|
||||
if (parsed.data === "qdrant" && !linkedQdrant) return null
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
/**
|
||||
* The runtime-built migration that creates the pgvector table, for a deployment that chose the
|
||||
* pgvector store. The migrate job applies it; the web readiness probe expects exactly its name, so
|
||||
* the decision lives here, in the one place that reads the same environment.
|
||||
*/
|
||||
export function vectorStoreMigrationName(environment: Record<string, string | undefined> = process.env): string | null {
|
||||
return vectorStoreOf(environment) === "pgvector" ? "0006_vectors" : null
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The app brand (decision AT-D11, spec §10.2). This file is the one place a customer replaces
|
||||
// ConusAI's name, logo and colours with their own: the neutral, status and type tokens stay with
|
||||
// the brand package, so a replacement cannot break contrast in either theme.
|
||||
//
|
||||
// `defineBrand` is the whole API. It checks what it is given -- a name, hex colours, logo paths on
|
||||
// this app's own origin, tokens that cannot break out of a style attribute -- and derives the
|
||||
// colours' `--brand-*` layer, including the per-theme ink that keeps a link legible (color.ts). The
|
||||
// layout writes that layer on `<html>` on the server, so there is no flash of another brand.
|
||||
//
|
||||
// A per-organization brand (an org's own colours, logos and name) is a runtime layer over this one,
|
||||
// rendered by the organization layout from `organizations.branding` (modules/branding).
|
||||
|
||||
import { type BrandReport, isHexColor, themeStyle, tokens } from "@conusai-kit/brand"
|
||||
|
||||
export interface AppBrand {
|
||||
/** The product name, used in the document title, the shell and e-mail subjects. */
|
||||
readonly name: string
|
||||
/** The short name for narrow places (a tab, a badge). */
|
||||
readonly shortName: string
|
||||
/** The fill colour of buttons and focus rings, `#rgb` or `#rrggbb`. Absent keeps the ConusAI palette. */
|
||||
readonly primary?: string
|
||||
/** A secondary tint for highlights, `#rgb` or `#rrggbb`. */
|
||||
readonly accent?: string
|
||||
/** A path under the app's own origin. No external host serves a logo (self-hosted D20). */
|
||||
readonly logo: { readonly light: string; readonly dark: string; readonly favicon: string }
|
||||
/** Overrides of the `--brand-*` layer, beyond what `primary` and `accent` derive. An empty object adds none. */
|
||||
readonly tokens: Readonly<Record<string, string>>
|
||||
/** "Built on ConusAI" in the footer. Off by default: the customer sells their own product. */
|
||||
readonly attribution: boolean
|
||||
/** The default locale when a user and their organization have none (kit-i18n negotiates the rest). */
|
||||
readonly defaultLocale: "en" | "lt"
|
||||
}
|
||||
|
||||
export interface BrandInput {
|
||||
readonly name: string
|
||||
readonly shortName?: string
|
||||
readonly primary?: string
|
||||
readonly accent?: string
|
||||
readonly logo?: AppBrand["logo"]
|
||||
readonly tokens?: Readonly<Record<string, string>>
|
||||
readonly attribution?: boolean
|
||||
readonly defaultLocale?: AppBrand["defaultLocale"]
|
||||
}
|
||||
|
||||
const TOKEN_NAME = /^(?:--brand-)?[a-z][a-z0-9-]*$/
|
||||
/** What a token value may be: colours, lengths and `light-dark()`/`color-mix()` of them. Not `;`, braces, quotes, `url(` or `\`. */
|
||||
const TOKEN_VALUE = /^[#a-zA-Z0-9(),.%\s/-]+$/
|
||||
const FORBIDDEN_IN_VALUE = /url\s*\(|expression\s*\(|@import|javascript:/i
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what is refused.
|
||||
const CONTROL = /[\u0000-\u001f\u007f]/
|
||||
|
||||
/** A path on this origin: one leading slash, no scheme and no second slash that would make it a host. */
|
||||
const isOwnPath = (value: string): boolean => /^\/(?!\/)[^\s\\]*$/.test(value) && !CONTROL.test(value)
|
||||
|
||||
/** Names the field and never the value: the value may be a customer's. */
|
||||
const refuse = (field: string, why: string): never => {
|
||||
throw new Error(`defineBrand: ${field} ${why}`)
|
||||
}
|
||||
|
||||
const printable = (field: string, value: string): string => {
|
||||
if (value.trim().length === 0 || value.length > 60 || CONTROL.test(value))
|
||||
refuse(field, "is 1 to 60 printable characters")
|
||||
return value
|
||||
}
|
||||
|
||||
export function defineBrand(input: BrandInput): AppBrand {
|
||||
const name = printable("name", input.name)
|
||||
const shortName = printable("shortName", input.shortName ?? input.name)
|
||||
for (const field of ["primary", "accent"] as const) {
|
||||
const value = input[field]
|
||||
if (value !== undefined && !isHexColor(value)) refuse(field, "is a hex colour such as #1a1a8c")
|
||||
}
|
||||
const logo = input.logo ?? { light: "/favicon.svg", dark: "/favicon.svg", favicon: "/favicon.svg" }
|
||||
for (const [variant, path] of Object.entries(logo)) {
|
||||
if (!isOwnPath(path)) refuse(`logo.${variant}`, "is a path on this app's own origin, starting with one slash")
|
||||
}
|
||||
const overrides = input.tokens ?? {}
|
||||
for (const [tokenName, value] of Object.entries(overrides)) {
|
||||
if (!TOKEN_NAME.test(tokenName)) refuse("token name", "is a lower-case custom property name such as radius-panel")
|
||||
if (!TOKEN_VALUE.test(value) || FORBIDDEN_IN_VALUE.test(value)) {
|
||||
refuse(`token ${tokenName}`, "may hold colours and lengths only: no url(), no ; and no braces")
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
name,
|
||||
shortName,
|
||||
...(input.primary === undefined ? {} : { primary: input.primary }),
|
||||
...(input.accent === undefined ? {} : { accent: input.accent }),
|
||||
logo: Object.freeze({ ...logo }),
|
||||
tokens: Object.freeze({ ...overrides }),
|
||||
attribution: input.attribution ?? false,
|
||||
defaultLocale: input.defaultLocale ?? "en",
|
||||
})
|
||||
}
|
||||
|
||||
export const appBrand: AppBrand = defineBrand({
|
||||
name: "Example App",
|
||||
shortName: "Example",
|
||||
// Files under apps/web/public. The favicon that ships is a monochrome placeholder; the two
|
||||
// logos are the ones you add when this app becomes yours.
|
||||
logo: { light: "/favicon.svg", dark: "/favicon.svg", favicon: "/favicon.svg" },
|
||||
// primary: "#1a1a8c", // your fill colour; the ink for links is derived per theme
|
||||
// accent: "#f4c430",
|
||||
attribution: false,
|
||||
defaultLocale: "en",
|
||||
})
|
||||
|
||||
/**
|
||||
* This app's brand as custom properties, for the `style` attribute of one element: what `primary`
|
||||
* and `accent` derive, then the explicit tokens (which win). A name may be written `--brand-primary`
|
||||
* or just `primary`.
|
||||
*/
|
||||
export function brandTokenStyle(brand: AppBrand = appBrand): Record<string, string> {
|
||||
const derived = themeStyle({
|
||||
...(brand.primary === undefined ? {} : { primary: brand.primary }),
|
||||
...(brand.accent === undefined ? {} : { accent: brand.accent }),
|
||||
}).style
|
||||
return {
|
||||
...derived,
|
||||
...Object.fromEntries(
|
||||
Object.entries(brand.tokens).map(([name, value]) => [name.startsWith("--") ? name : `--brand-${name}`, value]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/** What the colours of this brand measure in each theme, for a settings page or a test. */
|
||||
export function brandContrast(brand: AppBrand = appBrand): BrandReport {
|
||||
return themeStyle(brand.primary === undefined ? {} : { primary: brand.primary }).report
|
||||
}
|
||||
|
||||
/** Proof that the brand package is the only source of colour: the app ships no literal of its own. */
|
||||
export const BRAND_TOKEN_NAMES: readonly string[] = Object.keys(tokens)
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The one setting of the documents module's extraction that a deployment chooses: which kinds of page
|
||||
// the model is never asked about. The kinds themselves (`text`, `table`, `drawing`, `scan`, `empty`) are
|
||||
// the module's vocabulary and are checked where they are used (`pageClassesOf` of modules/documents);
|
||||
// this only reads the variable, so the configuration package depends on no module.
|
||||
|
||||
export const DOCUMENTS_EXCLUDED_PAGE_CLASSES_VAR = "DOCUMENTS_EXCLUDED_PAGE_CLASSES"
|
||||
|
||||
/**
|
||||
* The page classes named by `DOCUMENTS_EXCLUDED_PAGE_CLASSES`, a comma-separated list (`drawing,empty`);
|
||||
* undefined when it is not set, so the module's own default applies. An empty value is a list of
|
||||
* nothing: every page is read.
|
||||
*/
|
||||
export function excludedPageClassNamesOf(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): readonly string[] | undefined {
|
||||
const raw = env[DOCUMENTS_EXCLUDED_PAGE_CLASSES_VAR]
|
||||
if (raw === undefined) return undefined
|
||||
return raw
|
||||
.split(",")
|
||||
.map((name) => name.trim())
|
||||
.filter((name) => name !== "")
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The deployment contract of this app, per process (app-template spec §9.1, decision AT-D20).
|
||||
//
|
||||
// @conusai-kit/platform parses what the platform injects for every service; the three Postgres
|
||||
// roles of a linked managed database (data-services DS21) are read here, because which of them a
|
||||
// process may hold is this app's own rule:
|
||||
//
|
||||
// web, worker POSTGRES_URL (the runtime role) and POSTGRES_READER_URL (the global reader)
|
||||
// migrate the same two, plus POSTGRES_MIGRATE_URL (the owner), which the platform sends
|
||||
// only to the one service whose conusai.json entry declares postgres.migrate
|
||||
//
|
||||
// A process that is handed a URL it may not hold does not start: the owner's URL in the web
|
||||
// container would make every row-level-security policy advisory. There is no SET ROLE anywhere in
|
||||
// this app, and no code path that reaches a role other than the one its own URL names (AT-O8).
|
||||
//
|
||||
// This file moves into @conusai-kit/platform with plan item AT4.4, which is where the kit learns
|
||||
// the three names; until then the contract lives with the app that needs it.
|
||||
|
||||
import {
|
||||
type DeploymentEnv,
|
||||
DeploymentEnvError,
|
||||
type Profile,
|
||||
parseDeploymentEnv,
|
||||
requireProfileLink,
|
||||
} from "@conusai-kit/platform"
|
||||
|
||||
/** The persistence profile this app was generated on (spec §5.1). `kit template:prune` removes the others. */
|
||||
export const PROFILE: Profile = "postgres"
|
||||
|
||||
/** The processes of this app, which are the services of compose.yaml and conusai.json. */
|
||||
export const SERVICES = ["web", "worker", "migrate"] as const
|
||||
export type Service = (typeof SERVICES)[number]
|
||||
|
||||
export const RUNTIME_URL_VAR = "POSTGRES_URL"
|
||||
export const READER_URL_VAR = "POSTGRES_READER_URL"
|
||||
export const MIGRATE_URL_VAR = "POSTGRES_MIGRATE_URL"
|
||||
|
||||
const LINK_A_POSTGRES = "link a Postgres service to this project"
|
||||
|
||||
export interface PostgresAccess {
|
||||
/** The non-owner role that FORCE row-level security binds. Every request and every job uses it. */
|
||||
readonly runtime: string
|
||||
/** The global reader: the few reads that belong to no organization, and only what the migrations granted it. */
|
||||
readonly reader: string
|
||||
/** The owner. Present in the migrate job alone, which is the only process that writes the schema. */
|
||||
readonly owner?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the platform's services (Blob, OCR, the AI gateway, audit, usage, observability) come from.
|
||||
*
|
||||
* platform the ConusAI platform, with the three variables it injects into every service
|
||||
* fake the fake platform of @conusai-kit/testing, selected on purpose by CONUSAI_FAKE_PLATFORM=1
|
||||
* in development or in a test, and refused everywhere else
|
||||
*/
|
||||
export type PlatformMode = "platform" | "fake"
|
||||
|
||||
export interface AppEnv {
|
||||
readonly service: Service
|
||||
readonly deployment: DeploymentEnv
|
||||
readonly postgres?: PostgresAccess
|
||||
readonly convex?: { readonly url: string; readonly adminKey: string }
|
||||
readonly platform: PlatformMode
|
||||
/** Whether this is a production run: anything that is not development or a test is. */
|
||||
readonly production: boolean
|
||||
}
|
||||
|
||||
export const FAKE_PLATFORM_VAR = "CONUSAI_FAKE_PLATFORM"
|
||||
|
||||
/** What the platform injects into every service of a linked project (spec §9.1). */
|
||||
export const PLATFORM_VARS = ["CONUSAI_API_URL", "CONUSAI_API_TOKEN", "CONUSAI_PROJECT_ID"] as const
|
||||
|
||||
/**
|
||||
* A variable of the platform contract that is missing or may not be set here. It names the variable
|
||||
* in `variable` as well as in the message, so a caller (and a test) never has to parse a sentence.
|
||||
*/
|
||||
export class PlatformEnvError extends DeploymentEnvError {
|
||||
constructor(
|
||||
readonly variable: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "PlatformEnvError"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Production is the default: only `NODE_ENV=development` and `NODE_ENV=test` are not. A process
|
||||
* started with no `NODE_ENV` at all is therefore held to the production rules, which is the safe
|
||||
* way round -- the fake platform and the dev sign-in must be asked for, never fallen into.
|
||||
*/
|
||||
export const isProduction = (env: Record<string, string | undefined>): boolean =>
|
||||
env.NODE_ENV !== "development" && env.NODE_ENV !== "test"
|
||||
|
||||
/** The address a development process (`pnpm dev`) is reached at when the deployment states none. */
|
||||
const DEV_PUBLIC_URL = "http://localhost:3000"
|
||||
|
||||
/**
|
||||
* The app's public address, from the deployment contract (`CONUSAI_PUBLIC_URL`). A production run
|
||||
* without it is refused by name: a link, a share image or an invitation built on a guessed host
|
||||
* is wrong in a way no one sees until a customer clicks it. Only development and tests may do without.
|
||||
*/
|
||||
export function publicUrlOf(env: Record<string, string | undefined> = process.env): string {
|
||||
const url = env.CONUSAI_PUBLIC_URL
|
||||
if (url) return url
|
||||
if (isProduction(env)) {
|
||||
throw new PlatformEnvError(
|
||||
"CONUSAI_PUBLIC_URL",
|
||||
"CONUSAI_PUBLIC_URL is not set: a production run needs the address the platform routes to this app (the deployment contract injects it once the app has a public hostname)",
|
||||
)
|
||||
}
|
||||
return DEV_PUBLIC_URL
|
||||
}
|
||||
|
||||
const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]", "::1"])
|
||||
|
||||
/**
|
||||
* Which platform this process talks to, decided once and out loud. Every refusal is a
|
||||
* PlatformEnvError that names the variable and what to do about it.
|
||||
*/
|
||||
function platformMode(env: Record<string, string | undefined>, production: boolean): PlatformMode {
|
||||
const selected = ["1", "true"].includes(env[FAKE_PLATFORM_VAR]?.trim().toLowerCase() ?? "")
|
||||
if (selected && production) {
|
||||
throw new PlatformEnvError(
|
||||
FAKE_PLATFORM_VAR,
|
||||
`${FAKE_PLATFORM_VAR} is set in a production run: the fake platform is for development and tests only (NODE_ENV=development or test); remove it and link this project to the platform`,
|
||||
)
|
||||
}
|
||||
const how = production
|
||||
? "link this project to the platform, which injects it into every service"
|
||||
: `run \`pnpm dev\`, which starts the fake platform, or set ${FAKE_PLATFORM_VAR}=1 with ${PLATFORM_VARS.join(", ")} pointing at one`
|
||||
for (const name of PLATFORM_VARS) {
|
||||
if (env[name] === undefined || env[name]?.trim() === "") {
|
||||
throw new PlatformEnvError(name, `${name} is not set: ${how}`)
|
||||
}
|
||||
}
|
||||
if (selected) {
|
||||
// A fake platform that is a real one, with the real token in the request, is the mistake this
|
||||
// check exists for: the flag says "nothing here is real", so the address has to agree.
|
||||
let host: string
|
||||
try {
|
||||
host = new URL(env.CONUSAI_API_URL as string).hostname
|
||||
} catch {
|
||||
throw new PlatformEnvError("CONUSAI_API_URL", "CONUSAI_API_URL is not a URL")
|
||||
}
|
||||
// Loopback, or a one-label name such as a compose service: a dotted name is somebody's DNS.
|
||||
if (!LOOPBACK.has(host) && host.includes(".")) {
|
||||
throw new PlatformEnvError(
|
||||
"CONUSAI_API_URL",
|
||||
`${FAKE_PLATFORM_VAR}=1 but CONUSAI_API_URL names ${host}: the fake platform runs on this machine or on the compose network, never at a public address`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return selected ? "fake" : "platform"
|
||||
}
|
||||
|
||||
const required = (env: Record<string, string | undefined>, name: string, why: string): string => {
|
||||
const value = env[name]
|
||||
if (value === undefined || value.trim() === "") throw new DeploymentEnvError(`${name} is not set: ${why}`)
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
const refused = (name: string, service: Service, why: string): never => {
|
||||
throw new DeploymentEnvError(`${name} is set for the ${service} process: ${why}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* The role a connection string names, for the one statement that needs the name itself: the
|
||||
* `grant` the migrations give the global reader. A name that is not a plain identifier is refused
|
||||
* rather than quoted and hoped for.
|
||||
*/
|
||||
export function roleOf(url: string): string {
|
||||
const user = (() => {
|
||||
try {
|
||||
return decodeURIComponent(new URL(url).username)
|
||||
} catch {
|
||||
throw new DeploymentEnvError("a Postgres URL is not a URL")
|
||||
}
|
||||
})()
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_$]*$/.test(user)) {
|
||||
throw new DeploymentEnvError(`the role "${user}" in a Postgres URL is not a plain identifier`)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the environment of one process. Fails at boot, naming the variable and what to do, so a
|
||||
* misconfigured deployment never reaches its first request (the Showcase pattern, spec §9.1).
|
||||
*/
|
||||
export function loadAppEnv(
|
||||
service: Service,
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
profile: Profile = PROFILE,
|
||||
): AppEnv {
|
||||
const production = isProduction(env)
|
||||
const platform = platformMode(env, production)
|
||||
const deployment = parseDeploymentEnv(env)
|
||||
requireProfileLink(profile, deployment)
|
||||
// Usage is metered against the ConusAI organization that owns this deployment, which the platform
|
||||
// injects (plan item AT4.1). A production process without it would run and meter nothing, which
|
||||
// is a billing fault nobody sees; a development run meters against `dev` on the fake platform.
|
||||
if (production && service !== "migrate" && !deployment.CONUSAI_ORG_ID?.trim()) {
|
||||
throw new PlatformEnvError(
|
||||
"CONUSAI_ORG_ID",
|
||||
"CONUSAI_ORG_ID is not set: the platform injects it into every service, and usage cannot be metered without it",
|
||||
)
|
||||
}
|
||||
if (production && service !== "migrate" && !deployment.CONUSAI_ORG_SLUG?.trim()) {
|
||||
throw new PlatformEnvError(
|
||||
"CONUSAI_ORG_SLUG",
|
||||
"CONUSAI_ORG_SLUG is not set: the platform injects the owning organization slug, and usage billing routes by slug",
|
||||
)
|
||||
}
|
||||
|
||||
if (profile === "convex") {
|
||||
if (service !== "web") {
|
||||
throw new DeploymentEnvError(`the ${service} process is not supported by the Convex app runtime`)
|
||||
}
|
||||
if (env[MIGRATE_URL_VAR]?.trim()) {
|
||||
refused(MIGRATE_URL_VAR, service, "Convex deployments do not use a Postgres owner URL")
|
||||
}
|
||||
return {
|
||||
service,
|
||||
deployment,
|
||||
convex: {
|
||||
url: required(env, "CONVEX_SELF_HOSTED_URL", "link a Convex service to this project"),
|
||||
adminKey: required(env, "CONVEX_SELF_HOSTED_ADMIN_KEY", "the server-side key of the linked Convex service"),
|
||||
},
|
||||
platform,
|
||||
production,
|
||||
}
|
||||
}
|
||||
|
||||
if (profile !== "postgres") {
|
||||
throw new DeploymentEnvError(`the ${profile} profile is not supported by the app runtime yet`)
|
||||
}
|
||||
const runtime = required(env, RUNTIME_URL_VAR, `the runtime role of this app's database (${LINK_A_POSTGRES})`)
|
||||
const reader = required(env, READER_URL_VAR, `the global reader of this app's database (${LINK_A_POSTGRES})`)
|
||||
const owner = env[MIGRATE_URL_VAR]?.trim()
|
||||
|
||||
// Two logins that are the same login are one wall, not two: the reader's policies would then
|
||||
// apply to the runtime role and the tenant predicate would be advisory for the three tables the
|
||||
// migrations grant it.
|
||||
if (roleOf(runtime) === roleOf(reader)) {
|
||||
throw new DeploymentEnvError(
|
||||
`${RUNTIME_URL_VAR} and ${READER_URL_VAR} name the same role, "${roleOf(runtime)}": the runtime role and the global reader are separate logins, or the grants the migrations give the reader apply to every request`,
|
||||
)
|
||||
}
|
||||
|
||||
if (service === "migrate") {
|
||||
if (!owner) {
|
||||
throw new DeploymentEnvError(
|
||||
`${MIGRATE_URL_VAR} is not set for the migrate job: it is the owner's URL, and the platform sends it only to the service whose conusai.json entry declares postgres.migrate (${LINK_A_POSTGRES})`,
|
||||
)
|
||||
}
|
||||
if (roleOf(owner) === roleOf(runtime) || roleOf(owner) === roleOf(reader)) {
|
||||
throw new DeploymentEnvError(
|
||||
`${MIGRATE_URL_VAR} names the same role as ${RUNTIME_URL_VAR} or ${READER_URL_VAR}: the owner, the runtime role and the reader are three separate logins, or row-level security is advisory`,
|
||||
)
|
||||
}
|
||||
return { service, deployment, postgres: { runtime, reader, owner }, platform, production }
|
||||
}
|
||||
|
||||
if (owner) {
|
||||
refused(
|
||||
MIGRATE_URL_VAR,
|
||||
service,
|
||||
"only the migrate job may hold the owner's URL; remove postgres.migrate from this service in conusai.json",
|
||||
)
|
||||
}
|
||||
return { service, deployment, postgres: { runtime, reader }, platform, production }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Feature flags with their defaults (spec §4.1). A module declares its own flags in its manifest;
|
||||
// these are the app-wide ones. A flag is read through the registry, never from this object
|
||||
// directly, so a module's flag and an app's flag are read the same way.
|
||||
|
||||
export const appFlags = {
|
||||
/** The in-app audit viewer for organization administrators (spec §9.4). */
|
||||
"app.audit-viewer": { default: true },
|
||||
/** Sign-in without an identity provider. Refused at boot in production by @conusai-kit/auth. */
|
||||
"app.dev-login": { default: false },
|
||||
} as const satisfies Record<string, { default: boolean }>
|
||||
|
||||
export type AppFlag = keyof typeof appFlags
|
||||
@@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What the apps and the modules of this repository read their configuration from.
|
||||
|
||||
export type { KnowledgeBackendChoice, VectorStoreChoice } from "./ai.js"
|
||||
export {
|
||||
AI_MODEL_VAR,
|
||||
aiModelOf,
|
||||
DEFAULT_AI_MODEL,
|
||||
DEFAULT_VECTOR_DIMENSIONS,
|
||||
EMBEDDING_MODEL_VAR,
|
||||
embeddingModelOf,
|
||||
KNOWLEDGE_BACKEND_VAR,
|
||||
KNOWLEDGE_BACKENDS,
|
||||
KNOWLEDGE_VECTOR_STORE_VAR,
|
||||
knowledgeBackendOf,
|
||||
NO_VECTOR_STORE_REASON,
|
||||
VECTOR_DIMENSIONS_VAR,
|
||||
VECTOR_STORES,
|
||||
vectorDimensionsOf,
|
||||
vectorStoreMigrationName,
|
||||
vectorStoreOf,
|
||||
} from "./ai.js"
|
||||
export {
|
||||
type AppBrand,
|
||||
appBrand,
|
||||
BRAND_TOKEN_NAMES,
|
||||
type BrandInput,
|
||||
brandContrast,
|
||||
brandTokenStyle,
|
||||
defineBrand,
|
||||
} from "./brand.js"
|
||||
export { DOCUMENTS_EXCLUDED_PAGE_CLASSES_VAR, excludedPageClassNamesOf } from "./documents.js"
|
||||
export {
|
||||
type AppEnv,
|
||||
FAKE_PLATFORM_VAR,
|
||||
isProduction,
|
||||
loadAppEnv,
|
||||
MIGRATE_URL_VAR,
|
||||
PLATFORM_VARS,
|
||||
PlatformEnvError,
|
||||
type PlatformMode,
|
||||
type PostgresAccess,
|
||||
PROFILE,
|
||||
publicUrlOf,
|
||||
READER_URL_VAR,
|
||||
RUNTIME_URL_VAR,
|
||||
roleOf,
|
||||
SERVICES,
|
||||
type Service,
|
||||
} from "./env.js"
|
||||
export { type AppFlag, appFlags } from "./flags.js"
|
||||
export {
|
||||
appMigrationFiles,
|
||||
appRoot,
|
||||
expectedMigrations,
|
||||
GRANT_MIGRATIONS,
|
||||
type MigrationFile,
|
||||
missingMigrations,
|
||||
moduleMigrationFiles,
|
||||
} from "./migrations.js"
|
||||
export {
|
||||
createRedactor,
|
||||
currentObservability,
|
||||
type ErrorTrackingClient,
|
||||
FILTERED,
|
||||
type LogAttributes,
|
||||
type Logger,
|
||||
type LogLevel,
|
||||
type ObservabilityConfig,
|
||||
type Otlp,
|
||||
observabilityConfig,
|
||||
observabilityLine,
|
||||
quietObservability,
|
||||
type Redactor,
|
||||
type ServerObservability,
|
||||
type StartOptions,
|
||||
secretsOf,
|
||||
startObservability,
|
||||
} from "./observability.js"
|
||||
export { auditEventsOf, DEV_USAGE_ORG, type PlatformClients, platformClients, probePlatform } from "./platform.js"
|
||||
export { assertBoundByRowLevelSecurity, assertOwnsSchema, type RoleFacts, roleFacts, roleLine } from "./postgres.js"
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Which migrations this build of the app expects to have run: the one list the migrate job applies
|
||||
// from and the web shell's readiness probe compares the database against, so the two cannot
|
||||
// disagree about what "the schema is current" means.
|
||||
//
|
||||
// The files are found from the process's working directory upwards, not from the location of this
|
||||
// module: the web shell is bundled by Next.js, where `import.meta.url` is a chunk path, and every
|
||||
// process of this app (compose.yaml, `pnpm dev`, the tests) starts inside the repository.
|
||||
|
||||
import { existsSync } from "node:fs"
|
||||
import { readdir } from "node:fs/promises"
|
||||
import { dirname, join, resolve } from "node:path"
|
||||
import type { Registry } from "@conusai-kit/core"
|
||||
|
||||
/** What the migrate job runs after the files, in this order: their names are part of the level. */
|
||||
export const GRANT_MIGRATIONS = ["0002_reader_grants", "0003_job_schema_grants"] as const
|
||||
|
||||
const SQL_FILE = /^\d{4}_[a-z0-9-]+\.sql$/
|
||||
|
||||
export interface MigrationFile {
|
||||
/** The name the runner records: `0001_kernel`, or `<module>/<file>` for a module's. */
|
||||
readonly name: string
|
||||
readonly path: string
|
||||
}
|
||||
|
||||
/** The repository root: the nearest directory at or above `from` that holds `migrations/postgres`. */
|
||||
export function appRoot(from: string = process.cwd()): string {
|
||||
// The folder is found at run time from where the process was started and never bundled: Turbopack must
|
||||
// not trace the whole repository for it (it would ship every source file in the output).
|
||||
let dir = resolve(/*turbopackIgnore: true*/ from)
|
||||
for (;;) {
|
||||
if (existsSync(join(dir, "migrations", "postgres"))) return dir
|
||||
const parent = dirname(dir)
|
||||
if (parent === dir) throw new Error(`no migrations/postgres folder at or above ${from}: run this inside the app`)
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
/** The app's own committed migrations, in file-name order, which is the order they were written in. */
|
||||
export async function appMigrationFiles(root: string = appRoot()): Promise<MigrationFile[]> {
|
||||
const dir = join(root, "migrations", "postgres")
|
||||
return (await readdir(dir))
|
||||
.filter((name) => SQL_FILE.test(name))
|
||||
.sort()
|
||||
.map((name) => ({ name: name.replace(/\.sql$/, ""), path: join(dir, name) }))
|
||||
}
|
||||
|
||||
/** Each module's SQL files, named `<module>/<file>` so two modules cannot collide. */
|
||||
export async function moduleMigrationFiles(registry: Registry, root: string = appRoot()): Promise<MigrationFile[]> {
|
||||
const files: MigrationFile[] = []
|
||||
for (const { module, path } of registry.migrations.postgres) {
|
||||
const dir = join(root, "modules", module, path)
|
||||
const names = (await readdir(dir)).filter((name) => name.endsWith(".sql")).sort()
|
||||
for (const name of names) files.push({ name: `${module}/${name.replace(/\.sql$/, "")}`, path: join(dir, name) })
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Every migration name a current schema has recorded, in the order the migrate job applies them.
|
||||
* `runtime` names the migrations the migrate job builds from the deployment (the pgvector table
|
||||
* when the vector backend needs one): the readiness probe and the migrate job must expect exactly
|
||||
* the same list, so both take them from here.
|
||||
*/
|
||||
export async function expectedMigrations(
|
||||
registry: Registry,
|
||||
root: string = appRoot(),
|
||||
runtime: readonly string[] = [],
|
||||
): Promise<string[]> {
|
||||
const files = [...(await appMigrationFiles(root)), ...(await moduleMigrationFiles(registry, root))]
|
||||
return [...files.map((file) => file.name), ...runtime, ...GRANT_MIGRATIONS]
|
||||
}
|
||||
|
||||
/** What a database that has `applied` still lacks of `expected`. Newer-than-this-build is not behind. */
|
||||
export const missingMigrations = (expected: readonly string[], applied: ReadonlySet<string>): string[] =>
|
||||
expected.filter((name) => !applied.has(name))
|
||||
@@ -0,0 +1,358 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Observability for the server processes (spec §9.5): errors, traces and logs, configured from the
|
||||
// deployment contract and from nothing else, with secrets and tokens taken out before anything
|
||||
// leaves the process.
|
||||
//
|
||||
// errors @conusai-sdk/observe-node `init`, when CONUSAI_DSN is set. Inside a request every event
|
||||
// carries the ids the edge attached (traceparent, X-Request-ID), so an error joins its
|
||||
// page's session, trace and request log.
|
||||
// traces `withSpan` over the OTLP exporter, one span per procedure and per job.
|
||||
// logs one JSON line on stdout, and the same record over OTLP when the platform configured it
|
||||
// (OTEL_EXPORTER_OTLP_ENDPOINT with CONUSAI_API_URL and CONUSAI_API_TOKEN), carrying the
|
||||
// request's trace id.
|
||||
//
|
||||
// A deployment without a DSN is legitimate -- a local run, a preview that has none -- and runs with
|
||||
// the errors off and one log line that says so, never with an exception at boot.
|
||||
|
||||
import type { DeploymentEnv } from "@conusai-kit/platform"
|
||||
import {
|
||||
type ErrorTrackingClient,
|
||||
type Event,
|
||||
ids,
|
||||
init,
|
||||
initOtlp,
|
||||
type ObserveNodeOptions,
|
||||
type Otlp,
|
||||
withSpan,
|
||||
} from "@conusai-sdk/observe-node"
|
||||
import type { AppEnv, Service } from "./env.js"
|
||||
|
||||
export type { ErrorTrackingClient, Otlp }
|
||||
|
||||
// ---------------------------------------------------------------- configuration
|
||||
|
||||
export interface ObservabilityConfig {
|
||||
readonly service: Service
|
||||
/** Errors are sent: CONUSAI_DSN is set. */
|
||||
readonly errors: boolean
|
||||
/** Logs and traces are sent over OTLP: the platform set OTEL_EXPORTER_OTLP_ENDPOINT. */
|
||||
readonly otlp: boolean
|
||||
readonly dsn?: string
|
||||
/** CONUSAI_RELEASE, else OTEL_SERVICE_VERSION (the platform derives it from the commit), else `dev`. */
|
||||
readonly release: string
|
||||
/** CONUSAI_ENVIRONMENT, else `production` or `development` by the run mode. */
|
||||
readonly environment: string
|
||||
/** OTEL_SERVICE_NAME, else `app-<service>`. */
|
||||
readonly serviceName: string
|
||||
}
|
||||
|
||||
const nonEmpty = (value: string | undefined): string | undefined => (value?.trim() ? value.trim() : undefined)
|
||||
|
||||
/** Reads the observability settings of one process from its validated deployment contract. */
|
||||
export function observabilityConfig(
|
||||
service: Service,
|
||||
deployment: DeploymentEnv,
|
||||
production: boolean,
|
||||
): ObservabilityConfig {
|
||||
const dsn = nonEmpty(deployment.CONUSAI_DSN)
|
||||
return {
|
||||
service,
|
||||
errors: dsn !== undefined,
|
||||
otlp: nonEmpty(deployment.OTEL_EXPORTER_OTLP_ENDPOINT) !== undefined,
|
||||
...(dsn ? { dsn } : {}),
|
||||
release: nonEmpty(deployment.CONUSAI_RELEASE) ?? nonEmpty(deployment.OTEL_SERVICE_VERSION) ?? "dev",
|
||||
environment: nonEmpty(deployment.CONUSAI_ENVIRONMENT) ?? (production ? "production" : "development"),
|
||||
serviceName: nonEmpty(deployment.OTEL_SERVICE_NAME) ?? `app-${service}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one line a process logs at start-up about what it will send, so an operator never has to guess
|
||||
* whether a quiet dashboard means a healthy app or a missing variable.
|
||||
*/
|
||||
export function observabilityLine(config: ObservabilityConfig): string {
|
||||
const errors = config.errors ? "errors on" : "errors off (CONUSAI_DSN is not set)"
|
||||
const otlp = config.otlp ? "logs and traces on" : "logs and traces off (OTEL_EXPORTER_OTLP_ENDPOINT is not set)"
|
||||
return `${config.service}: observability ${errors}; ${otlp}; release ${config.release}, environment ${config.environment}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- redaction
|
||||
|
||||
export const FILTERED = "[Filtered]"
|
||||
|
||||
/** A key that names a secret, whatever the value looks like. */
|
||||
const SECRET_KEY = /token|secret|passw|auth|cookie|session|csrf|credential|api[-_]?key|dsn|private|signature|bearer/i
|
||||
|
||||
/** The variables whose values are secrets of this process: the exact value is removed wherever it appears. */
|
||||
const SECRET_VARIABLES = [
|
||||
"AUTH_SESSION_SECRET",
|
||||
"CONUSAI_API_TOKEN",
|
||||
"CONUSAI_DSN",
|
||||
"CONVEX_ADMIN_KEY",
|
||||
"CONVEX_SELF_HOSTED_ADMIN_KEY",
|
||||
"MARIADB_URL",
|
||||
"MONGODB_URL",
|
||||
"OIDC_WEB_CLIENT_SECRET",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
"POSTGRES_URL",
|
||||
"POSTGRES_READER_URL",
|
||||
"POSTGRES_MIGRATE_URL",
|
||||
"REDIS_URL",
|
||||
"S3_SECRET_KEY",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
] as const
|
||||
|
||||
/** The values worth removing by equality: a secret shorter than eight characters would also eat ordinary words. */
|
||||
export function secretsOf(env: Record<string, string | undefined>): string[] {
|
||||
const values = new Set<string>()
|
||||
for (const name of SECRET_VARIABLES) {
|
||||
const value = env[name]?.trim()
|
||||
if (!value) continue
|
||||
if (value.length >= 8) values.add(value)
|
||||
// A connection string carries its password on its own too: take the password out as well.
|
||||
try {
|
||||
const url = new URL(value)
|
||||
const password = decodeURIComponent(url.password)
|
||||
// A development login whose password is its own name would turn the name into "[Filtered]" in every line.
|
||||
if (password.length >= 8 && password !== decodeURIComponent(url.username)) values.add(password)
|
||||
} catch {
|
||||
// not a URL
|
||||
}
|
||||
}
|
||||
return [...values].sort((a, b) => b.length - a.length)
|
||||
}
|
||||
|
||||
const PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [
|
||||
[/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, `$1 ${FILTERED}`],
|
||||
// A credential in a URL: scheme://user:password@host
|
||||
[/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]*@/gi, `$1${FILTERED}@`],
|
||||
// A JSON web token, wherever it turns up.
|
||||
[/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*/g, FILTERED],
|
||||
// An organization API key (kit-auth: cak_<prefix>_<secret>).
|
||||
[/\bcak_[A-Za-z0-9]{4,}_[A-Za-z0-9_-]{8,}/g, FILTERED],
|
||||
// An invitation link: the token is the credential, the organization in it is not.
|
||||
[/(\/invitations\/[^/\s?#]+\/)[^/\s?#]+/g, `$1${FILTERED}`],
|
||||
// ... and the same link inside a `return_to=` query value.
|
||||
[/(%2Finvitations%2F[^%\s&]+%2F)[^%\s&]+/gi, `$1${FILTERED}`],
|
||||
// A query parameter or a form field that carries a secret.
|
||||
[
|
||||
/([?&;]|\b)(access_token|refresh_token|id_token|token|api[-_]?key|secret|password|passwd|code|state|sig|signature|session|csrf)=([^&\s"']+)/gi,
|
||||
`$1$2=${FILTERED}`,
|
||||
],
|
||||
// An e-mail address is personal data; a log line has no use for it.
|
||||
[/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "[email]"],
|
||||
]
|
||||
|
||||
export interface Redactor {
|
||||
/** A string with every secret, token, credential and address in it taken out. */
|
||||
text(value: string): string
|
||||
/** A copy of any JSON-like value, secret-looking keys filtered and every string passed through `text`. */
|
||||
deep<T>(value: T): T
|
||||
/** An error event, with the ids that join it to its request left alone. */
|
||||
event(event: Event): Event
|
||||
}
|
||||
|
||||
/** Builds the redactor of one process from the exact secret values it holds. */
|
||||
export function createRedactor(secrets: readonly string[] = []): Redactor {
|
||||
const text = (value: string): string => {
|
||||
let out = value
|
||||
for (const secret of secrets) out = out.split(secret).join(FILTERED)
|
||||
for (const [pattern, replacement] of PATTERNS) out = out.replace(pattern, replacement)
|
||||
return out
|
||||
}
|
||||
|
||||
const deep = <T>(value: T, depth = 0, seen = new WeakSet<object>()): T => {
|
||||
if (typeof value === "string") return text(value) as T
|
||||
if (value === null || typeof value !== "object") return value
|
||||
if (depth > 12 || seen.has(value)) return FILTERED as T
|
||||
seen.add(value)
|
||||
if (Array.isArray(value)) return value.map((item) => deep(item, depth + 1, seen)) as T
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[key] = SECRET_KEY.test(key) && item !== null && item !== undefined ? FILTERED : deep(item, depth + 1, seen)
|
||||
}
|
||||
return out as T
|
||||
}
|
||||
|
||||
const event = (input: Event): Event => {
|
||||
// The ids that join an event to its request are keyed `session_id` and the like, which the key
|
||||
// rule would filter; they are identifiers the edge minted and carry nothing secret, so they stay.
|
||||
const { contexts, ...rest } = input
|
||||
const kept: Record<string, unknown> = {}
|
||||
const scrubbed: Record<string, unknown> = {}
|
||||
for (const [name, context] of Object.entries(contexts ?? {})) {
|
||||
if (name === "trace" || name === "conusai") kept[name] = context
|
||||
else scrubbed[name] = context
|
||||
}
|
||||
return { ...deep(rest), contexts: { ...deep(scrubbed), ...kept } } as Event
|
||||
}
|
||||
|
||||
return { text, deep: (value) => deep(value), event }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the process's logger and spans
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error"
|
||||
const LEVELS: readonly LogLevel[] = ["debug", "info", "warn", "error"]
|
||||
/** `LOG_LEVEL`, or `info` when it is unset or not one of the four. */
|
||||
const levelOf = (value: string | undefined): LogLevel => LEVELS.find((level) => level === value?.trim()) ?? "info"
|
||||
export type LogAttributes = Record<string, string | number | boolean>
|
||||
|
||||
export interface Logger {
|
||||
debug(message: string, attributes?: LogAttributes): void
|
||||
info(message: string, attributes?: LogAttributes): void
|
||||
warn(message: string, attributes?: LogAttributes): void
|
||||
error(message: string, attributes?: LogAttributes): void
|
||||
}
|
||||
|
||||
export interface ServerObservability {
|
||||
readonly config: ObservabilityConfig
|
||||
readonly log: Logger
|
||||
readonly redact: Redactor
|
||||
/** The error client, when a DSN is configured. */
|
||||
readonly errors?: ErrorTrackingClient
|
||||
/** The OTLP exporters, when the platform configured them and they are enabled. */
|
||||
readonly otlp?: Otlp
|
||||
/** Sends an error with the current request's ids; a no-op without a DSN. */
|
||||
captureException(error: unknown, tags?: Record<string, string>): void
|
||||
/** Runs `fn` as a span carrying these attributes (no personal data); just runs it when traces are off. */
|
||||
span<T>(name: string, attributes: LogAttributes, fn: () => T): T
|
||||
/** Sends what is queued; call it before the process exits. */
|
||||
shutdown(): Promise<void>
|
||||
}
|
||||
|
||||
export interface StartOptions {
|
||||
/** The environment the secrets are read from. Default `process.env`. */
|
||||
readonly env?: Record<string, string | undefined>
|
||||
/** Where a log line goes. Default stdout. */
|
||||
readonly out?: (line: string) => void
|
||||
/** The `fetch` the error transport uses; a test's recording one. */
|
||||
readonly fetch?: typeof fetch
|
||||
/**
|
||||
* Starts the error client. The worker uses the Node SDK's `init`; the web shell passes
|
||||
* `@conusai-sdk/observe-next`'s idempotent `observeRegister`, because Next calls `register()` once
|
||||
* per server worker and again on a hot reload, and the SDK must start exactly once.
|
||||
*/
|
||||
readonly initErrors?: (options: ObserveNodeOptions) => ErrorTrackingClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts observability for one process. Called once, at boot, before the first request or job.
|
||||
* Idempotent per process: a second call returns a new handle over the same SDK state, which the
|
||||
* SDK replaces, so Next's repeated `register()` in development does not stack process handlers.
|
||||
*/
|
||||
export function startObservability(app: AppEnv, options: StartOptions = {}): ServerObservability {
|
||||
const env = options.env ?? process.env
|
||||
const out = options.out ?? ((line: string) => process.stdout.write(`${line}\n`))
|
||||
const config = observabilityConfig(app.service, app.deployment, app.production)
|
||||
const redact = createRedactor(secretsOf(env))
|
||||
|
||||
const initErrors = options.initErrors ?? init
|
||||
const errors = config.dsn
|
||||
? initErrors({
|
||||
dsn: config.dsn,
|
||||
release: config.release,
|
||||
environment: config.environment,
|
||||
serverName: config.serviceName,
|
||||
// Never the cookies, the address or the authorization header; and what the SDK keeps is
|
||||
// scrubbed again here, so a message that quotes a secret does not leave with it.
|
||||
sendDefaultPii: false,
|
||||
beforeSend: (event) => redact.event(event),
|
||||
...(options.fetch ? { transportOptions: { fetch: options.fetch } } : {}),
|
||||
})
|
||||
: undefined
|
||||
|
||||
const otlpWanted = config.otlp
|
||||
const otlp = otlpWanted
|
||||
? initOtlp({
|
||||
serviceName: config.serviceName,
|
||||
serviceVersion: config.release,
|
||||
environment: config.environment,
|
||||
apiUrl: app.deployment.CONUSAI_API_URL,
|
||||
token: app.deployment.CONUSAI_API_TOKEN,
|
||||
projectId: app.deployment.CONUSAI_PROJECT_ID,
|
||||
...(options.fetch ? { fetch: options.fetch } : {}),
|
||||
})
|
||||
: undefined
|
||||
const exporting = otlp?.enabled ? otlp : undefined
|
||||
|
||||
const floor = LEVELS.indexOf(levelOf(env.LOG_LEVEL))
|
||||
const write = (level: LogLevel, message: string, attributes: LogAttributes = {}): void => {
|
||||
if (LEVELS.indexOf(level) < floor) return
|
||||
const clean = redact.deep(attributes)
|
||||
const text = redact.text(message)
|
||||
const correlation = ids()
|
||||
out(
|
||||
JSON.stringify({
|
||||
at: new Date().toISOString(),
|
||||
level,
|
||||
service: config.serviceName,
|
||||
message: text,
|
||||
...(correlation.trace_id ? { trace_id: correlation.trace_id } : {}),
|
||||
...(correlation.request_id ? { request_id: correlation.request_id } : {}),
|
||||
...clean,
|
||||
}),
|
||||
)
|
||||
exporting?.logger.emit({ body: text, severity: level, attributes: clean })
|
||||
}
|
||||
|
||||
const log: Logger = {
|
||||
debug: (message, attributes) => write("debug", message, attributes),
|
||||
info: (message, attributes) => write("info", message, attributes),
|
||||
warn: (message, attributes) => write("warn", message, attributes),
|
||||
error: (message, attributes) => write("error", message, attributes),
|
||||
}
|
||||
|
||||
log.info(observabilityLine(config))
|
||||
if (config.otlp && !exporting) {
|
||||
log.warn(
|
||||
`${config.service}: OTEL_EXPORTER_OTLP_ENDPOINT is set but the OTLP exporter is disabled (CONUSAI_API_URL or CONUSAI_API_TOKEN is missing), so logs and traces are dropped`,
|
||||
)
|
||||
}
|
||||
|
||||
const handle: ServerObservability = {
|
||||
config,
|
||||
log,
|
||||
redact,
|
||||
...(errors ? { errors } : {}),
|
||||
...(exporting ? { otlp: exporting } : {}),
|
||||
captureException(error, tags) {
|
||||
errors?.captureException(error, tags ? { tags } : undefined)
|
||||
},
|
||||
span(name, attributes, fn) {
|
||||
return exporting ? withSpan(name, fn, { attributes: redact.deep(attributes) }) : fn()
|
||||
},
|
||||
async shutdown() {
|
||||
await Promise.all([errors?.flush(2_000), exporting?.shutdown(2_000)])
|
||||
},
|
||||
}
|
||||
holder()[CURRENT] = handle
|
||||
return handle
|
||||
}
|
||||
|
||||
// The handle is kept on `globalThis`, unlike the web runtime: it holds no kit module, so every module
|
||||
// graph of the process can share it (the error hook of `instrumentation.ts` reports through the very
|
||||
// client the routes started).
|
||||
const CURRENT = Symbol.for("conusai.app.observability")
|
||||
const holder = (): Record<symbol, ServerObservability | undefined> =>
|
||||
globalThis as unknown as Record<symbol, ServerObservability | undefined>
|
||||
|
||||
/** The observability this process started, or nothing when no request or job has started it yet. */
|
||||
export function currentObservability(): ServerObservability | undefined {
|
||||
return holder()[CURRENT]
|
||||
}
|
||||
|
||||
/** A handle that sends nothing, for a process or a test that starts no observability. */
|
||||
export function quietObservability(app: AppEnv): ServerObservability {
|
||||
const config = observabilityConfig(app.service, app.deployment, app.production)
|
||||
const redact = createRedactor()
|
||||
const log: Logger = { debug() {}, info() {}, warn() {}, error() {} }
|
||||
return {
|
||||
config,
|
||||
log,
|
||||
redact,
|
||||
captureException() {},
|
||||
span: (_name, _attributes, fn) => fn(),
|
||||
async shutdown() {},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The platform's services as this app uses them (spec §9): the AI gateway, OCR, Blob, the audit log
|
||||
// and usage metering, each built once from the deployment contract that env.ts validated.
|
||||
//
|
||||
// One function builds all five, and both composition roots (apps/web and apps/worker) call it, so
|
||||
// the two processes cannot disagree about which service an adapter talks to. What differs between
|
||||
// them is which module tokens each binds the clients to, and that stays in the roots.
|
||||
|
||||
import type { Registry } from "@conusai-kit/core"
|
||||
import {
|
||||
type AuditLog,
|
||||
createAuditLog,
|
||||
createDocumentReader,
|
||||
createModelGateway,
|
||||
createObjectStore,
|
||||
createUsage,
|
||||
type DeploymentEnv,
|
||||
type DocumentReader,
|
||||
type ModelGateway,
|
||||
type ObjectStore,
|
||||
type Usage,
|
||||
} from "@conusai-kit/platform"
|
||||
import { defineAuditEvents } from "@conusai-sdk/node-sdk/audit"
|
||||
import {
|
||||
aiModelOf,
|
||||
embeddingModelOf,
|
||||
knowledgeBackendOf,
|
||||
type VectorStoreChoice,
|
||||
vectorDimensionsOf,
|
||||
vectorStoreOf,
|
||||
} from "./ai.js"
|
||||
import type { AppEnv, PlatformMode } from "./env.js"
|
||||
|
||||
export interface PlatformClients {
|
||||
readonly mode: PlatformMode
|
||||
/** Blob, through `@conusai-sdk/blob`. */
|
||||
readonly objectStore: ObjectStore
|
||||
/** The hash-chained audit log, through `@conusai-sdk/node-sdk/audit`, with every module's events declared. */
|
||||
readonly audit: AuditLog
|
||||
/** Usage metering, through `@conusai-sdk/usage`, against the ConusAI organization that owns this deployment. */
|
||||
readonly usage: Usage
|
||||
/** OCR, through `@conusai-sdk/ocr`. */
|
||||
readonly documents: DocumentReader
|
||||
/** The AI gateway, through `@conusai-sdk/ai`, asking for `model` unless a call names another. */
|
||||
readonly models: ModelGateway
|
||||
readonly model: string
|
||||
/** The deployment contract itself, for clients addressed per organization and project. */
|
||||
readonly deployment: Pick<
|
||||
DeploymentEnv,
|
||||
"CONUSAI_API_URL" | "CONUSAI_API_TOKEN" | "CONUSAI_ORG_ID" | "CONUSAI_ORG_SLUG" | "CONUSAI_PROJECT_ID"
|
||||
>
|
||||
/** The knowledge module's deployment choices and the vector backing, if one is linked or set. */
|
||||
readonly knowledge: {
|
||||
readonly backend: "vectors" | "platform"
|
||||
/** Where the vectors backend keeps its vectors; null when it has no store (it is unavailable). */
|
||||
readonly vectorStore: VectorStoreChoice | null
|
||||
readonly embeddingModel?: string
|
||||
readonly dimensions: number
|
||||
readonly qdrantUrl?: string
|
||||
readonly qdrantApiKey?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The audit actions this app writes, built from the registry: every module declares its own in its
|
||||
* manifest, so this list is the manifests' list and cannot drift from them.
|
||||
*/
|
||||
export function auditEventsOf(registry: Registry): Record<string, { target: string }> {
|
||||
return Object.fromEntries(Object.keys(registry.audit).map((action) => [action, { target: "organization" }]))
|
||||
}
|
||||
|
||||
/** The organization usage is metered against when no platform has injected one: development only. */
|
||||
export const DEV_USAGE_ORG = "dev"
|
||||
|
||||
export function platformClients(
|
||||
app: AppEnv,
|
||||
registry: Registry,
|
||||
environment: Record<string, string | undefined> = process.env,
|
||||
): PlatformClients {
|
||||
const { deployment } = app
|
||||
const model = aiModelOf(environment)
|
||||
const embeddingModel = embeddingModelOf(environment)
|
||||
const org = deployment.CONUSAI_ORG_SLUG?.trim() || deployment.CONUSAI_ORG_ID?.trim()
|
||||
// env.ts refuses a production process without CONUSAI_ORG_ID, so the fallback is only reachable in
|
||||
// development and tests, where the fake platform keeps the facts under whatever name it is given.
|
||||
// BUG-INT-01a: the slug wins — the billing API is slug-scoped, and the numeric id 404s there.
|
||||
const orgSlug = org ? org : DEV_USAGE_ORG
|
||||
return {
|
||||
mode: app.platform,
|
||||
deployment: {
|
||||
CONUSAI_API_URL: deployment.CONUSAI_API_URL,
|
||||
CONUSAI_API_TOKEN: deployment.CONUSAI_API_TOKEN,
|
||||
CONUSAI_ORG_ID: deployment.CONUSAI_ORG_ID,
|
||||
CONUSAI_ORG_SLUG: deployment.CONUSAI_ORG_SLUG,
|
||||
CONUSAI_PROJECT_ID: deployment.CONUSAI_PROJECT_ID,
|
||||
},
|
||||
objectStore: createObjectStore(deployment),
|
||||
audit: createAuditLog(defineAuditEvents(auditEventsOf(registry)), deployment),
|
||||
usage: createUsage({ orgSlug }, deployment),
|
||||
documents: createDocumentReader(deployment),
|
||||
models: createModelGateway(deployment, { model, ...(embeddingModel === undefined ? {} : { embeddingModel }) }),
|
||||
model,
|
||||
knowledge: {
|
||||
backend: knowledgeBackendOf(environment),
|
||||
vectorStore: vectorStoreOf(environment),
|
||||
...(embeddingModel === undefined ? {} : { embeddingModel }),
|
||||
dimensions: vectorDimensionsOf(environment),
|
||||
...(deployment.QDRANT_URL === undefined ? {} : { qdrantUrl: deployment.QDRANT_URL }),
|
||||
...(deployment.QDRANT_API_KEY === undefined ? {} : { qdrantApiKey: deployment.QDRANT_API_KEY }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the platform API is ready. `/readyz` is the public server-readiness contract; its
|
||||
* platform descriptor lives under the authenticated API and is not a public health probe.
|
||||
* No deployment token is sent. A timeout is an answer too: this probe must not hang on its upstream.
|
||||
*/
|
||||
export async function probePlatform(
|
||||
deployment: Pick<DeploymentEnv, "CONUSAI_API_URL">,
|
||||
options: { fetch?: typeof fetch; timeoutMs?: number } = {},
|
||||
): Promise<boolean> {
|
||||
const request = options.fetch ?? fetch
|
||||
try {
|
||||
const response = await request(new URL("/readyz", deployment.CONUSAI_API_URL), {
|
||||
signal: AbortSignal.timeout(options.timeoutMs ?? 2_000),
|
||||
redirect: "error",
|
||||
})
|
||||
return response.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Which role this process is actually connected as (decision AT-D20).
|
||||
//
|
||||
// The contract in env.ts says which URL a process may hold; this says what the database thinks of
|
||||
// the connection it opened. Both are needed: a URL names a role, and only the server can say
|
||||
// whether that role owns a table or bypasses row-level security -- the two things that would make
|
||||
// every policy in this app advisory.
|
||||
|
||||
import { DeploymentEnvError } from "@conusai-kit/platform"
|
||||
import type { Pool } from "pg"
|
||||
|
||||
export interface RoleFacts {
|
||||
readonly role: string
|
||||
/** Tables in `public` this role owns. A table's owner is exempt from its own policies. */
|
||||
readonly ownedTables: number
|
||||
readonly bypassesRowLevelSecurity: boolean
|
||||
readonly isSuperuser: boolean
|
||||
/** Whether this role owns schema `public`, which is what the migrate job has to be. */
|
||||
readonly ownsSchema: boolean
|
||||
}
|
||||
|
||||
const FACTS = `select current_user as role,
|
||||
(select count(*)::int from pg_tables where schemaname = 'public' and tableowner = current_user) as owned_tables,
|
||||
coalesce((select rolbypassrls from pg_roles where rolname = current_user), false) as bypasses_rls,
|
||||
coalesce((select rolsuper from pg_roles where rolname = current_user), false) as is_superuser,
|
||||
coalesce((select nspowner = current_user::regrole from pg_namespace where nspname = 'public'), false) as owns_schema`
|
||||
|
||||
export async function roleFacts(pool: Pool): Promise<RoleFacts> {
|
||||
const { rows } = await pool.query<{
|
||||
role: string
|
||||
owned_tables: number
|
||||
bypasses_rls: boolean
|
||||
is_superuser: boolean
|
||||
owns_schema: boolean
|
||||
}>(FACTS)
|
||||
const row = rows[0]
|
||||
if (!row) throw new DeploymentEnvError("the database answered nothing about the connected role")
|
||||
return {
|
||||
role: row.role,
|
||||
ownedTables: row.owned_tables,
|
||||
bypassesRowLevelSecurity: row.bypasses_rls,
|
||||
isSuperuser: row.is_superuser,
|
||||
ownsSchema: row.owns_schema,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a migrate job that is not the owner. The platform sends `POSTGRES_MIGRATE_URL` only to
|
||||
* the service that declares `postgres.migrate`, and that login owns the database and schema
|
||||
* `public` (data-services DS21); a migrate job that is anything else would create tables the
|
||||
* runtime role then owns -- and a table's owner is exempt from its own policies unless the schema
|
||||
* forces them, which is the hole this whole design closes.
|
||||
*/
|
||||
export function assertOwnsSchema(facts: RoleFacts, variable: string): RoleFacts {
|
||||
if (!facts.ownsSchema) {
|
||||
throw new DeploymentEnvError(
|
||||
`${variable} connects as "${facts.role}", which does not own schema public: the migrate job runs as the database owner, and every table it creates is granted to the runtime role as it is created`,
|
||||
)
|
||||
}
|
||||
return facts
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a runtime or reader connection that is not bound by row-level security. A process that
|
||||
* started on the owner's login, or on a role with BYPASSRLS, would read every tenant's rows from
|
||||
* code that looks correct, which is the one failure this design exists to make impossible.
|
||||
*/
|
||||
export function assertBoundByRowLevelSecurity(facts: RoleFacts, variable: string): RoleFacts {
|
||||
const refuse = (why: string): never => {
|
||||
throw new DeploymentEnvError(
|
||||
`${variable} connects as "${facts.role}", which ${why}: web and worker use the non-owner runtime role and the global reader, and the owner's login reaches the migrate job alone (docs: the three Postgres roles)`,
|
||||
)
|
||||
}
|
||||
if (facts.isSuperuser) refuse("is a superuser")
|
||||
if (facts.bypassesRowLevelSecurity) refuse("has BYPASSRLS")
|
||||
if (facts.ownedTables > 0) refuse(`owns ${facts.ownedTables} table(s) in schema public`)
|
||||
return facts
|
||||
}
|
||||
|
||||
/** One line per process, so an operator can see which role a container holds. */
|
||||
export const roleLine = (service: string, facts: RoleFacts): string =>
|
||||
`${service}: connected as ${facts.role} (owns ${facts.ownedTables} table(s), schema owner=${facts.ownsSchema}, bypassrls=${facts.bypassesRowLevelSecurity})`
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"//": "The emit build resolves a workspace package through node_modules to its dist, not through the source paths of tsconfig.paths.json: pnpm builds the packages in dependency order, so the dist is there."
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig.paths.json",
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"types": ["node"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user