@@ -0,0 +1,36 @@
|
||||
{
|
||||
"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:",
|
||||
"@conusai-kit/host": "catalog:kit",
|
||||
"@conusai-kit/business": "catalog:kit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.6.5",
|
||||
"@types/pg": "catalog:",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Who may come into this app and who may found an organization in it (ADR-062), read by
|
||||
// `@conusai-kit/host/postgres/config`.
|
||||
|
||||
export {
|
||||
ADMISSION_TOKEN_VAR,
|
||||
IDENTITY_LINK_VAR,
|
||||
identityLinkOf,
|
||||
ORGANIZATION_SELF_SERVICE_VAR,
|
||||
organizationSelfServiceOf,
|
||||
SINGLE_ORGANIZATION_VAR,
|
||||
singleOrganizationOf,
|
||||
} from "@conusai-kit/host/postgres/config"
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The agent module's one deployment setting: how many days a finished run (its step log and its
|
||||
// conversation) is kept before the hourly sweep deletes it (modules/agent, the run retention job).
|
||||
//
|
||||
// AGENT_RUN_RETENTION_DAYS a whole number of days from 1 to 3650; 90 when unset or blank.
|
||||
//
|
||||
// A value out of that range is refused with the variable's name, never its value.
|
||||
|
||||
export const DEFAULT_AGENT_RUN_RETENTION_DAYS = 90
|
||||
|
||||
const MIN_DAYS = 1
|
||||
const MAX_DAYS = 3650
|
||||
|
||||
export function agentRunRetentionDaysOf(env: Record<string, string | undefined> = process.env): number {
|
||||
const raw = env.AGENT_RUN_RETENTION_DAYS?.trim()
|
||||
if (!raw) return DEFAULT_AGENT_RUN_RETENTION_DAYS
|
||||
const days = /^\d+$/.test(raw) ? Number(raw) : Number.NaN
|
||||
if (!Number.isInteger(days) || days < MIN_DAYS || days > MAX_DAYS) {
|
||||
throw new Error(`AGENT_RUN_RETENTION_DAYS must be a whole number of days from ${MIN_DAYS} to ${MAX_DAYS}`)
|
||||
}
|
||||
return days
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The AI settings of this app, read by `@conusai-kit/host/postgres/config`: which model the platform's
|
||||
// AI gateway is asked for, and which backend serves the knowledge module.
|
||||
|
||||
export {
|
||||
AI_MODEL_VAR,
|
||||
aiModelOf,
|
||||
DEFAULT_AI_MODEL,
|
||||
EMBEDDING_MODEL_VAR,
|
||||
embeddingModelOf,
|
||||
KNOWLEDGE_BACKEND_VAR,
|
||||
KNOWLEDGE_BACKENDS,
|
||||
type KnowledgeBackendChoice,
|
||||
knowledgeBackendOf,
|
||||
knowledgeBackendSchema,
|
||||
} from "@conusai-kit/host/postgres/config"
|
||||
@@ -0,0 +1,217 @@
|
||||
// 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' runtime theme roles through `themeStyle()` of @conusai-kit/brand (`--primary`, its text,
|
||||
// `--ring` held at text contrast per theme, `--sidebar-primary`, `--chart-1`, and the accent tint; brand-kit
|
||||
// plan §3.5). 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 non-colour brand tokens (`--brand-radius-*`, fonts) 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"
|
||||
/** The theme a person gets until they choose one. Absent: dark, the brand's own; `system` follows the device. */
|
||||
readonly defaultTheme?: "dark" | "light" | "system"
|
||||
/** The first page and the sign-in. Without it they show the app's name on the brand's scenery. */
|
||||
readonly signIn?: SignInBrand
|
||||
}
|
||||
|
||||
/** One text in each language of the app. */
|
||||
export type Localized = Readonly<Record<AppBrand["defaultLocale"], string>>
|
||||
|
||||
export interface SignInBrand {
|
||||
/** A path on this origin, in apps/web/public: the picture behind the way in. */
|
||||
readonly picture?: string
|
||||
/** The name on the way in, when it differs from `name`. */
|
||||
readonly wordmark?: string
|
||||
/** Draw `logo` beside the name there. On by default. */
|
||||
readonly logo?: boolean
|
||||
/** One strong line. */
|
||||
readonly tagline?: Localized
|
||||
/** Up to three: `icon` is a name of `NAV_ICONS` of @conusai-kit/core. */
|
||||
readonly highlights?: readonly { readonly icon?: string; readonly text: Localized }[]
|
||||
}
|
||||
|
||||
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"]
|
||||
readonly defaultTheme?: AppBrand["defaultTheme"]
|
||||
readonly signIn?: SignInBrand
|
||||
}
|
||||
|
||||
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.png", dark: "/favicon.png", favicon: "/favicon.png" }
|
||||
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")
|
||||
}
|
||||
}
|
||||
const signIn = input.signIn
|
||||
if (signIn?.picture !== undefined && !isOwnPath(signIn.picture)) {
|
||||
refuse("signIn.picture", "is a path on this app's own origin, starting with one slash")
|
||||
}
|
||||
if (signIn?.wordmark !== undefined) printable("signIn.wordmark", signIn.wordmark)
|
||||
if ((signIn?.highlights?.length ?? 0) > 3) refuse("signIn.highlights", "holds at most three")
|
||||
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",
|
||||
...(input.defaultTheme === undefined ? {} : { defaultTheme: input.defaultTheme }),
|
||||
...(signIn === undefined ? {} : { signIn: Object.freeze({ ...signIn }) }),
|
||||
})
|
||||
}
|
||||
|
||||
/** The way in as a page draws it, in one language: plain values, so it is loader data. */
|
||||
export interface AuthBrand {
|
||||
readonly name: string
|
||||
readonly logo?: { readonly light: string; readonly dark: string }
|
||||
readonly picture?: string
|
||||
readonly tagline?: string
|
||||
readonly highlights: readonly { readonly icon?: string; readonly text: string }[]
|
||||
}
|
||||
|
||||
export function authBrand(brand: AppBrand, locale: string): AuthBrand {
|
||||
const language = locale === "lt" ? "lt" : "en"
|
||||
const { signIn } = brand
|
||||
return {
|
||||
name: signIn?.wordmark ?? brand.name,
|
||||
...(signIn?.logo === false ? {} : { logo: { light: brand.logo.light, dark: brand.logo.dark } }),
|
||||
...(signIn?.picture ? { picture: signIn.picture } : {}),
|
||||
...(signIn?.tagline ? { tagline: signIn.tagline[language] } : {}),
|
||||
highlights: (signIn?.highlights ?? []).map(({ icon, text }) => ({
|
||||
...(icon ? { icon } : {}),
|
||||
text: text[language],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The public brand document the identity service reads (`GET /api/brand/identity`): what its hosted sign-in
|
||||
* pages draw, and `theme`, the organization's roles when the platform has any. Nothing here is a secret.
|
||||
*/
|
||||
export function identityBrandDocument(
|
||||
brand: AppBrand,
|
||||
theme?: { readonly light: object; readonly dark: object },
|
||||
organization: { readonly name?: string; readonly logo?: string } = {},
|
||||
) {
|
||||
const { signIn } = brand
|
||||
const perLanguage = <T>(read: (language: "en" | "lt") => T) => ({ en: read("en"), lt: read("lt") })
|
||||
const logo = organization.logo ? { light: organization.logo, dark: organization.logo } : brand.logo
|
||||
return {
|
||||
version: 1,
|
||||
name: signIn?.wordmark ?? organization.name ?? brand.name,
|
||||
...(signIn?.logo === false ? {} : { logo: { light: logo.light, dark: logo.dark } }),
|
||||
favicon: brand.logo.favicon,
|
||||
...(signIn?.picture ? { picture: signIn.picture } : {}),
|
||||
...(signIn?.tagline ? { tagline: signIn.tagline } : {}),
|
||||
highlights: perLanguage((language) =>
|
||||
(signIn?.highlights ?? []).map(({ icon, text }) => ({ ...(icon ? { icon } : {}), text: text[language] })),
|
||||
),
|
||||
defaultLocale: brand.defaultLocale,
|
||||
tokens: brandTokenStyle(brand),
|
||||
...(theme ? { theme: { light: theme.light, dark: theme.dark } } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export const appBrand: AppBrand = defineBrand({
|
||||
name: "Example App",
|
||||
shortName: "Example",
|
||||
// Paths on this app's origin. `/favicon.png` is the ConusAI mark, which apps/web/vite.config.ts emits from
|
||||
// @conusai-kit/brand; your own logos go in apps/web/public when this app becomes yours.
|
||||
logo: { light: "/favicon.png", dark: "/favicon.png", favicon: "/favicon.png" },
|
||||
// primary: "#1a1a8c", // your fill colour; the focus ring (and link colour) 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 as Record<string, string>
|
||||
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,99 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Service configuration is evaluated only after authorized dispatch; transport selects its authority.
|
||||
import { gatewayBusinessServiceHooks } from "@conusai-kit/business/server"
|
||||
import { type CallerOptions, type Container, type ModuleManifest, serviceRefusal } from "@conusai-kit/core"
|
||||
import { createNativeServiceReadiness, type Profile } from "@conusai-kit/platform"
|
||||
import type { AppEnv } from "./env.js"
|
||||
|
||||
type Hooks = Pick<CallerOptions, "prepareServices" | "serviceReadiness">
|
||||
export function businessServiceComposition(
|
||||
env: Pick<AppEnv, "deployment"> & Partial<Pick<AppEnv, "platform" | "production">>,
|
||||
modules: readonly ModuleManifest[],
|
||||
profile: Profile,
|
||||
options: {
|
||||
model?: string
|
||||
supportChannel?: string | ((container: Container) => string | undefined)
|
||||
guidesIdentityNamespace?: string | ((container: Container) => string | undefined)
|
||||
} = {},
|
||||
): Hooks {
|
||||
if (!modules.some((module) => module.serviceRequirements?.length)) return {}
|
||||
let current: Hooks | undefined
|
||||
const configured = (): Hooks => {
|
||||
if (profile === "convex") throw serviceRefusal("profile_unsupported")
|
||||
try {
|
||||
current ??= gatewayBusinessServiceHooks(env.deployment, profile)
|
||||
return current
|
||||
} catch {
|
||||
throw serviceRefusal("configuration_missing")
|
||||
}
|
||||
}
|
||||
return {
|
||||
prepareServices: async (container, context, requirements) => {
|
||||
if (
|
||||
!requirements.some(
|
||||
(requirement) =>
|
||||
requirement.transport === "business" &&
|
||||
requirement.partitionRequired &&
|
||||
Object.hasOwn(requirement.operations, context.procedure),
|
||||
)
|
||||
)
|
||||
return
|
||||
await configured().prepareServices?.(container, context, requirements)
|
||||
},
|
||||
serviceReadiness: {
|
||||
profile,
|
||||
observe: async (requirement, context) => {
|
||||
if (requirement.transport === "platform") {
|
||||
// Resolve from the actual dispatch container each time; do not retain a selector
|
||||
// across a port replacement or accept a selector from procedure/browser input.
|
||||
const supportChannel =
|
||||
requirement.service !== "support"
|
||||
? undefined
|
||||
: typeof options.supportChannel === "function"
|
||||
? context.container
|
||||
? options.supportChannel(context.container)
|
||||
: undefined
|
||||
: options.supportChannel
|
||||
const guidesIdentityNamespace =
|
||||
requirement.service !== "guides"
|
||||
? undefined
|
||||
: typeof options.guidesIdentityNamespace === "function"
|
||||
? context.container
|
||||
? options.guidesIdentityNamespace(context.container)
|
||||
: undefined
|
||||
: options.guidesIdentityNamespace
|
||||
const native = createNativeServiceReadiness(env.deployment, {
|
||||
profile,
|
||||
...(options.model === undefined ? {} : { model: options.model }),
|
||||
...(supportChannel === undefined ? {} : { supportChannel }),
|
||||
// Offline code guides use the fixed local namespace, but still pass the simulated
|
||||
// native protocol. Production never derives a selector from this fallback.
|
||||
...(guidesIdentityNamespace === undefined
|
||||
? env.platform === "fake" && env.production === false && requirement.service === "guides"
|
||||
? { guidesIdentityNamespace: "app" }
|
||||
: {}
|
||||
: { guidesIdentityNamespace }),
|
||||
})
|
||||
// The kernel has already resolved and authorized this project's membership.
|
||||
// Support is an org service; observe that same project's containing org,
|
||||
// while keeping the original project scope for the procedure and repository.
|
||||
const admissionContext =
|
||||
requirement.service === "support" &&
|
||||
context.module === "support" &&
|
||||
context.procedure === "support.projectProcessingStatus.get" &&
|
||||
context.scope.level === "project"
|
||||
? {
|
||||
...context,
|
||||
scope: { level: "org" as const, orgId: context.scope.orgId, actor: context.scope.actor },
|
||||
}
|
||||
: context
|
||||
return native.observe(requirement, admissionContext)
|
||||
}
|
||||
if (requirement.transport !== "business") throw serviceRefusal("observation_missing")
|
||||
const observer = configured().serviceReadiness
|
||||
if (!observer) throw serviceRefusal("observation_missing")
|
||||
return observer.observe(requirement, context)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,118 @@
|
||||
// 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/host reads the platform's variables and the Postgres profile's three roles; what is
|
||||
// here is the profile this app was generated on, and the one-login and Convex profiles, which stay
|
||||
// the template's own. A process that is handed a URL it may not hold does not start.
|
||||
|
||||
import {
|
||||
type DeploymentContract,
|
||||
loadDeploymentContract,
|
||||
loadPostgresAccess,
|
||||
MIGRATE_URL_VAR,
|
||||
type PostgresAccess,
|
||||
refusedVariable,
|
||||
requiredVariable,
|
||||
type Service,
|
||||
} from "@conusai-kit/host/postgres/config"
|
||||
import { DeploymentEnvError, type Profile } from "@conusai-kit/platform"
|
||||
|
||||
export {
|
||||
FAKE_PLATFORM_VAR,
|
||||
isProduction,
|
||||
MIGRATE_URL_VAR,
|
||||
PLATFORM_VARS,
|
||||
PlatformEnvError,
|
||||
type PlatformMode,
|
||||
type PostgresAccess,
|
||||
publicUrlOf,
|
||||
READER_URL_VAR,
|
||||
RUNTIME_URL_VAR,
|
||||
roleOf,
|
||||
SERVICES,
|
||||
type Service,
|
||||
} from "@conusai-kit/host/postgres/config"
|
||||
|
||||
/** The persistence profile this app was generated on (spec §5.1). `kit template:prune` removes the others. */
|
||||
export const PROFILE: Profile = "postgres"
|
||||
|
||||
/** The one login of a MariaDB or MongoDB profile: no RLS to bind, so every process holds the same URL (AT-D20 is Postgres-only). */
|
||||
export interface SingleLoginAccess {
|
||||
readonly url: string
|
||||
}
|
||||
|
||||
export const MARIADB_URL_VAR = "MARIADB_URL"
|
||||
export const MONGODB_URL_VAR = "MONGODB_URL"
|
||||
export const REDIS_URL_VAR = "REDIS_URL"
|
||||
|
||||
export interface AppEnv extends DeploymentContract {
|
||||
readonly postgres?: PostgresAccess
|
||||
/** The mariadb profile's one login (`MARIADB_URL`). */
|
||||
readonly mariadb?: SingleLoginAccess
|
||||
/** The mongodb profile's one login (`MONGODB_URL`, a replica set: AT-D15). */
|
||||
readonly mongodb?: SingleLoginAccess
|
||||
/** The BullMQ queue's Valkey (`REDIS_URL`) of the mariadb and mongodb profiles; absent in the migrate job. */
|
||||
readonly redis?: SingleLoginAccess
|
||||
readonly convex?: { readonly url: string; readonly adminKey: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 contract = loadDeploymentContract(service, env, profile)
|
||||
|
||||
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()) {
|
||||
refusedVariable(MIGRATE_URL_VAR, service, "Convex deployments do not use a Postgres owner URL")
|
||||
}
|
||||
return {
|
||||
...contract,
|
||||
convex: {
|
||||
url: requiredVariable(env, "CONVEX_SELF_HOSTED_URL", "link a Convex service to this project"),
|
||||
adminKey: requiredVariable(
|
||||
env,
|
||||
"CONVEX_SELF_HOSTED_ADMIN_KEY",
|
||||
"the server-side key of the linked Convex service",
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (profile === "mariadb" || profile === "mongodb") {
|
||||
// One login and no owner: the three-role split exists to make row-level security bind and
|
||||
// neither engine has it. The Postgres variables are not read on this profile.
|
||||
const engine = profile === "mariadb" ? "MariaDB" : "MongoDB"
|
||||
const variable = profile === "mariadb" ? MARIADB_URL_VAR : MONGODB_URL_VAR
|
||||
const store = { url: requiredVariable(env, variable, `link a ${engine} service to this project`) }
|
||||
// The migrate job writes the schema and enqueues nothing; web and worker share the BullMQ queue.
|
||||
const redis =
|
||||
service === "migrate"
|
||||
? undefined
|
||||
: {
|
||||
url: requiredVariable(
|
||||
env,
|
||||
REDIS_URL_VAR,
|
||||
"link a Redis (Valkey) service to this project: this profile's job queue is BullMQ",
|
||||
),
|
||||
}
|
||||
return {
|
||||
...contract,
|
||||
...(profile === "mariadb" ? { mariadb: store } : { mongodb: store }),
|
||||
...(redis ? { redis } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (profile !== "postgres") {
|
||||
throw new DeploymentEnvError(`the ${profile} profile is not supported by the app runtime yet`)
|
||||
}
|
||||
return { ...contract, postgres: loadPostgresAccess(service, env) }
|
||||
}
|
||||
@@ -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,100 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What the apps and the modules of this repository read their configuration from.
|
||||
|
||||
export {
|
||||
ADMISSION_TOKEN_VAR,
|
||||
IDENTITY_LINK_VAR,
|
||||
identityLinkOf,
|
||||
ORGANIZATION_SELF_SERVICE_VAR,
|
||||
organizationSelfServiceOf,
|
||||
SINGLE_ORGANIZATION_VAR,
|
||||
singleOrganizationOf,
|
||||
} from "./access.js"
|
||||
export { agentRunRetentionDaysOf, DEFAULT_AGENT_RUN_RETENTION_DAYS } from "./agent.js"
|
||||
export type { KnowledgeBackendChoice } from "./ai.js"
|
||||
export {
|
||||
AI_MODEL_VAR,
|
||||
aiModelOf,
|
||||
DEFAULT_AI_MODEL,
|
||||
EMBEDDING_MODEL_VAR,
|
||||
embeddingModelOf,
|
||||
KNOWLEDGE_BACKEND_VAR,
|
||||
KNOWLEDGE_BACKENDS,
|
||||
knowledgeBackendOf,
|
||||
} from "./ai.js"
|
||||
export {
|
||||
type AppBrand,
|
||||
type AuthBrand,
|
||||
appBrand,
|
||||
authBrand,
|
||||
BRAND_TOKEN_NAMES,
|
||||
type BrandInput,
|
||||
brandContrast,
|
||||
brandTokenStyle,
|
||||
defineBrand,
|
||||
identityBrandDocument,
|
||||
} from "./brand.js"
|
||||
export { businessServiceComposition } from "./business-services.js"
|
||||
export { DOCUMENTS_EXCLUDED_PAGE_CLASSES_VAR, excludedPageClassNamesOf } from "./documents.js"
|
||||
export {
|
||||
type AppEnv,
|
||||
FAKE_PLATFORM_VAR,
|
||||
isProduction,
|
||||
loadAppEnv,
|
||||
MARIADB_URL_VAR,
|
||||
MIGRATE_URL_VAR,
|
||||
MONGODB_URL_VAR,
|
||||
PLATFORM_VARS,
|
||||
PlatformEnvError,
|
||||
type PlatformMode,
|
||||
type PostgresAccess,
|
||||
PROFILE,
|
||||
publicUrlOf,
|
||||
READER_URL_VAR,
|
||||
REDIS_URL_VAR,
|
||||
RUNTIME_URL_VAR,
|
||||
roleOf,
|
||||
SERVICES,
|
||||
type Service,
|
||||
type SingleLoginAccess,
|
||||
} from "./env.js"
|
||||
export { type AppFlag, appFlags } from "./flags.js"
|
||||
export {
|
||||
appMigrationFiles,
|
||||
appRoot,
|
||||
DIALECT_JOBS_MIGRATION,
|
||||
DIALECT_LIVE_MIGRATION,
|
||||
type DialectProfile,
|
||||
dialectMigrationFiles,
|
||||
dialectModuleMigrationFiles,
|
||||
expectedDialectMigrations,
|
||||
expectedMigrations,
|
||||
GRANT_MIGRATIONS,
|
||||
liveCaptureMigrations,
|
||||
type MigrationFile,
|
||||
missingMigrations,
|
||||
moduleMigrationFiles,
|
||||
moduleMigrationFolder,
|
||||
} 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"
|
||||
export { ProfileCompositionError, profileRegistry } from "./profile.js"
|
||||
@@ -0,0 +1,103 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Which migrations this build of the app expects to have run. The Postgres profile's list is the one
|
||||
// `@conusai-kit/host` applies and compares the database against; the dialect profiles' lists are here.
|
||||
|
||||
import { existsSync } from "node:fs"
|
||||
import { readdir } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import type { Registry } from "@conusai-kit/core"
|
||||
import {
|
||||
appRoot,
|
||||
expectedMigrationNames,
|
||||
liveCaptures,
|
||||
type MigrationFile,
|
||||
moduleMigrationFolder,
|
||||
} from "@conusai-kit/host/postgres/config"
|
||||
|
||||
export {
|
||||
appMigrationFiles,
|
||||
appRoot,
|
||||
GRANT_MIGRATIONS,
|
||||
type MigrationFile,
|
||||
moduleMigrationFiles,
|
||||
moduleMigrationFolder,
|
||||
} from "@conusai-kit/host/postgres/config"
|
||||
|
||||
/** Every migration name a current Postgres schema has recorded, in the order the migrate job applies them. */
|
||||
export const expectedMigrations = expectedMigrationNames
|
||||
|
||||
/** The capture trigger of each live collection, as a migration name and its table, on any profile. */
|
||||
export const liveCaptureMigrations = liveCaptures
|
||||
|
||||
/** The mariadb profile's live change log, built by the migrate job (`liveMysqlSchemaStatements`). */
|
||||
export const DIALECT_LIVE_MIGRATION = "0008_kit-live"
|
||||
|
||||
/** 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))
|
||||
|
||||
/** The profiles that keep their own dialect files (spec §5.3): SQL on MariaDB, JSON commands on MongoDB. */
|
||||
export type DialectProfile = "mariadb" | "mongodb"
|
||||
|
||||
const DIALECT_EXTENSION: Record<DialectProfile, string> = { mariadb: ".sql", mongodb: ".json" }
|
||||
const DIALECT_FILE: Record<DialectProfile, RegExp> = {
|
||||
mariadb: /^\d{4}_[a-z0-9-]+\.sql$/,
|
||||
mongodb: /^\d{4}_[a-z0-9-]+\.json$/,
|
||||
}
|
||||
|
||||
/**
|
||||
* The kernel's files of a dialect profile: `migrations/<profile>` in an app pruned to it (`kit
|
||||
* template:prune --profile` moves them there), `profiles/<profile>/migrations` before that.
|
||||
*/
|
||||
export async function dialectMigrationFiles(
|
||||
profile: DialectProfile,
|
||||
root: string = appRoot(),
|
||||
): Promise<MigrationFile[]> {
|
||||
const pruned = join(root, "migrations", profile)
|
||||
const dir = existsSync(pruned) ? pruned : join(root, "profiles", profile, "migrations")
|
||||
const extension = DIALECT_EXTENSION[profile]
|
||||
return (await readdir(dir))
|
||||
.filter((name) => DIALECT_FILE[profile].test(name))
|
||||
.sort()
|
||||
.map((name) => ({ name: name.slice(0, -extension.length), path: join(dir, name) }))
|
||||
}
|
||||
|
||||
/** Each module's files of a dialect profile, from the folder its manifest declares for it. */
|
||||
export async function dialectModuleMigrationFiles(
|
||||
registry: Registry,
|
||||
profile: DialectProfile,
|
||||
root: string = appRoot(),
|
||||
): Promise<MigrationFile[]> {
|
||||
const extension = DIALECT_EXTENSION[profile]
|
||||
const files: MigrationFile[] = []
|
||||
for (const { module, path } of registry.migrations[profile]) {
|
||||
const dir = moduleMigrationFolder({ module, path }, root)
|
||||
const names = (await readdir(dir)).filter((name) => name.endsWith(extension)).sort()
|
||||
for (const name of names) {
|
||||
files.push({ name: `${module}/${name.slice(0, -extension.length)}`, path: join(dir, name) })
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* The migration the migrate job of a dialect profile builds at run time from the job adapter's own
|
||||
* entity (`kit_processed_events`, the outbox relay's exactly-once ledger), as the Postgres profile's
|
||||
* 0001_kernel.sql carries it.
|
||||
*/
|
||||
export const DIALECT_JOBS_MIGRATION = "0007_kit-jobs"
|
||||
|
||||
/** What a current schema of a dialect profile has recorded: the kernel's files, the jobs table, then the modules'. No grants: one login. */
|
||||
export async function expectedDialectMigrations(
|
||||
registry: Registry,
|
||||
profile: DialectProfile,
|
||||
root: string = appRoot(),
|
||||
): Promise<string[]> {
|
||||
const kernel = (await dialectMigrationFiles(profile, root)).map((file) => file.name)
|
||||
const modules = (await dialectModuleMigrationFiles(registry, profile, root)).map((file) => file.name)
|
||||
// The live collections (TS6.2): MariaDB carries a change log and row triggers per collection;
|
||||
// MongoDB's change stream needs only the pre-images of each collection, for its deletes.
|
||||
const captures = liveCaptureMigrations(registry, profile).map(({ name }) => name)
|
||||
const live = profile === "mariadb" ? [DIALECT_LIVE_MIGRATION, ...captures] : captures
|
||||
return [...kernel, DIALECT_JOBS_MIGRATION, ...live, ...modules]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Observability for the server processes (spec §9.5), from `@conusai-kit/host/postgres/config`: errors,
|
||||
// traces and logs, configured from the deployment contract and from nothing else, with secrets and
|
||||
// tokens taken out before anything leaves the process.
|
||||
|
||||
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 "@conusai-kit/host/postgres/config"
|
||||
@@ -0,0 +1,11 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The platform's services as this app uses them (spec §9), each built once from the deployment contract:
|
||||
// the same function for the web host and the worker, from `@conusai-kit/host/postgres/config`.
|
||||
|
||||
export {
|
||||
auditEventsOf,
|
||||
DEV_USAGE_ORG,
|
||||
type PlatformClients,
|
||||
platformClients,
|
||||
probePlatform,
|
||||
} from "@conusai-kit/host/postgres/config"
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Which role a process is actually connected as (decision AT-D20): `@conusai-kit/host/postgres/config`.
|
||||
|
||||
export {
|
||||
assertBoundByRowLevelSecurity,
|
||||
assertOwnsSchema,
|
||||
type RoleFacts,
|
||||
roleFacts,
|
||||
roleLine,
|
||||
} from "@conusai-kit/host/postgres/config"
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// A registry must keep the custody hooks of every adopted module. Refuse an
|
||||
// unsupported persistence profile instead of silently filtering those hooks.
|
||||
import { buildRegistry, type ModuleManifest, type Profile, type Registry } from "@conusai-kit/core"
|
||||
|
||||
/** A known deployment composition refusal, not an unexpected registry/runtime failure. */
|
||||
export class ProfileCompositionError extends Error {
|
||||
override readonly name = "ProfileCompositionError"
|
||||
|
||||
constructor(profile: Profile, unsupported: readonly string[]) {
|
||||
super(
|
||||
`The ${profile} profile does not support registered modules: ${unsupported.join(", ")}. ` +
|
||||
"Keep their original profile, schema and data-rights hooks; profile changes require verified custody settlement.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function profileRegistry(modules: readonly ModuleManifest[], profile: Profile): Registry {
|
||||
const registry = buildRegistry([...modules])
|
||||
const unsupported = registry.modules
|
||||
.filter((module) => !module.profiles.includes(profile))
|
||||
.map((module) => module.id)
|
||||
.sort()
|
||||
if (unsupported.length > 0) {
|
||||
throw new ProfileCompositionError(profile, unsupported)
|
||||
}
|
||||
return registry
|
||||
}
|
||||
@@ -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