This commit is contained in:
@@ -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.
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user