// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 // conusai.json against the real parser's own schema (AT2.1 done_when). // // `schemas/conusai.schema.json` is generated from crates/conusai-presets, and // scripts/vendor/test-template.sh runs that generator's `--check` before this suite, so what is // validated here is the closed schema the platform parses with: an unknown key fails, and so // does a missing one. import { readFileSync } from "node:fs" import { join } from "node:path" import { Ajv2020 } from "ajv/dist/2020.js" import addFormats from "ajv-formats" import { describe, expect, it } from "vitest" const repoRoot = process.env.CONUSAI_REPO_ROOT const config = JSON.parse(readFileSync(new URL("../conusai.json", import.meta.url), "utf8")) as { schema_version: number compose: { file: string } services: Record< string, { kind?: string pre_deploy_command?: string public?: { port: number; primary?: boolean } health_check?: { path: string } size?: string postgres?: { migrate?: boolean } } > links: { type: string }[] previews: { enabled: boolean; size?: string } token?: { permissions: string[] } guides?: { bundle: string } } describe("conusai.json", () => { it.runIf(repoRoot)("validates against the generated conusai.json schema", () => { const schema = JSON.parse(readFileSync(join(repoRoot as string, "schemas/conusai.schema.json"), "utf8")) as object const ajv = new Ajv2020({ strict: false, allErrors: true }) addFormats(ajv) const validate = ajv.compile(schema) const valid = validate(config) expect(validate.errors ?? [], JSON.stringify(validate.errors, null, 2)).toEqual([]) expect(valid).toBe(true) }) it("is schema version 2 and names the compose file", () => { expect(config.schema_version).toBe(2) expect(config.compose.file).toBe("compose.yaml") }) it("declares web, worker and the migrate service, all of one image and none of them a job", () => { expect(Object.keys(config.services).sort()).toEqual(["migrate", "web", "worker"]) // kind "job" and pre_deploy_command are gone (KI-197): the migrate service is an ordinary // service whose ordering comes from compose's depends_on (AT-D14). for (const [name, service] of Object.entries(config.services)) { expect(service.kind, `${name}.kind`).toBeUndefined() expect(service.pre_deploy_command, `${name}.pre_deploy_command`).toBeUndefined() } }) // kit:guides:begin it("declares the code-guides bundle the image build writes, at an absolute path (ADR-045)", () => { expect(config.guides).toEqual({ bundle: "/app/.conusai/guides-bundle.json" }) const dockerfile = readFileSync(new URL("../Dockerfile", import.meta.url), "utf8") // The build stage's WORKDIR is /app and the runtime stage copies /app whole, so the file the // build writes is where conusai.json says the platform will find it. expect(dockerfile).toContain(`node apps/worker/dist/guides-bundle.js ${config.guides?.bundle} guides-anchors.json`) }) // kit:guides:end it("exposes web alone, on its own health path", () => { expect(config.services.web?.public).toEqual({ port: 3000, primary: true }) // The path the platform probes is the path compose probes: one definition of "this web container is // fit to serve", /api/ready (store, reader, migration level), and not a second, weaker one. expect(config.services.web?.health_check?.path).toBe("/api/ready") const compose = readFileSync(new URL("../compose.yaml", import.meta.url), "utf8") expect(compose).toContain("http://127.0.0.1:3000/api/ready") expect(config.services.worker?.public).toBeUndefined() expect(config.services.migrate?.public).toBeUndefined() }) it("declares exactly one migrator, which is the migrate job (data-services DS21)", () => { const migrators = Object.entries(config.services).filter(([, service]) => service.postgres?.migrate === true) expect(migrators.map(([name]) => name)).toEqual(["migrate"]) }) it("links a Postgres service, and one sign-in link", () => { // The identity link is what injects OIDC_ISSUER/OIDC_WEB_CLIENT_ID/OIDC_AUDIENCE at // deploy (MIB I3); without the declaration the app boots signed-out-only (KI-199). const links = config.links as Array<{ type: string; role?: string }> expect(links[0]?.type).toBe("postgres") const signInLinks = ["identity"] signInLinks.push("better-auth") // kit:convex-better-auth -- the named Convex link replaces Identity (BL) expect(links.filter((link) => signInLinks.includes(link.role ?? link.type))).toHaveLength(1) }) it("requests the platform's canonical runtime scopes for document processing and usage metering", () => { const permissions = config.token?.permissions ?? [] expect(permissions).toEqual(expect.arrayContaining(["ocr:use", "ai:execute", "usage:write"])) expect(permissions).not.toContain("billing:ingest") const maria = JSON.parse(readFileSync(new URL("../profiles/mariadb/conusai.json", import.meta.url), "utf8")) as { token?: { permissions: string[] } } expect(maria.token?.permissions).toEqual(expect.arrayContaining(["ocr:use", "ai:execute", "usage:write"])) expect(maria.token?.permissions).not.toContain("billing:ingest") }) // The account page's platform analytics erasure and export (ADR-049) call the deployment-token // routes, which take `privacy:subjects` by name: `*` never carries it, so it is listed explicitly. it("requests privacy:subjects for the account page's analytics erasure and export", () => { expect(config.token?.permissions).toContain("privacy:subjects") expect(config.token?.permissions).not.toContain("*") }) // Every selectable profile's conusai.json replaces this one at the app root // (`kit template:prune --profile`), so each must request what the enabled modules need: the // Convex profile asked for none, so a Convex app's deployment token lacked guides:runtime and // support:widget-sessions and its guides and support calls were refused. `template:prune --without` // removes a module's permission from every copy, so the lists stay equal after a prune too. it.each(["convex", "mariadb"])("the %s profile requests the same runtime scopes as the default", (profile) => { const copy = JSON.parse(readFileSync(new URL(`../profiles/${profile}/conusai.json`, import.meta.url), "utf8")) as { token?: { permissions: string[] } } const moduleScopes: string[] = [] moduleScopes.push("guides:runtime") // kit:guides moduleScopes.push("support:widget-sessions") // kit:support expect(config.token?.permissions).toEqual(expect.arrayContaining(moduleScopes)) expect([...(copy.token?.permissions ?? [])].sort()).toEqual([...(config.token?.permissions ?? [])].sort()) }) })