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 })
|
||||
}
|
||||
Reference in New Issue
Block a user