#!/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" target_api_url="$platform_action_url" publish_env=() # kit:guides:begin # The source deploy above ran without a publish credential, as a git push does, so it published # nothing. This target deploy receives the credential and provenance of a member's deployment. pnpm kit guides:anchors --out guides-anchors.json >/dev/null target_api_url="$platform_host_url" publish_env=(CONUSAI_GUIDES_PUBLISH_TOKEN=dt_convex_smoke_publish 'CONUSAI_GUIDES_DEPLOYMENT={"deployment_id":"88","source_revision":"0123abcd","generation":88,"requested_by":7}') # kit:guides:end env ${publish_env[@]+"${publish_env[@]}"} \ CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$target_port" \ CONVEX_SELF_HOSTED_ADMIN_KEY="$target_key" \ CONUSAI_API_URL="$target_api_url" CONUSAI_API_TOKEN=dev-token CONUSAI_PROJECT_ID=1 \ CONUSAI_ORG_SLUG=fixture-org \ node profiles/convex/deploy.mjs | tee "$work/target-deploy.log" # kit:guides:begin rm -f guides-anchors.json grep -q "convex-deploy: guides: published 3 code guide(s), 0 awaiting review, unpublished 0, [1-9][0-9]* anchor(s)" "$work/target-deploy.log" \ || { echo "the Convex deploy step did not publish the code guides and anchors" >&2; exit 1; } curl -fsS "http://127.0.0.1:$platform_port/__e2e/state" | node -e ' const state = JSON.parse(require("node:fs").readFileSync(0, "utf8")) const calls = state.guides.filter((call) => call.route === "code-guides" || call.route === "anchors") const routes = calls.map((call) => call.route).join(",") if (routes !== "code-guides,anchors") throw new Error(`expected one code-guides and one anchors call, got ${routes}`) if (!calls.every((call) => call.authorization === "Bearer dt_convex_smoke_publish")) throw new Error("a publish call did not carry the publish credential") const keys = calls[0].body.guides.map((guide) => guide.key).sort().join(",") if (keys !== "notes.feedback,notes.first-note,notes.getting-started") throw new Error(`published guides ${keys}`) if (calls[0].body.deployment.deployment_id !== "88" || calls[1].body.deployment.generation !== 88) throw new Error("provenance not sent") const anchors = calls[1].body.anchors.map((anchor) => anchor.key) for (const key of ["notes.new", "notes.list"]) if (!anchors.includes(key)) throw new Error(`anchor ${key} not published`) console.log(`Convex publish step green: ${keys}; ${anchors.length} anchors; publish credential only`) ' # kit:guides:end 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"