ConusAI vendor v0.1.0-dev.20261027+9b1131fb
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-dev.20261027+9b1131fb
2026-10-27 18:05:17 +00:00
commit 3102273ec7
538 changed files with 80203 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
<!-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 -->
# The convex profile
**Status: selectable preview; not production-ready.** `kit template:prune --profile convex` now
selects this app composition, and the generated Next app builds. The BFF keeps the Convex admin
key server-side, domain queries and mutations execute within real Convex function transactions,
jobs use the Convex scheduler, and the one-shot deploy service installs the app's scoped platform
runtime variables before pushing functions. The local gate deploys generated functions to a local
backend and exercises a trusted identity, tenancy, notes, the outbox audit/usage sinks, and backup
restore. It does not yet prove browser login through the generated Next BFF or a native platform
deployment. File begin/complete/delete use server-side Convex actions around transactional
mutations; streamed parts stay on the Next BFF and go directly to Blob after a Convex authorization
query. The profile gate exercises an upload, streamed part, notes attachment, detach and delete
against a local backend and fake Blob; deleting rows also have a scheduled recovery action.
Documents OCR and proposal review run through a server-side Node action and the Convex scheduler;
the local gate exercises extraction, proposal approval, and the resulting document line. The
guides and support modules' effect procedures (each one call to a platform service, owning no app
row) are generated Convex actions: `convex/lib/effects.ts` runs the kernel in the action with the
module's platform port bound from the installed deployment variables, resolves the scope and checks
the permission in internal queries over `ctx.db` (`convex/effectScope.ts`), and runs a nested read
or write as its generated query or mutation. The account module's export and deletion are
generated actions too: they compose those modules' shares and reach the deletion ledger and the
person's rows through the account store bound in the action, whose every method is one internal
query or mutation of `convex/account.ts` (the Postgres profile's own store over `ctx.db`), so the
ledger records each step as it completes, a resumption runs only the steps left, and the five-minute
`account deletion sweep` cron finishes what a module held up. Better Auth's account events reach the
same flow (`account:providerEvent`). Branding logos remain unavailable; do not treat this preview as
a production deployment.
The table describes the current and intended profile architecture from spec §5.1 and §5.4.
| | postgres (default) | convex (this profile) |
|---|---|---|
| Primary store | Postgres 18 | Convex (self-hosted, managed link) |
| Data adapter | `@conusai-kit/data-kysely` | `@conusai-kit/data-convex` (`ctx.db` repositories) |
| Procedures reach handlers via | Hono HTTP mount + `callByName` | generated `convex/generated/<module>.ts` wrappers (`pnpm convex:gen`) |
| Jobs | pg-boss (Postgres) | Convex scheduler (`convex/conusaiJobs.ts` dispatcher; job rows and dispatch share a mutation) |
| Events | outbox → pg-boss relay | outbox table → scheduled internal mutation (`convex/relay.ts`, cron every minute); audit and usage use server-side platform adapters |
| Tenant wall | repository predicate + RLS | `convex-helpers` custom functions (`convex/lib/functions.ts`) + repository predicate |
| Migrations | numbered SQL, `migrate` job | `convex/schema.ts` generated from zod, pushed by `convex deploy`; data moves use the migrations component |
| Audit | outbox subscribers over HTTP | the same subscribers, audit written via `@conusai-sdk/convex-audit` (registered in `convex/convex.config.ts`) |
| Links | `postgres`, `identity` | `convex`, `identity` |
| Process env | `POSTGRES_URL`, `POSTGRES_READER_URL`, `POSTGRES_MIGRATE_URL` | `CONVEX_SELF_HOSTED_URL`, `CONVEX_SELF_HOSTED_ADMIN_KEY` (server only) |
## What this folder carries
- `convex-deploy` runs `profiles/convex/deploy.mjs`: it writes the app-scoped platform
variables to a mode-0600 temporary file, asks the Convex CLI to install them in the backend,
then deploys the app schema and functions before web and backup start. It uses the linked project
service credential, never a human owner credential.
- `convex/filesActions.ts` performs Blob begin/complete/delete calls; `convex/files.ts` keeps each
row transition in an internal mutation. A five-minute recovery cron reconciles abandoned uploads
and reschedules durable deletes.
Streamed part bodies stay in the Next BFF and are guarded before the server-side Blob client
sends them; no file bytes or platform token enter Convex arguments or browser bundles.
- The BFF uses the same filtered module registry as the generated functions. Its readiness check
compares the deployed module and procedure list plus the entity tables, columns, and indexes.
## Work required before this can be enabled
This remains a preview because browser login through the generated Next BFF, native platform
deployment, and external object-store workflows have not passed yet. The local file and OCR flows
use a fake Blob/platform API; production readiness still requires those flows against a project-
scoped test service credential and branding workflow composition.
## What the existing tests prove
- `@conusai-kit/data-convex` tests its adapter contracts and isolation behavior under
`convex-test` (`bun run --filter @conusai-kit/data-convex test:run`).
- `scripts/vendor/test-template.sh app --profile convex` validates `conusai.json`, checks
generated Convex files for deterministic regeneration, runs the adapter package suite, and tests
an app deploy and notes/outbox/file smoke plus backup export/restore against local Convex backend
containers. After deploying locally it also typechecks the functions against Convex's generated
contexts. The smoke calls Convex functions directly with a local test identity; it does not
prove browser login or the generated Next BFF route.
- The same smoke calls the guides actions (`guides.runtime.bootstrap`, `guides.runtime.record`) and
the support actions (`support.widget.config`, `support.widgetSession.mint`) for a member against the fake
platform, checks the subject, app tenant, `role` trait and display name the platform received, and
checks that a stranger to the organization and an identity pinned to another organization get the
kernel's not-found with no platform call. The guides deploy step (code guides and anchors) still
runs only in the Postgres profile's migrate job.
- The same smoke runs the account module against the real backend and the fake platform: the
export (complete, then a failed, named support section while support is down), the deletion
status, a mismatched confirmation (no ledger row), a deletion held by support with its steps
recorded, the session revoked and the identity detached, the `account:sweep` action failing while
it waits and finishing once support is back (widget sessions revoked before the contact is
erased), a repeated request, and a ban and a deletion as provider events. In a Better Auth app it
also starts a third backend for the Better Auth deployment: a person signs up there, and their
deletion ends that account (the password then signs nobody in).
- The derived-session revocation outbox (`account.sessions.revoke`) is still the Postgres
worker's: on this profile a sign-out asks the modules directly, bounded, once.
- This profile has no Postgres worker or migration process; supported module jobs run in Convex.
The local deployed-app smoke uploads, attaches, detaches and deletes a file through the real
Convex backend and fake Blob API, and verifies OCR proposals and approval. It does not prove
browser login or the generated Next BFF route.
## Backup implementation status
The platform does **not** back a Convex backend up (decision AT-D16). The backup runner, compose
schedule, Blob retention, and restore runbook are implemented and gated with a local round-trip.
Configure `CONVEX_SELF_HOSTED_ADMIN_KEY` only in server containers and use [the backup and restore
runbook](../../docs/backup.md). Backups run outside the Convex runtime because export is an admin
CLI operation.
## Runtime variables
The web and backup services receive `CONVEX_SELF_HOSTED_URL` and
`CONVEX_SELF_HOSTED_ADMIN_KEY` from the linked Convex service. The deploy service additionally
receives `CONUSAI_API_URL`, the project's scoped `CONUSAI_API_TOKEN`, `CONUSAI_PROJECT_ID`, and
`CONUSAI_ORG_SLUG`; it copies those values into the Convex backend so file, audit, and usage actions
can reach the platform. The web process also needs `AUTH_SESSION_SECRET`, `OIDC_ISSUER`,
`OIDC_WEB_CLIENT_ID`, and `OIDC_WEB_CLIENT_SECRET`; `OIDC_SCOPES`, `OIDC_AUDIENCE`,
`AUTH_COOKIE_NAME`, `AUTH_SESSION_IDLE_SECONDS`, and `AUTH_SESSION_ABSOLUTE_SECONDS` are optional
auth preferences. `CONVEX_BACKUP_INTERVAL_HOURS` defaults to 24 and
`CONVEX_BACKUP_RETENTION` defaults to 30. The platform-injected API token remains server-side and
is never sent to browser code.
With `AUTH_PROVIDER=convex-better-auth`, Better Auth runs in a second Convex deployment of the app,
beside this one, linked to the project under the `better-auth` role (`conusai.json`:
`{"type": "convex", "role": "better-auth"}`, which `kit template:prune --auth convex-better-auth`
writes in place of the Identity link). The data deployment's `default` link injects
`CONVEX_SELF_HOSTED_URL`, `CONVEX_SITE_URL` and `CONVEX_SELF_HOSTED_ADMIN_KEY`; the Better Auth
link injects `BETTER_AUTH_CONVEX_URL` (its cloud origin), `BETTER_AUTH_CONVEX_SITE_URL` (its site
origin) and `BETTER_AUTH_CONVEX_ADMIN_KEY`, as runtime-only variables. The operator sets only
`BETTER_AUTH_SECRET` (a secret) and `AUTH_IDENTITY_NAMESPACE` as project variables (the steps are in
docs/how-to/build-an-app-on-the-template.md of the platform repository). `web` receives the two URLs and the key; `convex-deploy` receives the
URL, the key, the namespace and the secret, deploys `better-auth/` to that deployment and installs
`AUTH_PROVIDER`, `AUTH_IDENTITY_NAMESPACE`, `BETTER_AUTH_CONVEX_URL` and
`BETTER_AUTH_CONVEX_ADMIN_KEY` in this backend, whose account actions end a deleted person's
Better Auth account. None of them reaches a browser or the image build.
+120
View File
@@ -0,0 +1,120 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Convex exports require the admin CLI, so this runs beside (not inside) Convex functions.
import { execFile } from "node:child_process"
import { createReadStream } from "node:fs"
import { mkdtemp, rm, stat } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Readable } from "node:stream"
import { promisify } from "node:util"
import { createObjectStore } from "@conusai-kit/platform"
const execFileAsync = promisify(execFile)
export async function backupOnce(env = process.env, now = new Date()) {
const url = requiredFrom(env, "CONVEX_SELF_HOSTED_URL")
const adminKey = requiredFrom(env, "CONVEX_SELF_HOSTED_ADMIN_KEY")
const apiUrl = requiredFrom(env, "CONUSAI_API_URL")
const token = requiredFrom(env, "CONUSAI_API_TOKEN")
const projectId = requiredFrom(env, "CONUSAI_PROJECT_ID")
const retention = positiveInt(env.CONVEX_BACKUP_RETENTION ?? "30", "CONVEX_BACKUP_RETENTION")
const directory = await mkdtemp(join(tmpdir(), "conusai-convex-backup-"))
const archive = join(directory, "convex-export.zip")
try {
await execFileAsync(
env.CONVEX_CLI ?? "node_modules/.bin/convex",
["export", "--include-file-storage", "--path", archive],
{
cwd: env.CONVEX_APP_DIR ?? process.cwd(),
env: { ...env, CONVEX_SELF_HOSTED_URL: url, CONVEX_SELF_HOSTED_ADMIN_KEY: adminKey },
maxBuffer: 8 * 1024 * 1024,
},
)
const { size: sizeBytes } = await stat(archive)
if (!Number.isSafeInteger(sizeBytes) || sizeBytes < 1)
throw new Error("Convex export archive is empty or too large")
const stamp = now.toISOString().replaceAll(/[:.]/g, "-")
const pathname = `convex-backups/${projectId}/${stamp}.zip`
const store = createObjectStore({
CONUSAI_API_URL: apiUrl,
CONUSAI_API_TOKEN: token,
CONUSAI_PROJECT_ID: projectId,
})
const session = await store.createUpload({ pathname, contentType: "application/zip", sizeBytes })
const parts = []
const chunkSize = session.upload.mode === "multipart" ? session.upload.partSizeBytes : sizeBytes
for (let offset = 0, part = 1; offset < sizeBytes; offset += chunkSize, part += 1) {
const partSize = Math.min(chunkSize, sizeBytes - offset)
const source = createReadStream(archive, { start: offset, end: offset + partSize - 1 })
const uploaded = await store.uploadContent(session.uploadId, Readable.toWeb(source), {
...(session.upload.mode === "multipart" ? { part } : {}),
size: partSize,
})
if (session.upload.mode === "multipart") parts.push({ partNumber: part, etag: uploaded.etag })
}
const completed = await store.completeUpload(
session.uploadId,
session.upload.mode === "multipart" ? { parts } : undefined,
)
await prune(store, `convex-backups/${projectId}/`, retention)
return { pathname, sizeBytes, sha256Digest: completed.sha256Digest }
} finally {
await rm(directory, { recursive: true, force: true })
}
}
function requiredFrom(env, name) {
const value = env[name]
if (!value) throw new Error(`${name} is required for Convex backups`)
return value
}
function positiveInt(value, name) {
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`${name} must be a positive integer`)
return parsed
}
async function prune(store, prefix, retention) {
const blobs = []
let cursor
do {
const page = await store.list({ prefix, limit: 1000, ...(cursor ? { cursor } : {}) })
blobs.push(...page.blobs)
cursor = page.hasMore ? page.cursor : undefined
} while (cursor)
blobs.sort((a, b) => b.pathname.localeCompare(a.pathname))
const expired = blobs.slice(retention).map((blob) => blob.url)
if (expired.length) await store.del(expired)
}
if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
if (process.argv.includes("--once")) {
try {
const result = await backupOnce()
console.info(
`Convex backup complete: ${result.pathname} (${result.sizeBytes} bytes, sha256 ${result.sha256Digest})`,
)
process.exit(0)
} catch {
console.error("Convex backup failed (CLI, Blob, or configuration error)")
process.exit(1)
}
}
const interval =
positiveInt(process.env.CONVEX_BACKUP_INTERVAL_HOURS ?? "24", "CONVEX_BACKUP_INTERVAL_HOURS") * 60 * 60 * 1000
const run = async () => {
try {
const result = await backupOnce()
console.info(
`Convex backup complete: ${result.pathname} (${result.sizeBytes} bytes, sha256 ${result.sha256Digest})`,
)
} catch {
// CLI errors can include their argv, which contains the admin key. Never log raw errors.
console.error("Convex backup failed (CLI, Blob, or configuration error)")
}
setTimeout(() => void run(), interval)
}
void run()
}
@@ -0,0 +1,608 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Local generated-app proof: deploy the actual functions to local Convex and exercise the
// server-only BFF identity contract plus atomic tenancy/notes mutations.
import assert from "node:assert/strict"
import { createObjectStore } from "@conusai-kit/platform"
import { ConvexHttpClient } from "convex/browser"
import { makeFunctionReference } from "convex/server"
const url = process.env.CONVEX_SELF_HOSTED_URL
const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY
if (!url || !adminKey) throw new Error("local app smoke needs the self-hosted Convex URL and admin key")
const ref = (name) => makeFunctionReference(name)
const client = new ConvexHttpClient(url)
client.setAdminAuth(adminKey)
const ready = await client.query(ref("system:ready"), {})
assert.equal(ready.ready, true)
assert.deepEqual(await client.action(ref("filesActions:readiness"), {}), { ready: true })
for (const name of ["auth", "files", "notes", "tenancy", "branding"])
assert(ready.modules.includes(name), `missing module ${name}`)
await assert.rejects(
() => client.mutation(ref("generated/tenancy:tenancy_org_create"), { input: { slug: "qa-org", name: "QA" } }),
(error) => error?.data?.code === "unauthenticated",
"unauthenticated requests must not run a public tenancy mutation",
)
const subject = `local-smoke-${Date.now()}`
const issuer = "https://conusai.internal/test"
// The BFF's first-login transaction (system:appUserResolve replaced system:userInsert).
const user = await client.mutation(ref("system:appUserResolve"), {
claims: {
issuer,
subject,
email: `${subject}@example.test`,
displayName: "Local smoke",
locale: "en",
},
create: true,
})
const identity = {
issuer: "https://conusai.internal/bff",
subject: user.id,
tokenIdentifier: `https://conusai.internal/bff|${user.id}`,
conusaiOrgId: undefined,
}
client.setAdminAuth(adminKey, identity)
const organization = await client.mutation(ref("generated/tenancy:tenancy_org_create"), {
input: { slug: "smoke-org", name: "Local smoke organization" },
})
const project = await client.mutation(ref("generated/tenancy:tenancy_project_create"), {
input: {
org: organization.slug,
workspace: organization.workspaceSlug,
slug: "smoke-project",
name: "Local smoke project",
moduleKey: "notes",
},
})
identity.conusaiOrgId = organization.orgId
client.setAdminAuth(adminKey, identity)
const brand = await client.mutation(ref("generated/branding:branding_brand_set"), {
input: { org: organization.slug, displayName: "Convex brand smoke", primary: "#1a1a8c", accent: "#f4c430" },
})
assert.equal(brand.displayName, "Convex brand smoke")
assert.equal(brand.primary, "#1a1a8c")
assert.deepEqual(
await client.query(ref("generated/branding:branding_brand_get"), {
input: { org: organization.slug },
}),
brand,
)
const scope = { org: organization.slug, workspace: organization.workspaceSlug, project: project.slug }
const note = await client.mutation(ref("generated/notes:notes_note_create"), {
input: { ...scope, title: "Convex generated-app smoke", body: "One atomic Convex mutation." },
})
const apiUrl = process.env.CONUSAI_API_URL
if (!apiUrl) throw new Error("local app smoke needs the fake platform API URL")
const blob = createObjectStore({
CONUSAI_API_URL: apiUrl,
CONUSAI_API_TOKEN: process.env.CONUSAI_API_TOKEN ?? "dev-token",
CONUSAI_PROJECT_ID: process.env.CONUSAI_PROJECT_ID ?? "1",
})
const bytes = new TextEncoder().encode("%PDF-1.4\nConvex local file action proof\n%%EOF\n")
const upload = await client.action(ref("filesActions:begin"), {
input: { ...scope, filename: "convex-action-proof.pdf", contentType: "application/pdf", size: bytes.length },
})
const partInfo = await client.query(ref("files:partInfo"), {
input: { ...scope, fileId: upload.fileId, part: 1 },
})
const sent = await blob.uploadContent(partInfo.uploadId, bytes, { part: 1, size: bytes.length })
const stored = await client.action(ref("filesActions:complete"), {
input: { ...scope, fileId: upload.fileId, parts: [{ number: 1, etag: sent.etag }] },
})
assert.equal(stored.size, bytes.length, "the action must finalize the bytes Blob actually accepted")
assert.equal(typeof stored.sha256, "string")
const firstRelay = await client.mutation(ref("relay:relayPass"), {})
assert.equal(firstRelay.failed, 0, "the Convex file event subscriber must enqueue its OCR job successfully")
let extracted
for (let i = 0; i < 60; i += 1) {
const listing = await client.query(ref("generated/documents:documents_document_list"), { input: scope })
extracted = listing.documents.find((document) => document.fileId === upload.fileId)
if (extracted?.status === "extracted") break
await new Promise((resolve) => setTimeout(resolve, 500))
}
assert.equal(extracted?.status, "extracted", "the stored-file event must dispatch the Convex OCR action")
assert(extracted?.pending >= 1, "the OCR action must persist review proposals")
const document = await client.query(ref("generated/documents:documents_document_get"), {
input: { ...scope, fileId: upload.fileId },
})
assert.equal(document.lines[0]?.quote, "Reference: QR-2026-04")
assert.equal(document.lines[0]?.status, "pending")
await client.mutation(ref("generated/proposals:proposals_proposal_approve"), {
input: {
...scope,
proposalId: document.lines[0].lineId,
expectedVersion: document.lines[0].expectedVersion,
},
})
const reviewed = await client.query(ref("generated/documents:documents_document_get"), {
input: { ...scope, fileId: upload.fileId },
})
assert.equal(reviewed.document.approved, 1, "a reviewed proposal must write an approved document line atomically")
assert.equal(reviewed.lines[0]?.status, "approved")
const attachment = await client.mutation(ref("generated/notes:notes_attachment_add"), {
input: { ...scope, noteId: note.noteId, fileId: upload.fileId },
})
const withAttachment = await client.query(ref("generated/notes:notes_note_get"), {
input: { ...scope, noteId: note.noteId },
})
assert.equal(withAttachment.attachmentsEnabled, true)
assert.equal(withAttachment.attachments[0]?.attachmentId, attachment.attachmentId)
await client.mutation(ref("generated/notes:notes_attachment_remove"), {
input: { ...scope, noteId: note.noteId, attachmentId: attachment.attachmentId },
})
await client.action(ref("filesActions:remove"), { input: { ...scope, fileId: upload.fileId } })
await assert.rejects(
() => client.query(ref("generated/files:files_file_get"), { input: { ...scope, fileId: upload.fileId } }),
/no such file/i,
"delete must remove the Blob object before finalizing the file row",
)
const listed = await client.query(ref("generated/notes:notes_note_list"), { input: scope })
assert(
listed.notes.some((item) => item.noteId === note.noteId),
"notes mutation must be readable through Convex",
)
const details = await client.query(ref("generated/notes:notes_note_get"), { input: { ...scope, noteId: note.noteId } })
assert.equal(details.attachmentsEnabled, true, "the composed file workflow enables notes attachments")
assert.equal(details.attachments.length, 0, "removing an attachment must detach the existing file link")
// Effect procedures of guides and support are generated Convex actions (convex/lib/effects.ts): the
// kernel resolves the member's scope and permission in an internal query, runs the nested
// `tenancy.me.get` as its generated query, and calls the platform through the module's port.
const effectState = async () => {
const response = await fetch(new URL("/__e2e/state", apiUrl), { headers: { "x-conusai-organization": "e2e" } })
return response.json()
}
// The widget calls these as a signed-in member: the BFF pins an organization only for an API key
// (convex-caller.ts), and a pinned credential has no user scope for the nested tenancy.me.get.
const memberIdentity = { ...identity }
delete memberIdentity.conusaiOrgId
client.setAdminAuth(adminKey, memberIdentity)
// A procedure without input, called the way the BFF calls it (input undefined, dropped on the wire).
const me = await client.query(ref("generated/tenancy:tenancy_me_get"), {})
assert(
me.organizations.some((entry) => entry.slug === organization.slug && entry.role === "owner"),
"tenancy.me.get answers a call without input",
)
// kit:guides:begin
const bootstrap = await client.action(ref("generated/guides:guides_runtime_bootstrap"), {
input: { org: organization.slug, path: "/notes", locale: "en" },
})
assert.deepEqual(bootstrap, { mode: "platform", etag: '"fake-guides"', guides: [] })
const eventId = globalThis.crypto.randomUUID()
const recorded = await client.action(ref("generated/guides:guides_runtime_record"), {
input: {
org: organization.slug,
idempotencyKey: "convex-smoke-batch",
events: [
{
schema_version: 1,
event_id: eventId,
type: "guide.started",
guide_key: "notes.first-note",
guide_version: 1,
expected_progress_revision: 0,
progress_epoch: 0,
occurred_at: new Date().toISOString(),
},
],
},
})
assert.equal(recorded.outcomes[0]?.event_id, eventId)
assert.equal(recorded.outcomes[0]?.status, "applied")
const guideCalls = (await effectState()).guides
const sentBootstrap = guideCalls.find((call) => call.route === "bootstrap")
assert.equal(sentBootstrap?.body?.identity?.subject, user.id, "the subject is the app's own user id")
assert.equal(
sentBootstrap?.body?.identity?.app_tenant,
organization.orgId,
"the app tenant is the resolved organization",
)
assert.equal(
sentBootstrap?.body?.context?.traits?.role,
"owner",
"the role trait came from the nested tenancy.me.get query",
)
assert(guideCalls.some((call) => call.route === "record" && call.body?.events?.[0]?.event_id === eventId))
// kit:guides:end
// kit:support:begin
assert.deepEqual(
await client.action(ref("generated/support:support_widget_config"), { input: { org: organization.slug } }),
{ mode: "platform", channel: "convex-smoke-channel" },
)
const supportSession = await client.action(ref("generated/support:support_widgetSession_mint"), {
input: { org: organization.slug },
})
assert(supportSession.token.startsWith("sws_"), "a minted support session")
const minted = (await effectState()).support.find((call) => call.route === "mint")
assert.equal(minted?.body?.subject, user.id)
assert.equal(minted?.body?.app_tenant, organization.orgId)
assert.equal(minted?.body?.display_name, "Local smoke", "the display name came from the nested tenancy.me.get query")
// kit:support:end
// A member of no organization: the action's kernel refuses before any platform call. The user is
// created with plain admin auth, as the first one was (system:appUserResolve is internal).
client.setAdminAuth(adminKey)
const stranger = await client.mutation(ref("system:appUserResolve"), {
claims: {
issuer,
subject: `${subject}-stranger`,
email: `${subject}-stranger@example.test`,
displayName: "Stranger",
locale: "en",
},
create: true,
})
const strangerIdentity = {
...identity,
subject: stranger.id,
tokenIdentifier: `https://conusai.internal/bff|${stranger.id}`,
}
delete strangerIdentity.conusaiOrgId
client.setAdminAuth(adminKey, strangerIdentity)
const callsBefore = await effectState()
const strangerCalls = []
strangerCalls.push(["generated/guides:guides_runtime_bootstrap", { org: organization.slug, path: "/notes" }]) // kit:guides
strangerCalls.push(["generated/support:support_widgetSession_mint", { org: organization.slug }]) // kit:support
for (const [reference, input] of strangerCalls) {
await assert.rejects(
() => client.action(ref(reference), { input }),
(error) => error?.data?.code === "not_found",
`${reference}: a stranger to the organization gets the not-found of a missing scope`,
)
}
const callsAfter = await effectState()
assert.equal(callsAfter.guides.length, callsBefore.guides.length, "no platform call for a stranger")
assert.equal(callsAfter.support.length, callsBefore.support.length, "no platform call for a stranger")
client.setAdminAuth(adminKey, identity)
identity.conusaiOrgId = "another-org-id"
client.setAdminAuth(adminKey, identity)
await assert.rejects(
() => client.query(ref("generated/branding:branding_brand_get"), { input: { org: organization.slug } }),
/scope|organization|tenant|membership|not found/i,
"branding must refuse the same mismatched organization identity as notes",
)
await assert.rejects(
() => client.query(ref("generated/notes:notes_note_list"), { input: scope }),
/scope|organization|tenant|membership|not found/i,
"a BFF identity pinned to another organization must not cross the tenant wall",
)
// kit:guides:begin
await assert.rejects(
() =>
client.action(ref("generated/guides:guides_runtime_bootstrap"), {
input: { org: organization.slug, path: "/notes" },
}),
(error) => error?.data?.code === "not_found",
"an effect action refuses an identity pinned to another organization",
)
// kit:guides:end
client.setAdminAuth(adminKey)
const relayResult = await client.mutation(ref("relay:relayPass"), {})
assert.equal(relayResult.failed, 0, "outbox subscribers must complete without failure")
const platformHeaders = {
authorization: "Bearer dev-token",
"x-conusai-organization": "fixture-org",
}
const auditHeaders = { ...platformHeaders, "x-conusai-organization": "e2e" }
const stateUrl = new URL("/__e2e/state", apiUrl)
const auditUrl = new URL("/_conusai/audit/events", apiUrl)
let delivered = false
let usageCount = 0
let auditCount = 0
for (let i = 0; i < 30; i += 1) {
const stateResponse = await fetch(stateUrl, { headers: platformHeaders })
const state = await stateResponse.json()
const auditResponse = await fetch(auditUrl, { headers: auditHeaders })
const audit = await auditResponse.json()
usageCount = state.usage?.length ?? 0
auditCount = audit.items?.length ?? 0
if (state.usage?.some((event) => event.meter === "notes_created") && audit.items?.length) {
delivered = true
break
}
await new Promise((resolve) => setTimeout(resolve, 500))
}
assert(
delivered,
`outbox relay must reach fake platform audit and usage sinks (usage=${usageCount}, audit=${auditCount})`,
)
// The account module on Convex (convex/account.ts): export my data and delete my account through the
// generated actions, with the ledger, the order and the partial-failure export of the Postgres profile.
// Every step is checked against the real backend's rows and what the fake platform received.
client.setAdminAuth(adminKey)
const outage = async (prefix, down) => {
const response = await fetch(new URL("/__e2e/outage", apiUrl), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ prefix, down }),
})
assert.equal(response.status, 204, `the fake platform takes ${prefix} ${down ? "down" : "up"}`)
}
const storeRead = (method, ...args) => client.query(ref("account:storeRead"), { method, args })
const iso = (ms) => new Date(ms).toISOString()
// The identity of each person: under the test issuer, or -- in a Better Auth app, whose deletions
// refuse an identity under any other namespace (`provider-account:not_mapped`) and end the account
// at Better Auth -- a real Better Auth account under the app's namespace.
const accounts = { issuer, subjectFor: async (name) => `${subject}-${name}` }
// kit:convex-better-auth:begin
const betterAuthSite = process.env.SMOKE_BETTER_AUTH_SITE_URL
const namespace = process.env.SMOKE_AUTH_IDENTITY_NAMESPACE
const appOrigin = process.env.SMOKE_PUBLIC_URL
if (!betterAuthSite || !namespace || !appOrigin)
throw new Error("the Better Auth smoke needs its deployment's site URL")
const betterAuth = (path, body) =>
fetch(`${betterAuthSite}/api/auth${path}`, {
method: "POST",
headers: { "content-type": "application/json", origin: appOrigin },
body: JSON.stringify(body),
})
const password = `convex-smoke-${Date.now()}-password`
const signUpAt = async (name, email) => {
const signUp = await betterAuth("/sign-up/email", { name, email, password })
assert.equal(signUp.status, 200, "a person signs up at the Better Auth deployment")
return (await signUp.json()).user.id
}
accounts.issuer = namespace
accounts.subjectFor = (name) => signUpAt(name, `${subject}-${name}@example.test`)
// kit:convex-better-auth:end
/** A person signed in with a kit session row, and the BFF identity of that device. */
const signedIn = async (name) => {
const personSubject = await accounts.subjectFor(name)
client.setAdminAuth(adminKey)
const person = await client.mutation(ref("system:appUserResolve"), {
claims: {
issuer: accounts.issuer,
subject: personSubject,
email: `${subject}-${name}@example.test`,
displayName: name,
locale: "en",
},
create: true,
})
const now = Date.now()
const device = await client.mutation(ref("system:sessionInsert"), {
input: {
userId: person.id,
issuer: accounts.issuer,
subject: personSubject,
handleHash: Array.from(globalThis.crypto.getRandomValues(new Uint8Array(32)), (b) =>
b.toString(16).padStart(2, "0"),
).join(""),
tokens: "sealed-tokens-of-the-smoke",
accessTokenExpiresAt: iso(now + 3_600_000),
lastSeenAt: iso(now),
absoluteExpiresAt: iso(now + 86_400_000),
revokedAt: null,
},
})
const browser = {
issuer: "https://conusai.internal/bff",
subject: person.id,
tokenIdentifier: `https://conusai.internal/bff|${person.id}`,
conusaiCredential: "interactive",
conusaiSessionId: device.id,
}
return { person, device, browser, email: `${subject}-${name}@example.test` }
}
const ada = await signedIn("account")
client.setAdminAuth(adminKey, ada.browser)
const accountOrg = await client.mutation(ref("generated/tenancy:tenancy_org_create"), {
input: { slug: "account-smoke", name: "Account smoke" },
})
// The support module knows the person once a widget session was minted for them.
await client.action(ref("generated/support:support_widgetSession_mint"), { input: { org: accountOrg.slug } }) // kit:support
const exportAs = () => client.action(ref("generated/account:account_data_export"), { input: {} })
// An API credential acting as the person is refused: the export is their own, in full.
const { conusaiCredential: _interactive, conusaiSessionId: _session, ...apiIdentity } = ada.browser
client.setAdminAuth(adminKey, apiIdentity)
await assert.rejects(exportAs, (error) => error?.data?.code === "forbidden", "an API credential cannot export")
client.setAdminAuth(adminKey, ada.browser)
const exported = await exportAs()
assert.equal(
exported.complete,
true,
`every installed module exported its share: ${JSON.stringify(exported.failed)} ${JSON.stringify(Object.fromEntries(Object.entries(exported.sections).map(([key, section]) => [key, section.status === "failed" ? section : section.status])))}`,
)
assert.equal(exported.subject, ada.person.id)
assert.equal(exported.sections.account?.status, "ok")
assert.equal(exported.sections.account.data.profile.email, ada.email, "this app's own rows, from the Convex tables")
assert.deepEqual(
exported.sections.account.data.organizations.map((org) => org.slug),
["account-smoke"],
)
// A module whose service is down is a failed section, named, never left out.
await outage("/v1/support/", true) // kit:support
const partial = await exportAs()
// kit:support:begin
assert.equal(partial.complete, false)
assert.deepEqual(partial.failed, ["support"])
assert.equal(partial.sections.support?.status, "failed")
assert.equal(partial.sections.support.retryable, true)
// kit:support:end
assert.equal(partial.sections.account?.status, "ok")
const status = await client.query(ref("generated/account:account_deletion_status"), {})
assert.equal(status.deletion, null)
assert.equal(status.interactive, true)
assert.equal(status.recentSignIn, true, "the sign-in of the session row the BFF named is recent")
assert.deepEqual(
status.onlyMemberOf.map((org) => org.slug),
["account-smoke"],
)
const deleteAs = (confirm) => client.action(ref("generated/account:account_deletion_request"), { input: { confirm } })
await assert.rejects(
() => deleteAs("someone-else@example.test"),
(error) => error?.data?.code === "confirmation_mismatch",
"a deletion is confirmed with the account's own address",
)
assert.equal(await storeRead("deletion", ada.person.id), null, "a refused deletion writes no ledger row")
const supportBefore = (await effectState()).support.length
const held = await deleteAs(ada.email.toUpperCase())
const ledger = await storeRead("deletion", ada.person.id)
// kit:support:begin
assert.equal(held.state, "erasing", "the support module is down: the deletion holds")
assert.deepEqual(held.pending, ["support"], `held at ${ledger.lastFailure} after ${JSON.stringify(ledger.steps)}`)
assert.match(ledger.lastFailure, /^support:/, "the ledger names the module that held it, by its problem code")
// The steps before the erasures completed, except the widget-session revocation the platform refused.
assert(!ledger.steps.includes("service-sessions"))
// kit:support:end
assert(
["credentials", "identity"].every((step) => ledger.steps.includes(step)),
"the ledger records the steps",
)
assert.equal((await client.query(ref("system:sessionById"), { id: ada.device.id }))?.revokedAt === null, false)
assert.equal(await storeRead("emailOf", ada.person.id), `${ada.person.id}@deleted.invalid`, "the identity is detached")
assert.deepEqual(await storeRead("identities", ada.person.id), [], "no sign-in reaches the account")
// kit:guides:begin
assert(
(await effectState()).guides.some((call) => call.route === "erase" && call.subject === ada.person.id),
"the guides module erased its share by the subject while the user row still existed",
)
// kit:guides:end
client.setAdminAuth(adminKey)
// kit:support:begin
await assert.rejects(
() => client.action(ref("account:sweep"), {}),
(error) => error?.data?.code === "account_deletion_waiting",
"the sweep fails while a deletion waits: the line an operator is alerted on",
)
// kit:support:end
await outage("/v1/support/", false) // kit:support
await client.action(ref("account:sweep"), {})
const done = await storeRead("deletion", ada.person.id)
assert.equal(done.state, "completed", "the sweep finished the deletion once the module was back")
assert.deepEqual(done.pending, [])
assert.equal(await storeRead("emailOf", ada.person.id), null, "the user row goes last")
assert.equal(await client.query(ref("system:sessionById"), { id: ada.device.id }), null)
// kit:support:begin
const supportCalls = (await effectState()).support.slice(supportBefore)
const revokedAt = supportCalls.findIndex((call) => call.route === "revoke" && call.body?.subject === ada.person.id)
const erasedAt = supportCalls.findIndex((call) => call.route === "erase" && call.subject === ada.person.id)
assert(revokedAt >= 0 && erasedAt > revokedAt, "the widget sessions were revoked before the contact was erased")
assert(done.steps.includes("service-sessions"), "the resumption repeated the refused revocation")
assert.deepEqual(
[...done.erased].sort(),
["guides", "support"].filter((key) => status.modules.includes(key)),
)
// kit:support:end
// Asked again, the deletion that has completed answers its state: one deletion, however often asked.
client.setAdminAuth(adminKey, ada.browser)
assert.equal((await deleteAs(ada.email)).state, "completed")
// The identity provider's account events (Better Auth's relay, with the admin key): a ban revokes
// every credential and erases nothing; a deletion at the provider erases through the same ledger.
const grace = await signedIn("provider-event")
client.setAdminAuth(adminKey)
await client.action(ref("account:providerEvent"), { reason: "user-disabled", userId: grace.person.id })
assert.notEqual((await client.query(ref("system:sessionById"), { id: grace.device.id }))?.revokedAt, null)
assert.equal(await storeRead("deletion", grace.person.id), null, "a ban erases nothing")
await client.action(ref("account:providerEvent"), { reason: "account-deleted", userId: grace.person.id })
await client.action(ref("account:providerEvent"), { reason: "account-deleted", userId: grace.person.id })
assert.equal((await storeRead("deletion", grace.person.id))?.state, "completed")
assert.equal(await storeRead("emailOf", grace.person.id), null)
// kit:support:begin
// The sign-out revocation outbox (convex/account.ts): a widget session minted for one device's app
// session is ended by that device's sign-out. The web's `account:revokeSession` records it in the
// backend and answers while the support platform is down; the cron's `account:revocationSweep`
// retries it once due, delivers it once, and a revocation older than 65 minutes lapses unasked.
const revocationRead = (method, ...args) => client.query(ref("account:revocationRead"), { method, args })
const revocationWrite = (method, ...args) => client.mutation(ref("account:revocationWrite"), { method, args })
const pendingRevocation = async (session) =>
(await revocationRead("due", iso(Date.now() + 3_600_000), 100)).find((row) => row.session === session)
const revokesOf = async (session) =>
(await effectState()).support.filter((call) => call.route === "revoke" && call.body?.identity_session === session)
// A person under the test issuer: no Better Auth sign-up (its sign-up rate limit is for the rows below).
client.setAdminAuth(adminKey)
const leaver = await client.mutation(ref("system:appUserResolve"), {
claims: {
issuer,
subject: `${subject}-sign-out`,
email: `${subject}-sign-out@example.test`,
displayName: "Sign-out",
locale: "en",
},
create: true,
})
const leaving = {
browser: {
issuer: "https://conusai.internal/bff",
subject: leaver.id,
tokenIdentifier: `https://conusai.internal/bff|${leaver.id}`,
},
}
const deviceSession = `4:oidc:${leaver.id.length}:${leaver.id}`
client.setAdminAuth(adminKey, leaving.browser)
const leavingOrg = await client.mutation(ref("generated/tenancy:tenancy_org_create"), {
input: { slug: "revocation-smoke", name: "Revocation smoke" },
})
client.setAdminAuth(adminKey, { ...leaving.browser, conusaiIdentitySession: deviceSession })
await client.action(ref("generated/support:support_widgetSession_mint"), { input: { org: leavingOrg.slug } })
assert.equal(
(await effectState()).support.filter((call) => call.route === "mint" && call.body?.identity_session === deviceSession)
.length,
1,
"the widget session names the device's app session it was minted for",
)
client.setAdminAuth(adminKey)
await outage("/v1/support/", true)
await client.action(ref("account:revokeSession"), { session: deviceSession })
const waiting = await pendingRevocation(deviceSession)
assert.equal(waiting?.state, "pending", "the sign-out answered and the revocation is recorded in the backend")
assert.deepEqual(waiting.pending, ["support"])
assert.equal(waiting.attempts, 1)
assert.match(waiting.lastFailure, /^support:/)
assert.deepEqual(await revokesOf(deviceSession), [])
await outage("/v1/support/", false)
// Not due yet (the row's 30 s backoff): the cron's run asks nobody.
await client.action(ref("account:revocationSweep"), {})
assert.deepEqual(await revokesOf(deviceSession), [], "a retry waits for its backoff")
// Due: the retry brought forward rather than waited out.
await revocationWrite("change", waiting, { nextAttemptAt: iso(Date.now()) })
await client.action(ref("account:revocationSweep"), {})
const revocations = await revokesOf(deviceSession)
assert.equal(revocations.length, 1, "the retry delivered the revocation")
assert.equal(revocations[0].revoked, 1, "the device's widget session ended")
assert.equal(await pendingRevocation(deviceSession), undefined)
await client.action(ref("account:revocationSweep"), {})
await client.action(ref("account:revokeSession"), { session: deviceSession })
assert.equal((await revokesOf(deviceSession)).length, 1, "delivered once: a second sweep or notification asks nobody")
const stale = `${deviceSession}:stale`
const longAgo = iso(Date.now() - 66 * 60_000)
await revocationWrite("record", { session: stale, modules: ["support"], at: longAgo, claimedUntil: longAgo })
await client.action(ref("account:revocationSweep"), {})
assert.equal(await pendingRevocation(stale), undefined, "a revocation older than 65 minutes lapses")
assert.deepEqual(await revokesOf(stale), [], "and nobody is asked")
// kit:support:end
// kit:convex-better-auth:begin
// With Better Auth, a deletion ends the person's account in the app's Better Auth deployment (a real
// backend here), found by the identity mapping, before the identity is detached.
const linusEmail = `${subject}-better-auth@example.test`
const betterAuthUser = await signUpAt("Linus", linusEmail)
client.setAdminAuth(adminKey)
const linus = await client.mutation(ref("system:appUserResolve"), {
claims: { issuer: namespace, subject: betterAuthUser, email: linusEmail, displayName: "Linus", locale: "en" },
create: true,
})
client.setAdminAuth(adminKey, {
issuer: "https://conusai.internal/bff",
subject: linus.id,
tokenIdentifier: `https://conusai.internal/bff|${linus.id}`,
conusaiCredential: "interactive",
// A Better Auth session has no kit row: the BFF names the sign-in time Better Auth reported.
conusaiSignedInAt: iso(Date.now() - 1000),
})
assert.equal((await deleteAs(linusEmail)).state, "completed")
const linusLedger = await storeRead("deletion", linus.id)
assert.equal(linusLedger.provider, "convex-better-auth", "the deletion recorded the provider it ends the account at")
assert(linusLedger.steps.includes("provider-account"))
assert.notEqual(
(await betterAuth("/sign-in/email", { email: linusEmail, password })).status,
200,
"the Better Auth account is gone: its password signs nobody in",
)
// kit:convex-better-auth:end
console.log(
"local Convex app smoke green: deployed functions, BFF identity, tenancy, project, file Blob actions, OCR action, proposal approval, notes read/write, guides/support effect actions, account export/deletion/sweep/provider events, sign-out revocation outbox",
)
+28
View File
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Deterministic local platform fixture for the generated Convex app smoke. It runs on the host
// gateway so Convex's Node action can call it, while the smoke itself uses loopback.
import { readFile } from "node:fs/promises"
import { startFakePlatform } from "@conusai-kit/testing/fake-platform"
const port = Number(process.argv[2])
if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error("a test platform port is required")
const fixture = JSON.parse(
await readFile(new URL("../../../modules/documents/__tests__/fixtures/ocr-document.json", import.meta.url), "utf8"),
)
const platform = await startFakePlatform(port, { host: "0.0.0.0" })
platform.ocr.setDocument(fixture)
platform.ai.respond(() => ({
content: JSON.stringify({
lines: [{ label: "Reference", value: "QR-2026-04", quote: "Reference: QR-2026-04" }],
}),
}))
console.log("local fake platform ready")
const stop = async () => {
await platform.close()
process.exit(0)
}
process.once("SIGINT", () => void stop())
process.once("SIGTERM", () => void stop())
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
# AT3.7 proof: a real local Convex backend -> backup runner -> fake Blob -> empty backend.
set -euo pipefail
app="$(cd "$(dirname "$0")/../../.." && pwd)"
repo="$(cd "$app/../.." && pwd)"
node_modules="$app/node_modules"
docker_backend_image="${CONVEX_BACKEND_IMAGE:-build.conusai.com/conusai/convex-backend@sha256:1738f1673f8d63161043a7859710d2301b1e9d6271e06afbb7af31594ea3a58f}"
work="$(mktemp -d)"
suffix="at37-$$"
source_name="${suffix}-source"
target_name="${suffix}-target"
fake_name="${suffix}-platform"
better_auth_name="${suffix}-better-auth" # kit:convex-better-auth
network_name="${suffix}-network"
source_port=""
target_port=""
platform_port=""
cleanup() {
local status=$?
if [[ "$status" -ne 0 ]] && docker inspect "$source_name" >/dev/null 2>&1; then
echo "AT3.7 local Convex backend log on failure:" >&2
docker logs --tail 100 "$source_name" >&2 || true
fi
docker rm -f "$source_name" "$target_name" "$fake_name" >/dev/null 2>&1 || true
docker rm -f "$better_auth_name" >/dev/null 2>&1 || true # kit:convex-better-auth
docker network rm "$network_name" >/dev/null 2>&1 || true
rm -rf "$work"
}
trap cleanup EXIT
command -v docker >/dev/null || { echo "AT3.7 round-trip needs Docker" >&2; exit 1; }
# An explicit small fixture subnet avoids exhausting a shared host's default pools.
subnet="$(node -e 'const b=require("node:crypto").randomBytes(3);process.stdout.write("198."+(18+(b[0]&1))+"."+b[1]+"."+(b[2]&240)+"/28")')"
docker network create --subnet "$subnet" "$network_name" >/dev/null
for name in "$source_name" "$target_name"; do
docker run -d --name "$name" --network "$network_name" --add-host=host.docker.internal:host-gateway -e INSTANCE_NAME="$name" \
-e INSTANCE_SECRET="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))')" \
-e CONVEX_CLOUD_ORIGIN="http://$name:3210" -e CONVEX_SITE_ORIGIN="http://$name:3211" \
-p "127.0.0.1::3210" "$docker_backend_image" >/dev/null
assigned_port="$(docker port "$name" 3210/tcp | sed -E 's/.*:([0-9]+)$/\1/')"
if [[ "$name" == "$source_name" ]]; then source_port="$assigned_port"; else target_port="$assigned_port"; fi
done
docker run -d --name "$fake_name" --network "$network_name" -p "127.0.0.1::4010" \
-v "$repo:$repo:ro" -w "$app" \
node:22 node profiles/convex/backup/test-platform.mjs 4010 >"$work/platform.log" 2>&1
platform_port="$(docker port "$fake_name" 4010/tcp | sed -E 's/.*:([0-9]+)$/\1/')"
platform_host_url="http://127.0.0.1:$platform_port/api"
platform_action_url="http://$fake_name:4010/api"
wait_backend() {
local port="$1" i
for i in $(seq 1 60); do
curl -fsS "http://127.0.0.1:$port/version" >/dev/null 2>&1 && return 0
sleep 2
done
echo "Convex backend on $port did not become ready" >&2
return 1
}
wait_backend "$source_port"
wait_backend "$target_port"
source_key="$(docker exec "$source_name" ./generate_admin_key.sh 2>/dev/null)"
target_key="$(docker exec "$target_name" ./generate_admin_key.sh 2>/dev/null)"
# kit:convex-better-auth:begin
# A Better Auth app: its own Better Auth Convex deployment, a third backend from the same image. The
# deploy job deploys the Better Auth functions to it and installs its URL, admin key and namespace in
# the app's backend, whose account actions end a deleted person's Better Auth account there.
docker run -d --name "$better_auth_name" --network "$network_name" -e INSTANCE_NAME="$better_auth_name" \
-e INSTANCE_SECRET="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))')" \
-e CONVEX_CLOUD_ORIGIN="http://127.0.0.1:3210" -e CONVEX_SITE_ORIGIN="http://127.0.0.1:3211" \
-p "127.0.0.1::3210" -p "127.0.0.1::3211" "$docker_backend_image" >/dev/null
better_auth_port="$(docker port "$better_auth_name" 3210/tcp | sed -E 's/.*:([0-9]+)$/\1/')"
better_auth_site_port="$(docker port "$better_auth_name" 3211/tcp | sed -E 's/.*:([0-9]+)$/\1/')"
wait_backend "$better_auth_port"
better_auth_key="$(docker exec "$better_auth_name" ./generate_admin_key.sh 2>/dev/null | tail -n 1)"
export AUTH_PROVIDER=convex-better-auth AUTH_IDENTITY_NAMESPACE=better-auth:convex-smoke
export BETTER_AUTH_CONVEX_URL="http://127.0.0.1:$better_auth_port" BETTER_AUTH_CONVEX_ADMIN_KEY="$better_auth_key"
export BETTER_AUTH_SECRET="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("base64url"))')"
export CONUSAI_PUBLIC_URL=http://127.0.0.1:3000
# kit:convex-better-auth:end
cd "$app"
# Import the same shaped notes row the app's notes fixture exercises. Import is the supported
# Convex path for seeded documents; backup/export is independent of the function deployment.
cat > "$work/notes.jsonl" <<'JSONL'
{"id":"backup-notes-fixture","orgId":"backup-org","workspaceId":"backup-workspace","projectId":"backup-project","createdBy":"backup-test","title":"AT3.7 notes fixture","body":"restored from the Blob archive","createdAt":"2026-10-21T00:00:00.000Z","updatedAt":"2026-10-21T00:00:00.000Z","version":1}
JSONL
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$source_port" \
CONVEX_SELF_HOSTED_ADMIN_KEY="$source_key" \
pnpm exec convex import --table notes_note "$work/notes.jsonl" >/dev/null
for i in $(seq 1 30); do
curl -fsS "http://127.0.0.1:$platform_port/__e2e/health" >/dev/null 2>&1 && break
sleep 1
done
if ! curl -fsS "http://127.0.0.1:$platform_port/__e2e/health" >/dev/null; then
echo "fake platform did not become ready; log follows" >&2
cat "$work/platform.log" >&2 || true
exit 1
fi
# Deploy the generated app (including its Convex schema and component) into the source backend,
# install only test-scoped platform variables, and exercise real functions over the self-hosted
# HTTP API. This proves module codegen/deploy and the admin-auth identity boundary, beyond the
# adapter's convex-test unit suite.
# The support module's effect actions mint sessions for this channel against the fake platform.
export CONUSAI_SUPPORT_CHANNEL=convex-smoke-channel # kit:support
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$source_port" \
CONVEX_SELF_HOSTED_ADMIN_KEY="$source_key" \
CONUSAI_API_URL="$platform_action_url" CONUSAI_API_TOKEN=dev-token CONUSAI_PROJECT_ID=1 \
CONUSAI_ORG_SLUG=fixture-org \
node profiles/convex/deploy.mjs
# kit:convex-better-auth:begin
# deploy.mjs installed the Better Auth deployment's variables in the app's backend. The deploy ran on
# this host, through the published port; the backend reaches the Better Auth backend on their shared
# network instead, so its URL is the one variable set again here (in a deployment both are one URL).
[[ "$(CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$source_port" CONVEX_SELF_HOSTED_ADMIN_KEY="$source_key" \
pnpm exec convex env get AUTH_IDENTITY_NAMESPACE)" == "better-auth:convex-smoke" ]] \
|| { echo "deploy.mjs did not install the Better Auth variables in the app's backend" >&2; exit 1; }
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$source_port" CONVEX_SELF_HOSTED_ADMIN_KEY="$source_key" \
pnpm exec convex env set BETTER_AUTH_CONVEX_URL "http://$better_auth_name:3210" >/dev/null
export SMOKE_BETTER_AUTH_SITE_URL="http://127.0.0.1:$better_auth_site_port"
export SMOKE_AUTH_IDENTITY_NAMESPACE="$AUTH_IDENTITY_NAMESPACE" SMOKE_PUBLIC_URL="$CONUSAI_PUBLIC_URL"
# kit:convex-better-auth:end
# `convex deploy` generated the app-specific API types for this local deployment. Typecheck every
# query/mutation/action against those generated contexts before exercising the HTTP functions.
pnpm exec tsc -p convex/tsconfig.json --noEmit
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$source_port" \
CONVEX_SELF_HOSTED_ADMIN_KEY="$source_key" \
CONUSAI_API_URL="$platform_host_url" \
node profiles/convex/backup/test-deployed-app.mjs
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$source_port" CONVEX_SELF_HOSTED_ADMIN_KEY="$source_key" \
CONUSAI_API_URL="$platform_host_url" CONUSAI_API_TOKEN=dev-token \
CONUSAI_PROJECT_ID=1 CONVEX_BACKUP_RETENTION=1 CONVEX_CLI="$app/node_modules/.bin/convex" \
CONVEX_APP_DIR="$app" node profiles/convex/backup/runner.mjs --once
sleep 1
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$source_port" CONVEX_SELF_HOSTED_ADMIN_KEY="$source_key" \
CONUSAI_API_URL="$platform_host_url" CONUSAI_API_TOKEN=dev-token \
CONUSAI_PROJECT_ID=1 CONVEX_BACKUP_RETENTION=1 CONVEX_CLI="$app/node_modules/.bin/convex" \
CONVEX_APP_DIR="$app" node profiles/convex/backup/runner.mjs --once
backup_path="$(node --input-type=module - "$platform_port" <<'JS'
import { createObjectStore } from "@conusai-kit/platform"
const port = process.argv[2]
const store = createObjectStore({ CONUSAI_API_URL: `http://127.0.0.1:${port}/api`, CONUSAI_API_TOKEN: "dev-token", CONUSAI_PROJECT_ID: "1" })
const listed = await store.list({ prefix: "convex-backups/1/" })
if (listed.blobs.length !== 1) throw new Error(`expected one backup, found ${listed.blobs.length}`)
console.log(listed.blobs[0].pathname)
JS
)"
curl -fsS -H 'Authorization: Bearer dev-token' \
"http://127.0.0.1:$platform_port/api/blob/1/$backup_path" -o "$work/backup.zip"
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$target_port" \
CONVEX_SELF_HOSTED_ADMIN_KEY="$target_key" \
CONUSAI_API_URL="$platform_action_url" CONUSAI_API_TOKEN=dev-token CONUSAI_PROJECT_ID=1 \
CONUSAI_ORG_SLUG=fixture-org \
node profiles/convex/deploy.mjs
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$target_port" \
CONVEX_SELF_HOSTED_ADMIN_KEY="$target_key" \
pnpm exec convex import "$work/backup.zip" >/dev/null
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$target_port" \
CONVEX_SELF_HOSTED_ADMIN_KEY="$target_key" \
pnpm exec convex export --include-file-storage --path "$work/restored.zip" >/dev/null
# Read the complete archive: an early-exiting grep can SIGPIPE unzip under pipefail
# and report a missing fixture even after finding it.
python3 - "$work/restored.zip" <<'RESTORED_PROOF'
import json, sys, zipfile
with zipfile.ZipFile(sys.argv[1]) as archive:
names = [name for name in archive.namelist() if name == "notes_note/documents.jsonl" or name.endswith("/notes_note/documents.jsonl")]
rows = [json.loads(line) for name in names for line in archive.read(name).splitlines() if line.strip()]
assert any(row.get("id") == "backup-notes-fixture" and row.get("title") == "AT3.7 notes fixture" and row.get("body") == "restored from the Blob archive" for row in rows), "restored archive lacks the exact notes fixture"
RESTORED_PROOF
echo "AT3.7 round-trip green: local Convex notes fixture -> fake Blob -> empty local Convex backend"
+104
View File
@@ -0,0 +1,104 @@
# SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
# Local development for the convex profile: a self-hosted Convex backend and its dashboard in
# Docker (the images the platform mirrors), the fake platform, and the web shell. The one-shot
# `convex-deploy` service pushes schema/functions before web starts; use `convex dev` separately
# when iterating on the backend function tree.
#
# First run: `docker compose -f compose.yaml -f profiles/convex/compose.dev.yaml up -d backend`
# then set CONVEX_SELF_HOSTED_ADMIN_KEY in .env to the key printed by
# `docker exec <backend> ./generate_admin_key.sh`.
x-development: &development
NODE_ENV: development
CONUSAI_FAKE_PLATFORM: "1"
CONUSAI_API_URL: http://platform:4010/api
CONUSAI_API_TOKEN: dev-token
CONUSAI_PROJECT_ID: "1"
CONUSAI_ORG_ID: dev
CONUSAI_ORG_SLUG: dev
CONVEX_SELF_HOSTED_URL: http://127.0.0.1:${DEV_CONVEX_PORT:-53210}
CONVEX_SELF_HOSTED_ADMIN_KEY: ${CONVEX_SELF_HOSTED_ADMIN_KEY:?set the key printed by generate_admin_key.sh}
services:
backend:
# The self-hosted Convex backend at the digest the platform pins (images/manifest.toml;
# mirrored by the platform's own registry -- no ghcr.io reference, the self-hosted rule D5/D22).
image: build.conusai.com/conusai/convex-backend@sha256:1738f1673f8d63161043a7859710d2301b1e9d6271e06afbb7af31594ea3a58f
environment:
INSTANCE_NAME: conusai-app-dev
INSTANCE_SECRET: acc6c73cc10bacee7b09653b8ce76f8731f153795da9c25490d47c6d3d163912 # dev-only, never a deployment
volumes:
- convex-data:/data
# Published on loopback only, for `pnpm dev` and `convex dev` on this machine. 3210 is the
# API (CONVEX_SELF_HOSTED_URL), 6791 the dashboard's view of it.
ports:
- "127.0.0.1:${DEV_CONVEX_PORT:-53210}:3210"
- "127.0.0.1:${DEV_CONVEX_HTTP_PORT:-56791}:6791"
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:3210/version"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
dashboard:
image: build.conusai.com/conusai/convex-dashboard@sha256:b21b95c4abfe543b26d39e0a1e125d65b33bc216f99a0378e9a81642ced2f8c7
environment:
NEXT_PUBLIC_DEPLOYMENT_URL: http://127.0.0.1:${DEV_CONVEX_PORT:-53210}
ports: ["127.0.0.1:${DEV_CONVEX_DASHBOARD_PORT:-56792}:6791"]
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
platform:
build:
context: .
dockerfile: Dockerfile
target: runtime
working_dir: /app
command: ["node", "node_modules/@conusai-kit/testing/dist/fake-platform/cli.js"]
environment:
PORT: "4010"
HOST: 0.0.0.0
PUBLIC_URL: http://platform:4010
expose: ["4010"]
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4010/__e2e/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
interval: 5s
timeout: 5s
retries: 20
init: true
restart: unless-stopped
web:
environment:
<<: *development
AUTH_DEV_LOGIN: "1"
AUTH_INSECURE_COOKIES: "1"
ports: ["127.0.0.1:3000:3000"]
depends_on:
backend:
condition: service_healthy
platform:
condition: service_healthy
convex-deploy:
environment: *development
depends_on:
backend:
condition: service_healthy
backup:
environment:
<<: *development
CONVEX_SELF_HOSTED_URL: http://backend:3210
CONVEX_BACKUP_INTERVAL_HOURS: "24"
CONVEX_BACKUP_RETENTION: "30"
depends_on:
backend:
condition: service_healthy
platform:
condition: service_healthy
volumes:
convex-data:
+142
View File
@@ -0,0 +1,142 @@
# SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
# The Convex provider provisions a backend and injects its URL/admin key; it does not deploy this
# app's functions. The one-shot `convex-deploy` service does that from the app image, and web and
# backup wait for it to succeed. The app's server-side BFF is the only client of the admin key.
#
# The tenant wall on this profile is the repository predicate plus the custom functions the
# generated wrappers are built with (spec §5.3), proven by the kit's contract and isolation
# suites under convex-test (@conusai-kit/data-convex).
services:
web:
build:
context: .
dockerfile: Dockerfile
target: runtime
working_dir: /app/apps/web
command: ["node", "node_modules/next/dist/bin/next", "start"]
environment:
NODE_ENV: production
PORT: "3000"
CONUSAI_API_URL: ${CONUSAI_API_URL}
CONUSAI_API_TOKEN: ${CONUSAI_API_TOKEN}
CONUSAI_PROJECT_ID: ${CONUSAI_PROJECT_ID}
CONUSAI_PUBLIC_URL: ${CONUSAI_PUBLIC_URL}
CONUSAI_ORG_ID: ${CONUSAI_ORG_ID}
CONUSAI_ORG_SLUG: ${CONUSAI_ORG_SLUG}
CONUSAI_RELEASE: ${CONUSAI_RELEASE}
CONUSAI_ENVIRONMENT: ${CONUSAI_ENVIRONMENT:-}
CONUSAI_DSN: ${CONUSAI_DSN:-}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-}
OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
OTEL_SERVICE_VERSION: ${OTEL_SERVICE_VERSION:-}
AI_MODEL: ${AI_MODEL:-}
LOG_LEVEL: ${LOG_LEVEL:-}
CONVEX_SELF_HOSTED_URL: ${CONVEX_SELF_HOSTED_URL}
CONVEX_SELF_HOSTED_ADMIN_KEY: ${CONVEX_SELF_HOSTED_ADMIN_KEY}
AUTH_SESSION_SECRET: ${AUTH_SESSION_SECRET}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_WEB_CLIENT_ID: ${OIDC_WEB_CLIENT_ID:-}
OIDC_WEB_CLIENT_SECRET: ${OIDC_WEB_CLIENT_SECRET:-}
OIDC_RESOURCE: ${OIDC_RESOURCE:-}
OIDC_INTROSPECTION_CLIENT_ID: ${OIDC_INTROSPECTION_CLIENT_ID:-}
OIDC_INTROSPECTION_CLIENT_SECRET: ${OIDC_INTROSPECTION_CLIENT_SECRET:-}
OIDC_RESOURCE_STATUS_URL: ${OIDC_RESOURCE_STATUS_URL:-}
OIDC_SCOPES: ${OIDC_SCOPES:-}
AUTH_PROVIDER_GENERATION: ${AUTH_PROVIDER_GENERATION:-}
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-}
AUTH_SESSION_IDLE_SECONDS: ${AUTH_SESSION_IDLE_SECONDS:-}
AUTH_SESSION_ABSOLUTE_SECONDS: ${AUTH_SESSION_ABSOLUTE_SECONDS:-}
OIDC_AUDIENCE: ${OIDC_AUDIENCE:-}
AUTH_PROVIDER: ${AUTH_PROVIDER:-}
AUTH_IDENTITY_NAMESPACE: ${AUTH_IDENTITY_NAMESPACE:-}
# kit:convex-better-auth:begin -- Better Auth's own Convex deployment, beside the app's
BETTER_AUTH_CONVEX_URL: ${BETTER_AUTH_CONVEX_URL:-}
BETTER_AUTH_CONVEX_SITE_URL: ${BETTER_AUTH_CONVEX_SITE_URL:-}
BETTER_AUTH_CONVEX_ADMIN_KEY: ${BETTER_AUTH_CONVEX_ADMIN_KEY:-}
BETTER_AUTH_RELAY_INTERVAL_MS: ${BETTER_AUTH_RELAY_INTERVAL_MS:-}
# kit:convex-better-auth:end
expose: ["3000"]
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/ready').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
interval: 10s
timeout: 5s
retries: 3
start_period: 20s
init: true
stop_grace_period: 20s
depends_on:
convex-deploy:
condition: service_completed_successfully
restart: unless-stopped
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
# The provider only provisions Convex. Push this app's schema and functions before web and
# backup start, and make failure block traffic instead of reporting a healthy empty backend.
convex-deploy:
build:
context: .
dockerfile: Dockerfile
target: runtime
working_dir: /app
command: ["node", "profiles/convex/deploy.mjs"]
environment:
NODE_ENV: production
CONUSAI_API_URL: ${CONUSAI_API_URL}
CONUSAI_API_TOKEN: ${CONUSAI_API_TOKEN}
CONUSAI_PROJECT_ID: ${CONUSAI_PROJECT_ID}
CONUSAI_ORG_SLUG: ${CONUSAI_ORG_SLUG}
AI_MODEL: ${AI_MODEL:-}
DOCUMENTS_EXCLUDED_PAGE_CLASSES: ${DOCUMENTS_EXCLUDED_PAGE_CLASSES:-}
CONVEX_SELF_HOSTED_URL: ${CONVEX_SELF_HOSTED_URL}
CONVEX_SELF_HOSTED_ADMIN_KEY: ${CONVEX_SELF_HOSTED_ADMIN_KEY}
# kit:convex-better-auth:begin -- this job also deploys the Better Auth functions, and alone holds the secret;
# it installs the deployment's URL, admin key and namespace in the app's backend for the account actions
AUTH_PROVIDER: ${AUTH_PROVIDER:-}
AUTH_IDENTITY_NAMESPACE: ${AUTH_IDENTITY_NAMESPACE:-}
CONUSAI_PUBLIC_URL: ${CONUSAI_PUBLIC_URL}
BETTER_AUTH_CONVEX_URL: ${BETTER_AUTH_CONVEX_URL:-}
BETTER_AUTH_CONVEX_ADMIN_KEY: ${BETTER_AUTH_CONVEX_ADMIN_KEY:-}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-}
# kit:convex-better-auth:end
restart: "no"
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
# convex export is a CLI operation and cannot run from a Convex query/mutation/cron. This small
# sidecar schedules it outside the backend and stores archives in the platform Blob service.
backup:
build:
context: .
dockerfile: Dockerfile
target: runtime
working_dir: /app
command: ["node", "profiles/convex/backup/runner.mjs"]
environment:
NODE_ENV: production
CONUSAI_API_URL: ${CONUSAI_API_URL}
CONUSAI_API_TOKEN: ${CONUSAI_API_TOKEN}
CONUSAI_PROJECT_ID: ${CONUSAI_PROJECT_ID}
CONVEX_SELF_HOSTED_URL: ${CONVEX_SELF_HOSTED_URL}
CONVEX_SELF_HOSTED_ADMIN_KEY: ${CONVEX_SELF_HOSTED_ADMIN_KEY}
CONVEX_BACKUP_INTERVAL_HOURS: ${CONVEX_BACKUP_INTERVAL_HOURS:-24}
CONVEX_BACKUP_RETENTION: ${CONVEX_BACKUP_RETENTION:-30}
init: true
stop_grace_period: 30s
restart: unless-stopped
depends_on:
convex-deploy:
condition: service_completed_successfully
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
+42
View File
@@ -0,0 +1,42 @@
{
"$schema": "https://build.conusai.com/schemas/conusai.schema.json",
"schema_version": 2,
"compose": {
"file": "compose.yaml"
},
"services": {
"web": {
"public": {
"port": 3000,
"primary": true
},
"health_check": {
"path": "/api/ready",
"interval_seconds": 10,
"timeout_seconds": 5
},
"size": "small"
},
"convex-deploy": {
"size": "small"
},
"backup": {
"size": "small"
}
},
"links": [
{
"type": "convex"
},
{
"type": "identity"
}
],
"token": {
"permissions": ["ocr:use", "ai:execute", "usage:write", "guides:runtime", "support:widget-sessions", "privacy:subjects"]
},
"previews": {
"enabled": true,
"size": "small"
}
}
+69
View File
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The linked service provisions the self-hosted backend; this runner installs only the app's
// already-scoped platform service credentials in that backend, then deploys the generated app.
// The temporary env file is mode 0600 and removed on both success and failure.
import { spawnSync } from "node:child_process"
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { fileURLToPath } from "node:url"
const required = (name) => {
const value = process.env[name]?.trim()
if (!value || /[\r\n]/.test(value)) throw new Error(`Convex deploy is missing a valid ${name}`)
return value
}
const root = fileURLToPath(new URL("../../", import.meta.url))
const cli = join(root, "node_modules/convex/bin/main.js")
const variables = ["CONUSAI_API_URL", "CONUSAI_API_TOKEN", "CONUSAI_PROJECT_ID", "CONUSAI_ORG_SLUG"]
const optional = ["AI_MODEL", "DOCUMENTS_EXCLUDED_PAGE_CLASSES"]
// The support channel's public key, for the support module's effect actions (convex/lib/effects.ts).
optional.push("CONUSAI_SUPPORT_CHANNEL") // kit:support
// kit:convex-better-auth:begin -- the account actions end a deleted person's Better Auth account
// (convex/lib/effects.ts): the Better Auth deployment's URL, its admin key and the identity namespace.
if (process.env.AUTH_PROVIDER?.trim() === "convex-better-auth") {
variables.push("AUTH_PROVIDER", "AUTH_IDENTITY_NAMESPACE", "BETTER_AUTH_CONVEX_URL", "BETTER_AUTH_CONVEX_ADMIN_KEY")
}
// kit:convex-better-auth:end
for (const name of optional) {
const value = process.env[name]?.trim()
if (value) {
if (/[\r\n]/.test(value)) throw new Error(`Convex deploy has an invalid ${name}`)
variables.push(name)
}
}
const contents = `${variables.map((name) => `${name}=${required(name)}`).join("\n")}\n`
const directory = mkdtempSync(join(tmpdir(), "conusai-convex-env-"))
const envFile = join(directory, ".env")
try {
writeFileSync(envFile, contents, { mode: 0o600, flag: "wx" })
const seeded = spawnSync(process.execPath, [cli, "env", "set", "--from-file", envFile], {
cwd: root,
env: process.env,
stdio: "ignore",
})
if (seeded.error || seeded.status !== 0) {
throw new Error("Convex platform service environment sync failed")
}
const deployed = spawnSync(process.execPath, [cli, "deploy", "--typecheck", "disable"], {
cwd: root,
env: process.env,
stdio: "inherit",
})
if (deployed.error) throw deployed.error
if (deployed.status !== 0) process.exitCode = deployed.status ?? 1
// kit:convex-better-auth:begin -- Better Auth's functions go to their own Convex deployment.
if (deployed.status === 0 && process.env.AUTH_PROVIDER?.trim() === "convex-better-auth") {
const betterAuth = spawnSync(process.execPath, [join(root, "better-auth/deploy.mjs")], {
env: process.env,
stdio: "inherit",
})
if (betterAuth.error || betterAuth.status !== 0) process.exitCode = 1
}
// kit:convex-better-auth:end
} finally {
rmSync(directory, { recursive: true, force: true })
}
+68
View File
@@ -0,0 +1,68 @@
<!-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 -->
# The mariadb profile
**Status (source review 2026-10-21): adapter and migration assets, not a deployable
generated app profile.** The CLI accepts MariaDB pruning, but only the Convex
selection path rewrites the root runtime/deployment composition. The web/worker
roots have no MySQL composition, and the previously described profile config
file is absent. The platform injects `DATABASE_URL`; these draft assets still
name `MARIADB_URL`, so their variable contract also needs reconciliation.
The tables and generated-app steps below describe the target design. Adapter
and migration-engine tests do not establish a generated application deployment.
The persistence profile for an app whose primary store is MariaDB instead of Postgres
(spec §5.1): the same procedures, the same modules, the same migrations discipline, on the
Kysely MySQL dialect (`@conusai-kit/data-kysely/mysql`) with jobs on BullMQ over Valkey
(`@conusai-kit/jobs-bullmq`).
| | postgres (default) | mariadb (this profile) |
|---|---|---|
| Primary store | Postgres 18 | MariaDB LTS |
| Data adapter | `@conusai-kit/data-kysely` | `@conusai-kit/data-kysely/mysql` |
| Jobs | pg-boss (Postgres) | BullMQ on Valkey (`@conusai-kit/jobs-bullmq`) |
| Tenant wall | repository predicate + RLS (three roles, AT-D20) | repository predicate alone — MariaDB has no RLS |
| Links | `postgres`, `identity` | `mariadb`, `redis`, `identity` |
| Process env | `POSTGRES_URL`, `POSTGRES_READER_URL`, `POSTGRES_MIGRATE_URL` | `MARIADB_URL`, `REDIS_URL` |
## What this folder carries
- `conusai.json` — the profile's deployment contract: the same three services, links
`mariadb` + `redis` + `identity`. There is no `mariadb.migrate` flag: the three-role split it
declares on Postgres (data-services DS21) exists to make row-level security bind, and MariaDB
has none, so every process holds the one `MARIADB_URL` and ordering is compose's
`service_completed_successfully` alone (AT-D14).
- `compose.yaml`, `compose.dev.yaml` — the profile's compose files. The dev overlay runs
`mariadb:lts` and `valkey/valkey:9-alpine`, the images the platform's catalog offers.
- `migrations/` — the kernel tables (tenancy, sign-in, files, proposals, outbox, idempotency
keys) as MariaDB DDL, generated by `mysqlSchemaStatements` from the same entity definitions as
the postgres files and committed, forward-only. A module's own MariaDB migrations live with the
module: `modules/<id>/adapters/kysely/mysql/migrations/`, declared in its manifest as
`migrations: { postgres: "migrations", mariadb: "adapters/kysely/mysql/migrations" }`.
## Target generated-app changes (not wired)
The profile is chosen at generation time (`.conusai/template.json` `profile`), and
`kit template:prune --profile mariadb` removes the other dialects. Concretely:
1. `config/src/env.ts` reads `MARIADB_URL` (every process) instead of the three Postgres URLs —
one login, because there is no RLS to bind (AT-D20 is Postgres-only).
2. `apps/web` and `apps/worker` compose `createMysqlDataSource` (Kysely `MysqlDialect` + `mysql2`)
and the worker's queue is `createBullmqJobQueue`/`createBullmqWorker` over `REDIS_URL`.
3. The migrate job runs `runMysqlMigrationsFromEnv` over `profiles/mariadb/migrations/` (moved to
`migrations/mariadb/`) and the modules' `adapters/kysely/mysql/migrations/`.
4. This folder's `conusai.json`, `compose.yaml` and `compose.dev.yaml` replace the root ones.
The dialect differences are explicit, never generated at run time (spec §5.3): a module's MariaDB
schema is its own numbered SQL folder, reviewed like any migration.
## Proof
- `@conusai-kit/data-kysely`'s MySQL dialect passes the shared repository and capability contract
suites against `mariadb:lts` in testcontainers — the tenant predicate is the wall, and the
contract is what proves it.
- `@conusai-kit/jobs-bullmq` passes the shared JobQueue contract against `valkey/valkey:9` in
testcontainers.
- `scripts/vendor/test-template.sh app --profile mariadb` runs the template's gate and then
applies this folder's and the modules' MariaDB migrations to a real MariaDB LTS container, so
the committed DDL is proven against the engine, not just generated.
+120
View File
@@ -0,0 +1,120 @@
# SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
# Local development for the mariadb profile: MariaDB and Valkey in Docker, the fake platform, the
# migrate job, the demo data, the worker and the web shell. Nothing here is deployed: the platform
# reads compose.yaml alone (conusai.json's `compose.file`).
x-development: &development
NODE_ENV: development
CONUSAI_FAKE_PLATFORM: "1"
CONUSAI_API_URL: http://platform:4010/api
CONUSAI_API_TOKEN: dev-token
CONUSAI_PROJECT_ID: "1"
CONUSAI_ORG_ID: dev
services:
mariadb:
image: mariadb:lts
environment:
MARIADB_ROOT_PASSWORD: mariadb
MARIADB_DATABASE: appdev
volumes:
- mariadb-data:/var/lib/mysql
# Published on loopback only, for `pnpm dev`, which runs the processes on this machine.
ports: ["127.0.0.1:${DEV_MARIADB_PORT:-53307}:3306"]
healthcheck:
test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -uroot -pmariadb --silent"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
valkey:
image: valkey/valkey:9-alpine
volumes:
- valkey-data:/data
ports: ["127.0.0.1:${DEV_VALKEY_PORT:-56380}:6379"]
healthcheck:
test: ["CMD-SHELL", "valkey-cli ping | grep -q PONG"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
platform:
build:
context: .
dockerfile: Dockerfile
target: runtime
working_dir: /app
command: ["node", "node_modules/@conusai-kit/testing/dist/fake-platform/cli.js"]
environment:
PORT: "4010"
HOST: 0.0.0.0
PUBLIC_URL: http://platform:4010
expose: ["4010"]
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4010/__e2e/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
interval: 5s
timeout: 5s
retries: 20
init: true
restart: unless-stopped
migrate:
environment: *development
depends_on:
mariadb:
condition: service_healthy
seed:
build:
context: .
dockerfile: Dockerfile
target: runtime
working_dir: /app
command: ["node", "apps/worker/dist/seed-main.js"]
environment:
<<: *development
CONUSAI_PUBLIC_URL: ${CONUSAI_PUBLIC_URL:-http://127.0.0.1:3000}
MARIADB_URL: ${MARIADB_URL}
REDIS_URL: ${REDIS_URL}
depends_on:
migrate:
condition: service_completed_successfully
platform:
condition: service_healthy
restart: "no"
init: true
web:
environment:
<<: *development
AUTH_DEV_LOGIN: "1"
AUTH_INSECURE_COOKIES: "1"
ports: ["127.0.0.1:3000:3000"]
depends_on:
mariadb:
condition: service_healthy
valkey:
condition: service_healthy
migrate:
condition: service_completed_successfully
seed:
condition: service_completed_successfully
platform:
condition: service_healthy
worker:
environment: *development
depends_on:
mariadb:
condition: service_healthy
valkey:
condition: service_healthy
migrate:
condition: service_completed_successfully
platform:
condition: service_healthy
volumes:
mariadb-data:
valkey-data:
+152
View File
@@ -0,0 +1,152 @@
# SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
# The mariadb profile's compose.yaml: the same three processes from one image as the default
# (postgres) profile, with this profile's two links in place of the three Postgres roles
# (spec §5.1).
#
# MariaDB has no row-level security, so there is no owner/runtime/reader split to enforce it
# with: every process holds the one MARIADB_URL the platform injects for a linked MariaDB, and
# the tenant wall is the repository predicate, proven by the kit's contract suite
# (@conusai-kit/data-kysely's MySQL dialect runs it against mariadb:lts). Jobs are BullMQ on the
# linked Valkey (REDIS_URL).
#
# Ordering is the default profile's mechanism unchanged (decision AT-D14): migrate carries
# `restart: "no"`, and web and worker wait for it with `condition: service_completed_successfully`.
services:
web:
build:
context: .
dockerfile: Dockerfile
target: runtime
working_dir: /app/apps/web
command: ["node", "node_modules/next/dist/bin/next", "start"]
environment:
NODE_ENV: production
PORT: "3000"
CONUSAI_API_URL: ${CONUSAI_API_URL}
CONUSAI_API_TOKEN: ${CONUSAI_API_TOKEN}
CONUSAI_PROJECT_ID: ${CONUSAI_PROJECT_ID}
CONUSAI_PUBLIC_URL: ${CONUSAI_PUBLIC_URL}
CONUSAI_ORG_ID: ${CONUSAI_ORG_ID}
CONUSAI_ORG_SLUG: ${CONUSAI_ORG_SLUG:-}
CONUSAI_RELEASE: ${CONUSAI_RELEASE}
CONUSAI_ENVIRONMENT: ${CONUSAI_ENVIRONMENT:-}
CONUSAI_DSN: ${CONUSAI_DSN:-}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-}
OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
OTEL_SERVICE_VERSION: ${OTEL_SERVICE_VERSION:-}
AI_MODEL: ${AI_MODEL:-}
LOG_LEVEL: ${LOG_LEVEL:-}
MARIADB_URL: ${MARIADB_URL}
REDIS_URL: ${REDIS_URL}
AUTH_SESSION_SECRET: ${AUTH_SESSION_SECRET}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_WEB_CLIENT_ID: ${OIDC_WEB_CLIENT_ID:-}
OIDC_WEB_CLIENT_SECRET: ${OIDC_WEB_CLIENT_SECRET:-}
OIDC_AUDIENCE: ${OIDC_AUDIENCE:-}
OIDC_RESOURCE: ${OIDC_RESOURCE:-}
OIDC_INTROSPECTION_CLIENT_ID: ${OIDC_INTROSPECTION_CLIENT_ID:-}
OIDC_INTROSPECTION_CLIENT_SECRET: ${OIDC_INTROSPECTION_CLIENT_SECRET:-}
OIDC_RESOURCE_STATUS_URL: ${OIDC_RESOURCE_STATUS_URL:-}
OIDC_SCOPES: ${OIDC_SCOPES:-}
AUTH_PROVIDER_GENERATION: ${AUTH_PROVIDER_GENERATION:-}
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-}
AUTH_SESSION_IDLE_SECONDS: ${AUTH_SESSION_IDLE_SECONDS:-}
AUTH_SESSION_ABSOLUTE_SECONDS: ${AUTH_SESSION_ABSOLUTE_SECONDS:-}
expose: ["3000"]
depends_on:
migrate:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/ready').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
interval: 10s
timeout: 5s
retries: 3
start_period: 20s
init: true
stop_grace_period: 20s
restart: unless-stopped
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
worker:
build:
context: .
dockerfile: Dockerfile
target: runtime
working_dir: /app
command: ["node", "apps/worker/dist/main.js"]
environment:
NODE_ENV: production
CONUSAI_API_URL: ${CONUSAI_API_URL}
CONUSAI_API_TOKEN: ${CONUSAI_API_TOKEN}
CONUSAI_PROJECT_ID: ${CONUSAI_PROJECT_ID}
CONUSAI_ORG_ID: ${CONUSAI_ORG_ID}
CONUSAI_ORG_SLUG: ${CONUSAI_ORG_SLUG:-}
CONUSAI_PUBLIC_URL: ${CONUSAI_PUBLIC_URL}
CONUSAI_RELEASE: ${CONUSAI_RELEASE}
CONUSAI_ENVIRONMENT: ${CONUSAI_ENVIRONMENT:-}
CONUSAI_DSN: ${CONUSAI_DSN:-}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-}
OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
OTEL_SERVICE_VERSION: ${OTEL_SERVICE_VERSION:-}
AI_MODEL: ${AI_MODEL:-}
LOG_LEVEL: ${LOG_LEVEL:-}
DOCUMENTS_EXCLUDED_PAGE_CLASSES: ${DOCUMENTS_EXCLUDED_PAGE_CLASSES:-}
MARIADB_URL: ${MARIADB_URL}
REDIS_URL: ${REDIS_URL}
WORKER_HEARTBEAT_FILE: /tmp/worker-heartbeat
depends_on:
migrate:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "node", "apps/worker/dist/healthcheck.js"]
interval: 15s
timeout: 5s
retries: 3
start_period: 20s
init: true
stop_grace_period: 30s
restart: unless-stopped
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
migrate:
build:
context: .
dockerfile: Dockerfile
target: runtime
working_dir: /app
command: ["node", "apps/worker/dist/migrate.js"]
environment:
NODE_ENV: production
CONUSAI_API_URL: ${CONUSAI_API_URL}
CONUSAI_API_TOKEN: ${CONUSAI_API_TOKEN}
CONUSAI_PROJECT_ID: ${CONUSAI_PROJECT_ID}
CONUSAI_RELEASE: ${CONUSAI_RELEASE}
CONUSAI_ENVIRONMENT: ${CONUSAI_ENVIRONMENT:-}
CONUSAI_DSN: ${CONUSAI_DSN:-}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-}
OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
OTEL_SERVICE_VERSION: ${OTEL_SERVICE_VERSION:-}
# One login, not three: there is no RLS to bind, so the owner/runtime/reader split of the
# postgres profile has no counterpart here (decision AT-D20 is Postgres-only, DS21).
MARIADB_URL: ${MARIADB_URL}
restart: "no"
init: true
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
+43
View File
@@ -0,0 +1,43 @@
{
"$schema": "https://build.conusai.com/schemas/conusai.schema.json",
"schema_version": 2,
"compose": {
"file": "compose.yaml"
},
"services": {
"web": {
"public": {
"port": 3000,
"primary": true
},
"health_check": {
"path": "/api/ready",
"interval_seconds": 10,
"timeout_seconds": 5
},
"size": "small"
},
"worker": {
"size": "small"
},
"migrate": {}
},
"links": [
{
"type": "mariadb"
},
{
"type": "redis"
},
{
"type": "identity"
}
],
"token": {
"permissions": ["ocr:use", "ai:execute", "usage:write", "guides:runtime", "support:widget-sessions", "privacy:subjects"]
},
"previews": {
"enabled": true,
"size": "small"
}
}
+206
View File
@@ -0,0 +1,206 @@
-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
create table `outbox` (
`id` char(36) primary key,
`org_id` char(36) not null,
`name` text not null,
`payload` json not null,
`created_at` datetime(6) not null,
`attempts` integer not null,
`published_at` datetime(6),
`last_error` text,
`deferrals` integer not null default 0,
`deferred_since` datetime(6),
`deferred_until` datetime(6)
) engine=InnoDB default charset=utf8mb4;
create index `outbox_pending_idx` on `outbox` (`created_at`);
create table `idempotency_keys` (
`org_id` char(36) not null,
-- Not null, with the zero uuid where a scope has no workspace or project: a primary key
-- ignores a row whose column is NULL, and the key would stop being unique.
`workspace_id` char(36) not null,
`project_id` char(36) not null,
`procedure` varchar(255) not null,
`key` varchar(255) not null,
-- The response may itself be the JSON value null, so "finished" is a column of its own
-- rather than a non-null response. Null while the transaction that claimed this key is
-- running; a second caller's insert blocks on the key until that transaction ends.
`response` json,
`completed_at` datetime(6),
`created_at` datetime(6) not null,
primary key (`org_id`, `workspace_id`, `project_id`, `procedure`, `key`)
) engine=InnoDB default charset=utf8mb4;
create table `auth_api_keys` (
`id` char(36) primary key,
`version` integer not null,
`created_at` datetime(6) not null,
`updated_at` datetime(6) not null,
`org_id` char(36) not null,
`name` text not null,
`token_hash` text not null,
`prefix` text not null,
`user_id` char(36) not null,
`expires_at` text,
`revoked_at` text
) engine=InnoDB default charset=utf8mb4;
alter table `auth_api_keys` add constraint `auth_api_keys_org_id_token_hash_key` unique (`org_id`, `token_hash`);
create index `auth_api_keys_org_id_user_id_idx` on `auth_api_keys` (`org_id`, `user_id`);
create index `auth_api_keys_org_id_prefix_idx` on `auth_api_keys` (`org_id`, `prefix`(191));
create table `auth_sessions` (
`id` char(36) primary key,
`version` integer not null,
`created_at` datetime(6) not null,
`updated_at` datetime(6) not null,
`user_id` char(36) not null,
`issuer` text not null,
`subject` text not null,
`handle_hash` text not null,
`tokens` text not null,
`access_token_expires_at` text not null,
`last_seen_at` text not null,
`absolute_expires_at` text not null,
`revoked_at` text
) engine=InnoDB default charset=utf8mb4;
alter table `auth_sessions` add constraint `auth_sessions_handle_hash_key` unique (`handle_hash`);
create index `auth_sessions_user_id_idx` on `auth_sessions` (`user_id`);
create index `auth_sessions_absolute_expires_at_idx` on `auth_sessions` (`absolute_expires_at`(191));
create table `extensions` (
`id` char(36) primary key,
`version` integer not null,
`created_at` datetime(6) not null,
`updated_at` datetime(6) not null,
`org_id` char(36) not null,
`subject_type` text not null,
`subject_id` char(36) not null,
`namespace` text not null,
`payload` json not null,
`payload_version` integer not null
) engine=InnoDB default charset=utf8mb4;
alter table `extensions` add constraint `extensions_org_id_subject_type_subject_id_namespace_key` unique (`org_id`, `subject_type`, `subject_id`, `namespace`);
create index `extensions_org_id_namespace_idx` on `extensions` (`org_id`, `namespace`(191));
create table `invitations` (
`id` char(36) primary key,
`version` integer not null,
`created_at` datetime(6) not null,
`updated_at` datetime(6) not null,
`org_id` char(36) not null,
`email` text not null,
`role` text not null,
`workspace_id` char(36),
`workspace_role` text,
`token_hash` text not null,
`expires_at` text not null,
`accepted_at` text,
`invited_by` char(36) not null
) engine=InnoDB default charset=utf8mb4;
alter table `invitations` add constraint `invitations_org_id_token_hash_key` unique (`org_id`, `token_hash`);
create index `invitations_org_id_email_idx` on `invitations` (`org_id`, `email`(191));
create index `invitations_org_id_accepted_at_idx` on `invitations` (`org_id`, `accepted_at`(191));
create table `org_memberships` (
`id` char(36) primary key,
`version` integer not null,
`created_at` datetime(6) not null,
`updated_at` datetime(6) not null,
`org_id` char(36) not null,
`user_id` char(36) not null,
`role` text not null
) engine=InnoDB default charset=utf8mb4;
alter table `org_memberships` add constraint `org_memberships_org_id_user_id_key` unique (`org_id`, `user_id`);
create index `org_memberships_org_id_role_idx` on `org_memberships` (`org_id`, `role`(191));
create table `organizations` (
`id` char(36) primary key,
`version` integer not null,
`created_at` datetime(6) not null,
`updated_at` datetime(6) not null,
`slug` text not null,
`name` text not null,
`status` text not null,
`settings` json not null,
`branding` json
) engine=InnoDB default charset=utf8mb4;
alter table `organizations` add constraint `organizations_slug_key` unique (`slug`);
create index `organizations_status_idx` on `organizations` (`status`(191));
create table `projects` (
`id` char(36) primary key,
`version` integer not null,
`created_at` datetime(6) not null,
`updated_at` datetime(6) not null,
`org_id` char(36) not null,
`workspace_id` char(36) not null,
`slug` text not null,
`name` text not null,
`module_key` text not null,
`archived_at` text
) engine=InnoDB default charset=utf8mb4;
alter table `projects` add constraint `projects_org_id_workspace_id_slug_key` unique (`org_id`, `workspace_id`, `slug`);
create index `projects_org_id_workspace_id_archived_at_idx` on `projects` (`org_id`, `workspace_id`, `archived_at`(191));
create table `users` (
`id` char(36) primary key,
`version` integer not null,
`created_at` datetime(6) not null,
`updated_at` datetime(6) not null,
`issuer` text not null,
`subject` text not null,
`email` text not null,
`display_name` text not null,
`locale` text not null
) engine=InnoDB default charset=utf8mb4;
alter table `users` add constraint `users_issuer_subject_key` unique (`issuer`, `subject`);
create index `users_email_idx` on `users` (`email`(191));
create table `workspace_memberships` (
`id` char(36) primary key,
`version` integer not null,
`created_at` datetime(6) not null,
`updated_at` datetime(6) not null,
`org_id` char(36) not null,
`workspace_id` char(36) not null,
`user_id` char(36) not null,
`role` text not null
) engine=InnoDB default charset=utf8mb4;
alter table `workspace_memberships` add constraint `workspace_memberships_org_id_workspace_id_user_id_key` unique (`org_id`, `workspace_id`, `user_id`);
create index `workspace_memberships_org_id_user_id_idx` on `workspace_memberships` (`org_id`, `user_id`);
create table `workspaces` (
`id` char(36) primary key,
`version` integer not null,
`created_at` datetime(6) not null,
`updated_at` datetime(6) not null,
`org_id` char(36) not null,
`slug` text not null,
`name` text not null,
`is_default` boolean not null,
`visibility` text not null
) engine=InnoDB default charset=utf8mb4;
alter table `workspaces` add constraint `workspaces_org_id_slug_key` unique (`org_id`, `slug`);
@@ -0,0 +1,28 @@
-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
create table `files_file` (
`id` char(36) primary key,
`version` integer not null,
`created_at` datetime(6) not null,
`updated_at` datetime(6) not null,
`org_id` char(36) not null,
`workspace_id` char(36) not null,
`project_id` char(36) not null,
`status` text not null,
`storage_key` text not null,
`filename` text not null,
`content_type` text not null,
`size` integer,
`sha256` text,
`uploaded_by` char(36) not null,
`stored_at` text,
`upload_id` text,
`declared_size` integer,
`part_size` integer,
`deleting_at` text
) engine=InnoDB default charset=utf8mb4;
alter table `files_file` add constraint `files_file_org_id_workspace_id_project_id_storage_key_key` unique (`org_id`, `workspace_id`, `project_id`, `storage_key`);
create index `files_file_org_id_workspace_id_project_id_status_idx` on `files_file` (`org_id`, `workspace_id`, `project_id`, `status`(191));
create index `files_file_org_id_workspace_id_project_id_sha256_idx` on `files_file` (`org_id`, `workspace_id`, `project_id`, `sha256`(191));
@@ -0,0 +1,28 @@
-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
create table `proposals` (
`id` char(36) primary key,
`version` integer not null,
`created_at` datetime(6) not null,
`updated_at` datetime(6) not null,
`org_id` char(36) not null,
`workspace_id` char(36) not null,
`project_id` char(36) not null,
`status` text not null,
`target_key` text not null,
`target_record_id` text,
`written_record_id` text,
`change` json not null,
`evidence` json not null,
`model` text not null,
`prompt_version` text not null,
`created_by` text not null,
`edited_by` text,
`edited_at` text,
`decided_by` text,
`decided_at` text,
`reason` text
) engine=InnoDB default charset=utf8mb4;
create index `proposals_org_id_workspace_id_project_id_status_idx` on `proposals` (`org_id`, `workspace_id`, `project_id`, `status`(191));
create index `proposals_org_id_workspace_id_project_id_target_key_tar_25412770` on `proposals` (`org_id`, `workspace_id`, `project_id`, `target_key`(191), `target_record_id`(191));
@@ -0,0 +1,36 @@
-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
create table `app_user_identity` (
`id` char(36) primary key,
`version` integer not null,
`created_at` datetime(6) not null,
`updated_at` datetime(6) not null,
`issuer` varchar(255) not null,
`subject` varchar(255) not null,
`user_id` char(36) not null,
`migration_id` varchar(255)
) engine=InnoDB default charset=utf8mb4;
alter table `app_user_identity` add constraint `app_user_identity_issuer_subject_key` unique (`issuer`, `subject`);
create index `app_user_identity_user_id_idx` on `app_user_identity` (`user_id`);
insert into `app_user_identity` (
`id`,
`version`,
`created_at`,
`updated_at`,
`issuer`,
`subject`,
`user_id`,
`migration_id`
)
select
`id`,
1,
`created_at`,
`updated_at`,
`issuer`,
`subject`,
`id`,
'legacy-users-issuer-subject-v1'
from `users`;
+91
View File
@@ -0,0 +1,91 @@
<!-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 -->
# The mongodb profile
**Status (source review 2026-10-21): adapter and migration assets, not a selectable
app profile.** `kit template:prune --profile mongodb` is still refused; its error
incorrectly says no adapter exists, although `@conusai-kit/data-mongo` is
implemented. The web/worker composition is unfinished. The tables and generated-
app steps below describe the target design; repository/transaction and migration
tests do not establish a generated application deployment.
The persistence profile for an app whose primary store is MongoDB instead of Postgres
(spec §5.1): the same procedures, the same modules, the same migrations discipline, on the
official `mongodb` driver (`@conusai-kit/data-mongo`) with jobs on BullMQ over Valkey
(`@conusai-kit/jobs-bullmq`).
| | postgres (default) | mongodb (this profile) |
|---|---|---|
| Primary store | Postgres 18 | MongoDB 8.0, **single-node replica set** (below) |
| Data adapter | `@conusai-kit/data-kysely` | `@conusai-kit/data-mongo` |
| Jobs | pg-boss (Postgres) | BullMQ on Valkey (`@conusai-kit/jobs-bullmq`) |
| Tenant wall | repository predicate + RLS (three roles, AT-D20) | repository predicate + compound indexes leading with `org_id` — MongoDB has no RLS |
| Links | `postgres`, `identity` | `mongodb`, `redis`, `identity` |
| Process env | `POSTGRES_URL`, `POSTGRES_READER_URL`, `POSTGRES_MIGRATE_URL` | `MONGODB_URL`, `REDIS_URL` |
## The replica_set requirement (AT-D15)
**Create the MongoDB service with `replica_set` set.** A managed MongoDB is a standalone server
unless `replica_set` is given at creation (the console's create form offers it and states it
cannot be changed afterwards), and a standalone server has no multi-document transactions — a
`unitOfWork.run` on it would be silently non-atomic. `@conusai-kit/data-mongo` therefore asks the
server what it is at boot (`hello`'s `setName`) and refuses to start against a standalone mongod
with a `MongoTopologyError` that names this setting. A single-node replica set is enough: with
`replica_set` set, the platform starts mongod with `--replSet` and `rs.initiate()`, and the
injected `MONGODB_URL` carries `directConnection=true`. The `conusai.json` in this folder links
`mongodb` — that link is only valid for an app whose MongoDB service was created this way.
## What this folder carries
- `conusai.json` — the profile's deployment contract: the same three services, links `mongodb` +
`redis` + `identity`. There is no `mongodb.migrate` flag: the three-role split the postgres
profile declares (data-services DS21) exists to make row-level security bind, and MongoDB has
none, so every process holds the one `MONGODB_URL` and ordering is compose's
`service_completed_successfully` alone (AT-D14).
- `compose.yaml`, `compose.dev.yaml` — the profile's compose files. The dev overlay runs
`mongo:8.0` as a single-node replica set (`--replSet` plus a one-shot `mongo-init` that runs
`rs.initiate()`) and `valkey/valkey:9-alpine`, the images the platform's catalog offers.
- `migrations/` — the kernel collections (tenancy, sign-in, files, proposals, outbox, idempotency
keys) as declarative MongoDB commands in JSON — a collection with its `$jsonSchema` validator
generated from the entity's zod schema, and every unique group and index as a compound index
leading with `org_id` — generated by `mongoSchemaCommands` from the same entity definitions as
the postgres files and committed, forward-only. A module's own MongoDB migrations live with the
module: `modules/<id>/adapters/mongo/migrations/`, declared in its manifest as
`migrations: { postgres: "migrations", mariadb: "...", mongodb: "adapters/mongo/migrations" }`.
## Target generated-app changes (not wired)
The profile is chosen at generation time (`.conusai/template.json` `profile`), and
`kit template:prune --profile mongodb` removes the other dialects. Concretely:
1. `config/src/env.ts` reads `MONGODB_URL` (every process) instead of the three Postgres URLs —
one login, because there is no RLS to bind (AT-D20 is Postgres-only).
2. `apps/web` and `apps/worker` compose `connectMongoDataSourceFromEnv` (which refuses a
standalone server, above) and the worker's queue is `createBullmqJobQueue`/`createBullmqWorker`
over `REDIS_URL`.
3. The migrate job runs `runMongoMigrationsFromEnv` over `profiles/mongodb/migrations/` (moved to
`migrations/mongodb/`) and the modules' `adapters/mongo/migrations/`, each JSON file a
`mongoCommandsMigration`.
4. This folder's `conusai.json`, `compose.yaml` and `compose.dev.yaml` replace the root ones.
The dialect differences are explicit, never generated at run time (spec §5.3): a module's MongoDB
schema is its own numbered folder, reviewed like any migration.
## Proof
- `@conusai-kit/data-mongo` passes the shared repository and capability contract suites in
testcontainers against MongoDB 8 running as a single-node replica set, including a multi-entity
UnitOfWork commit and rollback — the tenant predicate is the wall, and the contract is what
proves it. Against a standalone mongod the adapter refuses to boot with `MongoTopologyError`,
naming `replica_set` (AT-D15). The suite's image is `mongo:8.2`, not `mongo:8.0`: the 8.0 binary
refuses to start on Linux kernels 6.19 and newer (SERVER-121912, the TCMalloc/rseq
incompatibility — the refusal is compiled into the server), which covers current development
hosts; the topology under test is the same on either minor, and the platform's managed image is
`mongodb-walg:8.0`. The same applies to this folder's `compose.dev.yaml` on such a kernel: bump
its `mongo:` tag to 8.2.
- `@conusai-kit/jobs-bullmq` passes the shared JobQueue contract against `valkey/valkey:9` in
testcontainers.
- `scripts/vendor/test-template.sh app --profile mongodb` runs the template's gate and then
applies this folder's and the modules' MongoDB migrations to a real MongoDB 8 container
started as a single-node replica set, and probes that the adapter boots on it and refuses a
standalone server — so the committed schema is proven against the engine, not just generated.
+144
View File
@@ -0,0 +1,144 @@
# SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
# Local development for the mongodb profile: MongoDB and Valkey in Docker, the fake platform, the
# migrate job, the demo data, the worker and the web shell. Nothing here is deployed: the platform
# reads compose.yaml alone (conusai.json's `compose.file`).
#
# The dev MongoDB runs as a single-node replica set (AT-D15): `--replSet` plus the one-shot
# `mongo-init` container's rs.initiate(), because a standalone mongod has no multi-document
# transactions and the adapter refuses to boot against one. The advertised member host is the
# compose service name, so app containers reach it as `mongo:27017` with `directConnection=true`;
# `pnpm dev` on this machine uses the loopback publication with `directConnection=true` as well.
x-development: &development
NODE_ENV: development
CONUSAI_FAKE_PLATFORM: "1"
CONUSAI_API_URL: http://platform:4010/api
CONUSAI_API_TOKEN: dev-token
CONUSAI_PROJECT_ID: "1"
CONUSAI_ORG_ID: dev
services:
mongo:
image: mongo:8.0
command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
volumes:
- mongo-data:/data/db
# Published on loopback only, for `pnpm dev`, which runs the processes on this machine.
ports: ["127.0.0.1:${DEV_MONGO_PORT:-57017}:27017"]
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand({ ping: 1 })"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
mongo-init:
image: mongo:8.0
command:
- mongosh
- --host
- mongo:27017
- --quiet
- --eval
- |
try {
rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "mongo:27017" }] })
} catch (e) {
if (!/already initialized/.test(String(e))) throw e
}
depends_on:
mongo:
condition: service_healthy
restart: "no"
init: true
valkey:
image: valkey/valkey:9-alpine
volumes:
- valkey-data:/data
ports: ["127.0.0.1:${DEV_VALKEY_PORT:-56380}:6379"]
healthcheck:
test: ["CMD-SHELL", "valkey-cli ping | grep -q PONG"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
platform:
build:
context: .
dockerfile: Dockerfile
target: runtime
working_dir: /app
command: ["node", "node_modules/@conusai-kit/testing/dist/fake-platform/cli.js"]
environment:
PORT: "4010"
HOST: 0.0.0.0
PUBLIC_URL: http://platform:4010
expose: ["4010"]
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4010/__e2e/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
interval: 5s
timeout: 5s
retries: 20
init: true
restart: unless-stopped
migrate:
environment: *development
depends_on:
mongo-init:
condition: service_completed_successfully
seed:
build:
context: .
dockerfile: Dockerfile
target: runtime
working_dir: /app
command: ["node", "apps/worker/dist/seed-main.js"]
environment:
<<: *development
CONUSAI_PUBLIC_URL: ${CONUSAI_PUBLIC_URL:-http://127.0.0.1:3000}
MONGODB_URL: ${MONGODB_URL}
REDIS_URL: ${REDIS_URL}
depends_on:
migrate:
condition: service_completed_successfully
platform:
condition: service_healthy
restart: "no"
init: true
web:
environment:
<<: *development
AUTH_DEV_LOGIN: "1"
AUTH_INSECURE_COOKIES: "1"
ports: ["127.0.0.1:3000:3000"]
depends_on:
mongo-init:
condition: service_completed_successfully
valkey:
condition: service_healthy
migrate:
condition: service_completed_successfully
seed:
condition: service_completed_successfully
platform:
condition: service_healthy
worker:
environment: *development
depends_on:
mongo-init:
condition: service_completed_successfully
valkey:
condition: service_healthy
migrate:
condition: service_completed_successfully
platform:
condition: service_healthy
volumes:
mongo-data:
valkey-data:
+147
View File
@@ -0,0 +1,147 @@
# SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
# The mongodb profile's compose.yaml: the same three processes from one image as the default
# (postgres) profile, with this profile's two links in place of the three Postgres roles
# (spec §5.1).
#
# MongoDB has no row-level security, so there is no owner/runtime/reader split to enforce it
# with: every process holds the one MONGODB_URL the platform injects for a linked MongoDB, and
# the tenant wall is the repository predicate, proven by the kit's contract suite
# (@conusai-kit/data-mongo runs it against MongoDB 8 as a single-node replica set). Jobs are
# BullMQ on the linked Valkey (REDIS_URL).
#
# The linked MongoDB service must be created with `replica_set` set (AT-D15): a standalone server
# has no multi-document transactions, and the adapter refuses to boot against one. When
# `replica_set` is set the platform starts mongod with --replSet and rs.initiate(), and the
# injected MONGODB_URL carries directConnection=true.
#
# Ordering is the default profile's mechanism unchanged (decision AT-D14): migrate carries
# `restart: "no"`, and web and worker wait for it with `condition: service_completed_successfully`.
services:
web:
build:
context: .
dockerfile: Dockerfile
target: runtime
working_dir: /app/apps/web
command: ["node", "node_modules/next/dist/bin/next", "start"]
environment:
NODE_ENV: production
PORT: "3000"
CONUSAI_API_URL: ${CONUSAI_API_URL}
CONUSAI_API_TOKEN: ${CONUSAI_API_TOKEN}
CONUSAI_PROJECT_ID: ${CONUSAI_PROJECT_ID}
CONUSAI_PUBLIC_URL: ${CONUSAI_PUBLIC_URL}
CONUSAI_ORG_ID: ${CONUSAI_ORG_ID}
CONUSAI_ORG_SLUG: ${CONUSAI_ORG_SLUG:-}
CONUSAI_RELEASE: ${CONUSAI_RELEASE}
CONUSAI_ENVIRONMENT: ${CONUSAI_ENVIRONMENT:-}
CONUSAI_DSN: ${CONUSAI_DSN:-}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-}
OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
OTEL_SERVICE_VERSION: ${OTEL_SERVICE_VERSION:-}
AI_MODEL: ${AI_MODEL:-}
LOG_LEVEL: ${LOG_LEVEL:-}
MONGODB_URL: ${MONGODB_URL}
REDIS_URL: ${REDIS_URL}
AUTH_SESSION_SECRET: ${AUTH_SESSION_SECRET}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_WEB_CLIENT_ID: ${OIDC_WEB_CLIENT_ID:-}
OIDC_WEB_CLIENT_SECRET: ${OIDC_WEB_CLIENT_SECRET:-}
OIDC_RESOURCE: ${OIDC_RESOURCE:-}
OIDC_INTROSPECTION_CLIENT_ID: ${OIDC_INTROSPECTION_CLIENT_ID:-}
OIDC_INTROSPECTION_CLIENT_SECRET: ${OIDC_INTROSPECTION_CLIENT_SECRET:-}
OIDC_RESOURCE_STATUS_URL: ${OIDC_RESOURCE_STATUS_URL:-}
OIDC_SCOPES: ${OIDC_SCOPES:-}
AUTH_PROVIDER_GENERATION: ${AUTH_PROVIDER_GENERATION:-}
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-}
AUTH_SESSION_IDLE_SECONDS: ${AUTH_SESSION_IDLE_SECONDS:-}
AUTH_SESSION_ABSOLUTE_SECONDS: ${AUTH_SESSION_ABSOLUTE_SECONDS:-}
OIDC_AUDIENCE: ${OIDC_AUDIENCE:-}
expose: ["3000"]
depends_on:
migrate:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/ready').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
interval: 10s
timeout: 5s
retries: 3
start_period: 20s
init: true
stop_grace_period: 20s
restart: unless-stopped
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
worker:
build:
context: .
dockerfile: Dockerfile
target: runtime
working_dir: /app
command: ["node", "apps/worker/dist/main.js"]
environment:
NODE_ENV: production
CONUSAI_API_URL: ${CONUSAI_API_URL}
CONUSAI_API_TOKEN: ${CONUSAI_API_TOKEN}
CONUSAI_PROJECT_ID: ${CONUSAI_PROJECT_ID}
CONUSAI_ORG_ID: ${CONUSAI_ORG_ID}
CONUSAI_ORG_SLUG: ${CONUSAI_ORG_SLUG:-}
CONUSAI_RELEASE: ${CONUSAI_RELEASE}
CONUSAI_ENVIRONMENT: ${CONUSAI_ENVIRONMENT:-}
CONUSAI_DSN: ${CONUSAI_DSN:-}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-}
OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
OTEL_SERVICE_VERSION: ${OTEL_SERVICE_VERSION:-}
AI_MODEL: ${AI_MODEL:-}
LOG_LEVEL: ${LOG_LEVEL:-}
MONGODB_URL: ${MONGODB_URL}
REDIS_URL: ${REDIS_URL}
WORKER_HEARTBEAT_FILE: /tmp/worker-heartbeat
depends_on:
migrate:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "node", "apps/worker/dist/healthcheck.js"]
interval: 15s
timeout: 5s
retries: 3
start_period: 20s
init: true
stop_grace_period: 30s
restart: unless-stopped
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
migrate:
build:
context: .
dockerfile: Dockerfile
target: runtime
working_dir: /app
command: ["node", "apps/worker/dist/migrate.js"]
environment:
NODE_ENV: production
CONUSAI_API_URL: ${CONUSAI_API_URL}
CONUSAI_API_TOKEN: ${CONUSAI_API_TOKEN}
CONUSAI_PROJECT_ID: ${CONUSAI_PROJECT_ID}
# One login, not three: there is no RLS to bind, so the owner/runtime/reader split of the
# postgres profile has no counterpart here (decision AT-D20 is Postgres-only, DS21).
MONGODB_URL: ${MONGODB_URL}
restart: "no"
init: true
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
+40
View File
@@ -0,0 +1,40 @@
{
"$schema": "https://build.conusai.com/schemas/conusai.schema.json",
"schema_version": 2,
"compose": {
"file": "compose.yaml"
},
"services": {
"web": {
"public": {
"port": 3000,
"primary": true
},
"health_check": {
"path": "/api/ready",
"interval_seconds": 10,
"timeout_seconds": 5
},
"size": "small"
},
"worker": {
"size": "small"
},
"migrate": {}
},
"links": [
{
"type": "mongodb"
},
{
"type": "redis"
},
{
"type": "identity"
}
],
"previews": {
"enabled": true,
"size": "small"
}
}
@@ -0,0 +1,933 @@
[
{
"createCollection": "outbox",
"validator": {
"$jsonSchema": {
"bsonType": "object",
"required": [
"_id",
"org_id",
"name",
"payload",
"created_at",
"attempts",
"published_at",
"last_error",
"deferrals",
"deferred_since",
"deferred_until"
],
"properties": {
"_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"org_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"name": {
"bsonType": "string"
},
"payload": {},
"created_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"attempts": {
"bsonType": "number"
},
"published_at": {
"bsonType": ["string", "null"],
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"last_error": {
"bsonType": ["string", "null"]
},
"deferrals": {
"bsonType": "number"
},
"deferred_since": {
"bsonType": ["string", "null"],
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"deferred_until": {
"bsonType": ["string", "null"],
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
}
},
"additionalProperties": false
}
}
},
{
"createIndex": {
"collection": "outbox",
"keys": {
"created_at": 1
},
"name": "outbox_pending_idx",
"unique": false
}
},
{
"createCollection": "idempotency_keys",
"validator": {
"$jsonSchema": {
"bsonType": "object",
"required": [
"org_id",
"workspace_id",
"project_id",
"procedure",
"key",
"response",
"completed_at",
"created_at"
],
"properties": {
"_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"org_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"workspace_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"project_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"procedure": {
"bsonType": "string"
},
"key": {
"bsonType": "string"
},
"response": {},
"completed_at": {
"bsonType": ["string", "null"],
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"created_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
}
},
"additionalProperties": false
}
}
},
{
"createIndex": {
"collection": "idempotency_keys",
"keys": {
"org_id": 1,
"workspace_id": 1,
"project_id": 1,
"procedure": 1,
"key": 1
},
"name": "idempotency_keys_scope_key",
"unique": true
}
},
{
"createCollection": "auth_api_keys",
"validator": {
"$jsonSchema": {
"bsonType": "object",
"required": ["_id", "version", "created_at", "updated_at", "org_id", "name", "token_hash", "prefix", "user_id"],
"properties": {
"_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"version": {
"bsonType": "number"
},
"created_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"updated_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"org_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"name": {
"bsonType": "string"
},
"token_hash": {
"bsonType": "string"
},
"prefix": {
"bsonType": "string"
},
"user_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"expires_at": {
"bsonType": ["string", "null"]
},
"revoked_at": {
"bsonType": ["string", "null"]
}
},
"additionalProperties": false
}
}
},
{
"createIndex": {
"collection": "auth_api_keys",
"keys": {
"org_id": 1,
"token_hash": 1
},
"name": "auth_api_keys_org_id_token_hash_key",
"unique": true
}
},
{
"createIndex": {
"collection": "auth_api_keys",
"keys": {
"org_id": 1,
"user_id": 1
},
"name": "auth_api_keys_org_id_user_id_idx",
"unique": false
}
},
{
"createIndex": {
"collection": "auth_api_keys",
"keys": {
"org_id": 1,
"prefix": 1
},
"name": "auth_api_keys_org_id_prefix_idx",
"unique": false
}
},
{
"createCollection": "auth_sessions",
"validator": {
"$jsonSchema": {
"bsonType": "object",
"required": [
"_id",
"version",
"created_at",
"updated_at",
"user_id",
"issuer",
"subject",
"handle_hash",
"tokens",
"access_token_expires_at",
"last_seen_at",
"absolute_expires_at"
],
"properties": {
"_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"version": {
"bsonType": "number"
},
"created_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"updated_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"user_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"issuer": {
"bsonType": "string"
},
"subject": {
"bsonType": "string"
},
"handle_hash": {
"bsonType": "string"
},
"tokens": {
"bsonType": "string"
},
"access_token_expires_at": {
"bsonType": "string"
},
"last_seen_at": {
"bsonType": "string"
},
"absolute_expires_at": {
"bsonType": "string"
},
"revoked_at": {
"bsonType": ["string", "null"]
}
},
"additionalProperties": false
}
}
},
{
"createIndex": {
"collection": "auth_sessions",
"keys": {
"handle_hash": 1
},
"name": "auth_sessions_handle_hash_key",
"unique": true
}
},
{
"createIndex": {
"collection": "auth_sessions",
"keys": {
"user_id": 1
},
"name": "auth_sessions_user_id_idx",
"unique": false
}
},
{
"createIndex": {
"collection": "auth_sessions",
"keys": {
"absolute_expires_at": 1
},
"name": "auth_sessions_absolute_expires_at_idx",
"unique": false
}
},
{
"createCollection": "extensions",
"validator": {
"$jsonSchema": {
"bsonType": "object",
"required": [
"_id",
"version",
"created_at",
"updated_at",
"org_id",
"subject_type",
"subject_id",
"namespace",
"payload",
"payload_version"
],
"properties": {
"_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"version": {
"bsonType": "number"
},
"created_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"updated_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"org_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"subject_type": {
"bsonType": "string"
},
"subject_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"namespace": {
"bsonType": "string"
},
"payload": {
"bsonType": "object"
},
"payload_version": {
"bsonType": "number"
}
},
"additionalProperties": false
}
}
},
{
"createIndex": {
"collection": "extensions",
"keys": {
"org_id": 1,
"subject_type": 1,
"subject_id": 1,
"namespace": 1
},
"name": "extensions_org_id_subject_type_subject_id_namespace_key",
"unique": true
}
},
{
"createIndex": {
"collection": "extensions",
"keys": {
"org_id": 1,
"namespace": 1
},
"name": "extensions_org_id_namespace_idx",
"unique": false
}
},
{
"createCollection": "invitations",
"validator": {
"$jsonSchema": {
"bsonType": "object",
"required": [
"_id",
"version",
"created_at",
"updated_at",
"org_id",
"email",
"role",
"workspace_id",
"token_hash",
"expires_at",
"invited_by"
],
"properties": {
"_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"version": {
"bsonType": "number"
},
"created_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"updated_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"org_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"email": {
"bsonType": "string"
},
"role": {
"bsonType": "string",
"enum": ["viewer", "member", "admin", "owner"]
},
"workspace_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"workspace_role": {
"bsonType": ["string", "null"],
"enum": ["viewer", "editor", "admin"]
},
"token_hash": {
"bsonType": "string"
},
"expires_at": {
"bsonType": "string"
},
"accepted_at": {
"bsonType": ["string", "null"]
},
"invited_by": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
}
},
"additionalProperties": false
}
}
},
{
"createIndex": {
"collection": "invitations",
"keys": {
"org_id": 1,
"token_hash": 1
},
"name": "invitations_org_id_token_hash_key",
"unique": true
}
},
{
"createIndex": {
"collection": "invitations",
"keys": {
"org_id": 1,
"email": 1
},
"name": "invitations_org_id_email_idx",
"unique": false
}
},
{
"createIndex": {
"collection": "invitations",
"keys": {
"org_id": 1,
"accepted_at": 1
},
"name": "invitations_org_id_accepted_at_idx",
"unique": false
}
},
{
"createCollection": "org_memberships",
"validator": {
"$jsonSchema": {
"bsonType": "object",
"required": ["_id", "version", "created_at", "updated_at", "org_id", "user_id", "role"],
"properties": {
"_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"version": {
"bsonType": "number"
},
"created_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"updated_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"org_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"user_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"role": {
"bsonType": "string",
"enum": ["viewer", "member", "admin", "owner"]
}
},
"additionalProperties": false
}
}
},
{
"createIndex": {
"collection": "org_memberships",
"keys": {
"org_id": 1,
"user_id": 1
},
"name": "org_memberships_org_id_user_id_key",
"unique": true
}
},
{
"createIndex": {
"collection": "org_memberships",
"keys": {
"org_id": 1,
"role": 1
},
"name": "org_memberships_org_id_role_idx",
"unique": false
}
},
{
"createCollection": "organizations",
"validator": {
"$jsonSchema": {
"bsonType": "object",
"required": ["_id", "version", "created_at", "updated_at", "slug", "name", "status", "settings"],
"properties": {
"_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"version": {
"bsonType": "number"
},
"created_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"updated_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"slug": {
"bsonType": "string"
},
"name": {
"bsonType": "string"
},
"status": {
"bsonType": "string",
"enum": ["active", "suspended"]
},
"settings": {
"bsonType": "object"
},
"branding": {
"bsonType": ["object", "null"],
"properties": {
"displayName": {
"bsonType": "string"
},
"primary": {
"bsonType": "string"
},
"accent": {
"bsonType": "string"
},
"logoLight": {
"bsonType": "string"
},
"logoDark": {
"bsonType": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
}
},
{
"createIndex": {
"collection": "organizations",
"keys": {
"slug": 1
},
"name": "organizations_slug_key",
"unique": true
}
},
{
"createIndex": {
"collection": "organizations",
"keys": {
"status": 1
},
"name": "organizations_status_idx",
"unique": false
}
},
{
"createCollection": "projects",
"validator": {
"$jsonSchema": {
"bsonType": "object",
"required": [
"_id",
"version",
"created_at",
"updated_at",
"org_id",
"workspace_id",
"slug",
"name",
"module_key"
],
"properties": {
"_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"version": {
"bsonType": "number"
},
"created_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"updated_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"org_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"workspace_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"slug": {
"bsonType": "string"
},
"name": {
"bsonType": "string"
},
"module_key": {
"bsonType": "string"
},
"archived_at": {
"bsonType": ["string", "null"]
}
},
"additionalProperties": false
}
}
},
{
"createIndex": {
"collection": "projects",
"keys": {
"org_id": 1,
"workspace_id": 1,
"slug": 1
},
"name": "projects_org_id_workspace_id_slug_key",
"unique": true
}
},
{
"createIndex": {
"collection": "projects",
"keys": {
"org_id": 1,
"workspace_id": 1,
"archived_at": 1
},
"name": "projects_org_id_workspace_id_archived_at_idx",
"unique": false
}
},
{
"createCollection": "users",
"validator": {
"$jsonSchema": {
"bsonType": "object",
"required": [
"_id",
"version",
"created_at",
"updated_at",
"issuer",
"subject",
"email",
"display_name",
"locale"
],
"properties": {
"_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"version": {
"bsonType": "number"
},
"created_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"updated_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"issuer": {
"bsonType": "string"
},
"subject": {
"bsonType": "string"
},
"email": {
"bsonType": "string"
},
"display_name": {
"bsonType": "string"
},
"locale": {
"bsonType": "string"
}
},
"additionalProperties": false
}
}
},
{
"createIndex": {
"collection": "users",
"keys": {
"issuer": 1,
"subject": 1
},
"name": "users_issuer_subject_key",
"unique": true
}
},
{
"createIndex": {
"collection": "users",
"keys": {
"email": 1
},
"name": "users_email_idx",
"unique": false
}
},
{
"createCollection": "workspace_memberships",
"validator": {
"$jsonSchema": {
"bsonType": "object",
"required": ["_id", "version", "created_at", "updated_at", "org_id", "workspace_id", "user_id", "role"],
"properties": {
"_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"version": {
"bsonType": "number"
},
"created_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"updated_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"org_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"workspace_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"user_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"role": {
"bsonType": "string",
"enum": ["viewer", "editor", "admin"]
}
},
"additionalProperties": false
}
}
},
{
"createIndex": {
"collection": "workspace_memberships",
"keys": {
"org_id": 1,
"workspace_id": 1,
"user_id": 1
},
"name": "workspace_memberships_org_id_workspace_id_user_id_key",
"unique": true
}
},
{
"createIndex": {
"collection": "workspace_memberships",
"keys": {
"org_id": 1,
"user_id": 1
},
"name": "workspace_memberships_org_id_user_id_idx",
"unique": false
}
},
{
"createCollection": "workspaces",
"validator": {
"$jsonSchema": {
"bsonType": "object",
"required": [
"_id",
"version",
"created_at",
"updated_at",
"org_id",
"slug",
"name",
"is_default",
"visibility"
],
"properties": {
"_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"version": {
"bsonType": "number"
},
"created_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"updated_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"org_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"slug": {
"bsonType": "string"
},
"name": {
"bsonType": "string"
},
"is_default": {
"bsonType": "bool"
},
"visibility": {
"bsonType": "string",
"enum": ["org", "restricted"]
}
},
"additionalProperties": false
}
}
},
{
"createIndex": {
"collection": "workspaces",
"keys": {
"org_id": 1,
"slug": 1
},
"name": "workspaces_org_id_slug_key",
"unique": true
}
}
]
+131
View File
@@ -0,0 +1,131 @@
[
{
"createCollection": "files_file",
"validator": {
"$jsonSchema": {
"bsonType": "object",
"required": [
"_id",
"version",
"created_at",
"updated_at",
"org_id",
"workspace_id",
"project_id",
"status",
"storage_key",
"filename",
"content_type",
"uploaded_by"
],
"properties": {
"_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"version": {
"bsonType": "number"
},
"created_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"updated_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"org_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"workspace_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"project_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"status": {
"bsonType": "string",
"enum": ["pending", "stored", "deleting"]
},
"storage_key": {
"bsonType": "string"
},
"filename": {
"bsonType": "string"
},
"content_type": {
"bsonType": "string"
},
"size": {
"bsonType": ["number", "null"]
},
"sha256": {
"bsonType": ["string", "null"]
},
"uploaded_by": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"stored_at": {
"bsonType": ["string", "null"]
},
"upload_id": {
"bsonType": ["string", "null"]
},
"declared_size": {
"bsonType": ["number", "null"]
},
"part_size": {
"bsonType": ["number", "null"]
},
"deleting_at": {
"bsonType": ["string", "null"]
}
},
"additionalProperties": false
}
}
},
{
"createIndex": {
"collection": "files_file",
"keys": {
"org_id": 1,
"workspace_id": 1,
"project_id": 1,
"storage_key": 1
},
"name": "files_file_org_id_workspace_id_project_id_storage_key_key",
"unique": true
}
},
{
"createIndex": {
"collection": "files_file",
"keys": {
"org_id": 1,
"workspace_id": 1,
"project_id": 1,
"status": 1
},
"name": "files_file_org_id_workspace_id_project_id_status_idx",
"unique": false
}
},
{
"createIndex": {
"collection": "files_file",
"keys": {
"org_id": 1,
"workspace_id": 1,
"project_id": 1,
"sha256": 1
},
"name": "files_file_org_id_workspace_id_project_id_sha256_idx",
"unique": false
}
}
]
@@ -0,0 +1,164 @@
[
{
"createCollection": "proposals",
"validator": {
"$jsonSchema": {
"bsonType": "object",
"required": [
"_id",
"version",
"created_at",
"updated_at",
"org_id",
"workspace_id",
"project_id",
"status",
"target_key",
"change",
"evidence",
"model",
"prompt_version",
"created_by"
],
"properties": {
"_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"version": {
"bsonType": "number"
},
"created_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"updated_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"org_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"workspace_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"project_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"status": {
"bsonType": "string",
"enum": ["pending", "approved", "rejected"]
},
"target_key": {
"bsonType": "string"
},
"target_record_id": {
"bsonType": ["string", "null"]
},
"written_record_id": {
"bsonType": ["string", "null"]
},
"change": {
"bsonType": "object"
},
"evidence": {
"bsonType": "array",
"items": {
"bsonType": "object",
"required": ["fileId"],
"properties": {
"fileId": {
"bsonType": "string"
},
"page": {
"bsonType": "number"
},
"blockId": {
"bsonType": "string"
},
"box": {
"bsonType": "object",
"required": ["x", "y", "width", "height"],
"properties": {
"x": {
"bsonType": "number"
},
"y": {
"bsonType": "number"
},
"width": {
"bsonType": "number"
},
"height": {
"bsonType": "number"
}
},
"additionalProperties": false
},
"quote": {
"bsonType": "string"
}
},
"additionalProperties": false
}
},
"model": {
"bsonType": "string"
},
"prompt_version": {
"bsonType": "string"
},
"created_by": {
"bsonType": "string"
},
"edited_by": {
"bsonType": ["string", "null"]
},
"edited_at": {
"bsonType": ["string", "null"]
},
"decided_by": {
"bsonType": ["string", "null"]
},
"decided_at": {
"bsonType": ["string", "null"]
},
"reason": {
"bsonType": ["string", "null"]
}
},
"additionalProperties": false
}
}
},
{
"createIndex": {
"collection": "proposals",
"keys": {
"org_id": 1,
"workspace_id": 1,
"project_id": 1,
"status": 1
},
"name": "proposals_org_id_workspace_id_project_id_status_idx",
"unique": false
}
},
{
"createIndex": {
"collection": "proposals",
"keys": {
"org_id": 1,
"workspace_id": 1,
"project_id": 1,
"target_key": 1,
"target_record_id": 1
},
"name": "proposals_org_id_workspace_id_project_id_target_key_target_record_id_idx",
"unique": false
}
}
]
@@ -0,0 +1,63 @@
[
{
"createCollection": "app_user_identity",
"validator": {
"$jsonSchema": {
"bsonType": "object",
"required": ["_id", "version", "created_at", "updated_at", "issuer", "subject", "user_id"],
"properties": {
"_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"version": {
"bsonType": "number"
},
"created_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"updated_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"issuer": {
"bsonType": "string"
},
"subject": {
"bsonType": "string"
},
"user_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"migration_id": {
"bsonType": ["string", "null"]
}
},
"additionalProperties": false
}
}
},
{
"createIndex": {
"collection": "app_user_identity",
"keys": {
"issuer": 1,
"subject": 1
},
"name": "app_user_identity_issuer_subject_key",
"unique": true
}
},
{
"createIndex": {
"collection": "app_user_identity",
"keys": {
"user_id": 1
},
"name": "app_user_identity_user_id_idx",
"unique": false
}
}
]