From 25de93591adac3c420fab59c1116f9ae0d371dd2 Mon Sep 17 00:00:00 2001 From: stepandel Date: Wed, 21 Oct 2026 13:58:25 -0700 Subject: [PATCH 1/3] Provision the Cantelop Fleet from the setup script The cantelop section now uses the owner's `cantelop login` credential to create or reuse the juiced Fleet, register the active App release as the package, and issue API and worker principals, writing the tokens to Vercel without printing them. It refuses a release whose runtime differs from this CLI build and revokes the one-off publisher principal. Co-Authored-By: Claude Opus 5.5 --- docs/managed-service.md | 6 +- service/deploy/cantelop-fleet.mjs | 102 ++++++++++++++++++++++++++++++ service/deploy/production-env.sh | 37 +++++++---- 3 files changed, 130 insertions(+), 15 deletions(-) create mode 100644 service/deploy/cantelop-fleet.mjs diff --git a/docs/managed-service.md b/docs/managed-service.md index a678041..fd70504 100644 --- a/docs/managed-service.md +++ b/docs/managed-service.md @@ -143,7 +143,11 @@ References: [Next.js on Vercel](https://vercel.com/docs/frameworks/full-stack/ne 3. `service/deploy/production-env.sh` performs this step and writes the generated role URLs, auth secret, OAuth and Fleet values to Vercel production without echoing them; run it per section (`auth`, `database`, `oauth`, - `cantelop`). Manually: run `npm run migrate:service` as the schema owner. Apply + `cantelop`). The `cantelop` section uses the `cantelop login` owner credential + to create or reuse the `juiced` Fleet, registers the active `juiced` App + release as the package (its runtime must match this CLI build; otherwise run + `cantelop deploy` first) and issues separate API and worker principals. The + short-lived publisher principal is revoked after registration. Manually: run `npm run migrate:service` as the schema owner. Apply `service/deploy/roles.sql` after migrations and provision login secrets outside source control. The API role cannot update paid periods, included credits, usage or immutable grants. Use separate database credentials and Fleet diff --git a/service/deploy/cantelop-fleet.mjs b/service/deploy/cantelop-fleet.mjs new file mode 100644 index 0000000..c0e5e33 --- /dev/null +++ b/service/deploy/cantelop-fleet.mjs @@ -0,0 +1,102 @@ +// Creates or reuses the Juiced Fleet, issues its API and worker principals, +// and registers a Juiced App release as the approved package. Uses the owner +// credential from `cantelop login`. Prints NAME=value lines for +// production-env.sh; never run it with stdout attached to a terminal. +// +// node cantelop-fleet.mjs RELEASE_ID VERSION EXPECTED_RUNTIME_ID +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +const [releaseId, version, expectedRuntime] = process.argv.slice(2); +if (!/^rel_[a-f0-9]{32}$/.test(releaseId ?? '') || !version || !expectedRuntime) + throw new Error('Usage: cantelop-fleet.mjs RELEASE_ID VERSION EXPECTED_RUNTIME_ID'); +if (process.stdout.isTTY || process.env.JUICED_FLEET_SETUP !== '1') + throw new Error('Run through service/deploy/production-env.sh cantelop'); + +const api = process.env.CANTELOP_API_URL ?? 'https://cantelop.com'; +const name = process.env.FLEET_NAME ?? 'juiced'; +const maxApps = Number(process.env.FLEET_MAX_APPS ?? 25); +const expiresAt = new Date(Date.now() + 180 * 86400000).toISOString(); +const configPath = + process.env.CANTELOP_CONFIG ?? + (process.platform === 'darwin' + ? join(homedir(), 'Library/Application Support/cantelop/config.json') + : join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'cantelop/config.json')); +const refresh = JSON.parse(await readFile(configPath, 'utf8')).refresh_token; +if (!refresh) throw new Error('Run `cantelop login` first'); + +const log = (line) => process.stderr.write(` ${line}\n`); +async function call(method, path, token, body) { + const response = await fetch(`${api}${path}`, { + method, + redirect: 'error', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const value = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(`${method} ${path}: ${response.status} ${value.error ?? ''}`); + return value; +} +// Owner access tokens live for 60 seconds, so each owner call gets a fresh one. +async function owner(method, path, body) { + const issued = await fetch(`${api}/api/cli/token`, { + method: 'POST', + redirect: 'error', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ refresh_token: refresh }), + }); + if (!issued.ok) throw new Error(`Cantelop login expired (${issued.status}); run \`cantelop login\``); + return call(method, path, (await issued.json()).access_token, body); +} + +let fleet = (await owner('GET', '/v1/fleets')).items.find((f) => f.name === name); +if (fleet) log(`reusing Fleet ${fleet.id}`); +else { + fleet = await owner('POST', '/v1/fleets', { name, maxApps }); + log(`created Fleet ${fleet.id} (max ${maxApps} Apps)`); +} +const principal = async (label, scopes) => { + const p = await owner('POST', `/v1/fleets/${fleet.id}/principals`, { label, scopes, expiresAt }); + log(`issued ${label} principal ${p.id}, expires ${p.expiresAt}`); + return p; +}; +const revoke = (p) => owner('DELETE', `/v1/fleets/${fleet.id}/principals/${p.id}`); + +const publisher = await principal('juiced-publisher', ['packages:write', 'installations:read']); +let pkg; +try { + pkg = await call('POST', `/v1/fleets/${fleet.id}/packages`, publisher.token, { + releaseId, + version, + }); + if (pkg.runtime_id !== expectedRuntime) { + await call('DELETE', `/v1/fleets/${fleet.id}/packages/${pkg.id}`, publisher.token); + throw new Error( + `${releaseId} has runtime ${pkg.runtime_id}, but this CLI build expects ${expectedRuntime}; ` + + 'deploy the App with `cantelop deploy` and use the new release', + ); + } + log(`registered package ${pkg.id} (${pkg.name} ${pkg.version})`); +} finally { + // Packages are published once; a later release issues a new publisher. + await revoke(publisher); +} +const apiPrincipal = await principal('juiced-api', ['installations:read', 'credentials:read']); +const worker = await principal('juiced-worker', [ + 'installations:read', + 'installations:write', + 'credentials:read', + 'policy:write', + 'usage:read', +]); +process.stdout.write( + [ + `CANTELOP_FLEET_ID=${fleet.id}`, + `CANTELOP_FLEET_TOKEN=${apiPrincipal.token}`, + `CANTELOP_WORKER_FLEET_TOKEN=${worker.token}`, + `JUICED_PACKAGE_ID=${pkg.id}`, + `JUICED_RUNTIME_ID=${pkg.runtime_id}`, + '', + ].join('\n'), +); diff --git a/service/deploy/production-env.sh b/service/deploy/production-env.sh index c10dd3a..f968d36 100755 --- a/service/deploy/production-env.sh +++ b/service/deploy/production-env.sh @@ -92,21 +92,30 @@ oauth() { cantelop() { echo "Cantelop Fleet" - local fleet api worker - fleet=$(secret 'Fleet ID (flt_...)') - [[ $fleet =~ ^flt_[a-f0-9]{32}$ ]] || { echo "Invalid Fleet ID" >&2; exit 1; } - api=$(secret 'API principal token (credentials:read, installations:read)') - worker=$(secret 'Worker principal token (installations:read/write, policy:write, usage:read, credentials:read)') - for token in "$api" "$worker"; do - [[ $token =~ ^fsp_[a-f0-9]{32}\.[a-f0-9]{64}$ ]] || { echo "Invalid Fleet token" >&2; exit 1; } - done - [[ $api != "$worker" ]] || { echo "Use separate API and worker principals" >&2; exit 1; } + # Creates real Fleet principals, so DRY_RUN would only strand them. + [[ -z ${DRY_RUN:-} ]] || { echo " skipped in a dry run"; return; } + local release version runtime values name value + # The package must carry the runtime this CLI version was built with. + npm run --silent build:runtime >/dev/null + runtime=$(node -e ' + const m = require("./dist/cantelop/juiced/runtime/cantelop-runtime.json"); + process.stdout.write(m.session_runtime_id);') + # CANTELOP_RELEASE selects a release; the App's active one is the default. + # Package versions are immutable, so each App release gets its own. + read -r release version < <(cantelop releases --json | CANTELOP_RELEASE=${CANTELOP_RELEASE:-} node -e ' + let s = ""; process.stdin.on("data", (c) => (s += c)).on("end", () => { + const d = JSON.parse(s), rs = Array.isArray(d) ? d : d.releases ?? d.items; + const want = process.env.CANTELOP_RELEASE; + const r = rs.find((r) => (want ? r.id === want : r.status === "active")); + if (!r) throw new Error("Release not found"); + console.log(r.id, `${require("./package.json").version}-r${r.version}`); + });') + echo " release $release, package version $version, runtime $runtime" + values=$(JUICED_FLEET_SETUP=1 node service/deploy/cantelop-fleet.mjs "$release" "$version" "$runtime") put CANTELOP_API_URL https://cantelop.com - put CANTELOP_FLEET_ID "$fleet" - put CANTELOP_FLEET_TOKEN "$api" - put CANTELOP_WORKER_FLEET_TOKEN "$worker" - put JUICED_PACKAGE_ID "$(secret 'Approved package ID')" - put JUICED_RUNTIME_ID "$(secret 'Runtime ID')" + while IFS='=' read -r name value; do + [[ -n $name ]] && put "$name" "$value" + done <<<"$values" existing JUICED_MINIMUM_CLI || put JUICED_MINIMUM_CLI 0.2.0 } From 3fc3780f9b9b1d0e2d2e065cb55f2fcb8ae39b21 Mon Sep 17 00:00:00 2001 From: stepandel Date: Wed, 21 Oct 2026 14:12:31 -0700 Subject: [PATCH 2/3] Call the Cantelop CLI explicitly from the setup script The cantelop section is a shell function of the same name, so its `cantelop releases` call recursed into itself. Co-Authored-By: Claude Opus 5.5 --- service/deploy/production-env.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/deploy/production-env.sh b/service/deploy/production-env.sh index f968d36..fabef9f 100755 --- a/service/deploy/production-env.sh +++ b/service/deploy/production-env.sh @@ -102,7 +102,7 @@ cantelop() { process.stdout.write(m.session_runtime_id);') # CANTELOP_RELEASE selects a release; the App's active one is the default. # Package versions are immutable, so each App release gets its own. - read -r release version < <(cantelop releases --json | CANTELOP_RELEASE=${CANTELOP_RELEASE:-} node -e ' + read -r release version < <(command cantelop releases --json | CANTELOP_RELEASE=${CANTELOP_RELEASE:-} node -e ' let s = ""; process.stdin.on("data", (c) => (s += c)).on("end", () => { const d = JSON.parse(s), rs = Array.isArray(d) ? d : d.releases ?? d.items; const want = process.env.CANTELOP_RELEASE; From 75ba0deb43ca929dc0d005fe218a5a7687f0da73 Mon Sep 17 00:00:00 2001 From: stepandel Date: Wed, 21 Oct 2026 15:01:05 -0700 Subject: [PATCH 3/3] Let the database setup read the owner URL from Vercel OWNER_FROM_VERCEL=1 pulls JUICED_OWNER_DATABASE_URL from the development environment, where the Neon resource is connected only for the setup, into a temporary file that is removed straight away. Co-Authored-By: Claude Opus 5.5 --- service/deploy/production-env.sh | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/service/deploy/production-env.sh b/service/deploy/production-env.sh index fabef9f..e54d671 100755 --- a/service/deploy/production-env.sh +++ b/service/deploy/production-env.sh @@ -50,7 +50,23 @@ database() { echo "Database" command -v psql >/dev/null || { echo "psql is required" >&2; exit 1; } local owner api worker - owner=$(secret 'Owner PostgreSQL URL (used here only, never stored in Vercel)') + if [[ -n ${OWNER_FROM_VERCEL:-} ]]; then + # The Neon resource connected to the development environment only, as + # `vercel integration resource connect juiced-db juiced -e development + # --prefix JUICED_OWNER_`. Disconnect it afterwards. + local pulled + pulled=$(mktemp -d) + vercel env pull "$pulled/.env" --environment development --yes \ + --project "$PROJECT" --scope "$SCOPE" >/dev/null 2>&1 + owner=$(PULLED="$pulled/.env" node -e ' + const line = require("fs").readFileSync(process.env.PULLED, "utf8") + .split("\n").find((l) => l.startsWith("JUICED_OWNER_DATABASE_URL=")); + if (line) process.stdout.write(JSON.parse(line.slice(line.indexOf("=") + 1)));') + rm -rf "$pulled" + [[ -n $owner ]] || { echo "JUICED_OWNER_DATABASE_URL not found in development" >&2; exit 1; } + else + owner=$(secret 'Owner PostgreSQL URL (used here only, never stored in Vercel)') + fi JUICED_DATABASE_URL=$owner JUICED_SERVICE_URL=$SERVICE_URL NODE_ENV=development \ JUICED_AUTH_SECRET=$(openssl rand -hex 32) npm run --silent migrate:service api=$(openssl rand -hex 24)