Skip to content

Deployment

The suite deploys as one Vercel project (apps/console) plus the Chrome extension (built from the repo, shipped via the Web Store). One domain, one env set.

PieceTargetHow
apps/console — all tools + MCP + blogging cronVercel (one project)Root Directory = apps/console, framework Next.js, install/build at repo root with pnpm + Turbo
apps/chrome-extensionChrome Web Storepnpm --filter @advance-labs/chrome-extension build → zip dist/ → upload

Current deployment (live)

  • Project: advancelabs/aeo-toolkit (Vercel team Advance Labs), Root Directory apps/console.
  • URL: https://aeo.advancelabs.dev (canonical) · https://aeo-toolkit-ten.vercel.app (Vercel alias)
  • Database: Supabase, provisioned via the Vercel Marketplace (project axuaeezqdxyhenmpbdnf), connected to Production + Preview, schema applied (oauth_tokens + posts).
  • Env set: TOKEN_ENCRYPTION_KEY, OAUTH_STATE_SECRET, CRON_SECRET, MCP_PUBLIC_URL, AUDIT_MAX_PAGES, BACKLINK_GRAPH_LIMIT, SUPABASE_URL (+ the integration’s SUPABASE_SERVICE_ROLE_KEY, NEXT_PUBLIC_SUPABASE_URL, POSTGRES_*, …).
  • Working now: /tools/audit, /tools/eeat, /tools/llms-txt, /tools/graph; the human MCP connection page at /mcp; the ai-visibility + backlink MCP servers (BYOK Perplexity); MCP discovery; Supabase-backed token + post storage.
  • Pending creds: Google OAuth (chat + ga-gsc MCP), LLM keys (blogging cron), Upstash (optional), custom domain.

1. Deploy the console to Vercel

Create + configure the project

Terminal window
# from the repo ROOT (not apps/console — the root is the deploy context for a sub-dir app)
vercel link --yes --scope <team> --project aeo-toolkit

Then set the Root Directory to apps/console. Linking from a subdir does not set it, and the default (repo root) fails with “No Next.js version detected”. Set it in the dashboard (Settings → General → Root Directory) or via the API:

Terminal window
TOKEN=$(sed -n 's/.*"token" *: *"\([^"]*\)".*/\1/p' \
"$HOME/Library/Application Support/com.vercel.cli/auth.json")
curl -X PATCH "https://api.vercel.com/v9/projects/<projectId>?teamId=<teamId>" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"rootDirectory":"apps/console","framework":"nextjs"}'

The monorepo install/build is declared in apps/console/vercel.json — Turbo builds the @advance-labs/* workspace packages (their dist/) before the Next build, or Next can’t resolve them:

{
"installCommand": "cd ../.. && pnpm install --frozen-lockfile",
"buildCommand": "cd ../.. && pnpm exec turbo run build --filter=@advance-labs/console",
"crons": [{ "path": "/api/cron/blogging", "schedule": "0 13 * * *" }]
}

Gotcha — Next.js version: Vercel blocks deploys on known-vulnerable Next versions. Keep next at a patched release (the repo runs 15.5.x; 15.1.x is rejected).

Set env vars + deploy

Terminal window
# generate the app secrets (one-time)
openssl rand -base64 32 # TOKEN_ENCRYPTION_KEY
openssl rand -base64 32 # OAUTH_STATE_SECRET
openssl rand -hex 16 # CRON_SECRET
# add each (reads value from stdin); repeat per var, see apps/console/.env.example
printf '%s' "<value>" | vercel env add TOKEN_ENCRYPTION_KEY production
# deploy from the repo ROOT
vercel deploy --prod --yes

After the first deploy, set MCP_PUBLIC_URL to the live URL and redeploy. It’s the canonical origin used across the app (SEO metadata, tool-page URLs, and the .well-known routes’ notion of “this origin”). The ROOT /.well-known/oauth-* documents 404 regardless; MCP_PUBLIC_URL only matters there if an external OAUTH_ISSUER is ever set, so that issuer isn’t mistaken for this origin. The search server’s own login (below) names whatever host the request arrived on, not MCP_PUBLIC_URL.


2. Databases — Vercel Marketplace (vercel install)

The Vercel CLI provisions a Marketplace database, connects it to the project, and auto-syncs the env vars (the Vercel MCP server tools do not — they’re for deploy/projects/logs/docs/domains).

Terminal window
vercel install supabase --name aeo-toolkit -e production -e preview
vercel install neon --name aeo-toolkit-db --plan free -e production -e preview
vercel install upstash/upstash-kv -e production -e preview
vercel integration discover # list all available providers

Marketplace database options for this team:

TypeProviders (slug)
PostgresSupabase (supabase), Neon (neon), Prisma Postgres (prisma/prisma-postgres), Nile, Amazon Aurora (aws/aws-apg, aws/aws-dsql)
Serverless SQLiteTurso (tursocloud/database)
Redis / KVRedis (redis), Upstash for Redis (upstash/upstash-kv)
NoSQL / reactiveDynamoDB (aws/aws-dynamodb), Convex (convex)

Code compatibility: @advance-labs/storage talks to Supabase via @supabase/supabase-js (PostgREST + service-role key). Supabase drops in with no code change. A raw-SQL Postgres (Neon / Prisma) needs a small adapter — implement TokenStore / PostStore (the existing interfaces) against @neondatabase/serverless. Redis/KV is a natural fit for the token store (get/set/delete by key).

Supabase specifics (what this deployment uses)

  1. vercel install supabase … (accept the marketplace terms once, in the browser or vercel integration accept-terms supabase --yes).
  2. Alias the URL: the integration syncs NEXT_PUBLIC_SUPABASE_URL, but the code reads SUPABASE_URL — add SUPABASE_URL with the same value (SUPABASE_SERVICE_ROLE_KEY matches as-is).
  3. Apply the schema (apps/console/supabase/schema.sql) using the synced POSTGRES_URL_NON_POOLING:
    Terminal window
    vercel env pull /tmp/prod.env --environment production --yes
    PG=$(grep -E '^POSTGRES_URL_NON_POOLING=' /tmp/prod.env | cut -d= -f2- | tr -d '"')
    psql "$PG" -f apps/console/supabase/schema.sql # or paste into the Supabase SQL editor
    rm -f /tmp/prod.env
    (No psql? Use the Supabase dashboard SQL editor, or a one-off Node client with the postgres package.)
  4. Redeploy so the new env applies. Token rows are encrypted at rest via TOKEN_ENCRYPTION_KEY; RLS is enabled with no permissive policies (service-role only).

3. Remaining backing services

ServiceLights upEnv to set
Supabasetoken persistence + blog store(done — see above)
Google Cloud OAuth/tools/chat, ga-gsc MCPGOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI=https://<domain>/api/auth/google/callback; enable GA4 Data API, GA4 Admin API, Search Console API; scopes analytics.readonly + webmasters.readonly
LLM keysblogging cronGROQ_API_KEY, ANTHROPIC_API_KEY/OPENAI_API_KEY, GOOGLE_ACCESS_TOKEN, GA4_PROPERTY_ID, SITE_URL, GSC_SITE_URL
Upstash (optional)distributed rate limitingvercel install upstash/upstash-kv, or UPSTASH_REDIS_REST_URL + ..._TOKEN (in-memory fallback otherwise)
Custom domainpretty URLattach in Vercel, then update MCP_PUBLIC_URL + the Google redirect

The blogging agent runs via the Vercel Cron in apps/console/vercel.json (/api/cron/blogging, daily) — Vercel sends Authorization: Bearer $CRON_SECRET, which the route verifies.


4. Connect MCP servers to Claude

The human-facing connection page is https://<domain>/mcp — it lists every tool and the exact connect steps for Claude.ai and Cursor. In Claude.ai → Settings → Connectors, add:

  • https://<domain>/api/mcp/ai-visibility/mcp
  • https://<domain>/api/mcp/search/mcp (/api/mcp/ga-gsc/mcp still works as a compatibility alias)
  • https://<domain>/api/mcp/backlink/mcp

The trailing /mcp is required — the bare /api/mcp/<slug> returns the adapter’s own “Not found”.

AI Visibility and Backlink need no login. Search runs its own OAuth 2.1 authorization server at /api/mcp/oauth (register, authorize, complete, token), switched on by OAUTH_STATE_SECRET: unset, those routes and their path-scoped .well-known documents 404 and the server is BYOK-only again. The login rides the existing Connect Google flow, so it needs the same GOOGLE_* env, the Supabase token store, and AUTH_COOKIE_DOMAIN when the Google callback lands on a sibling host. Everything the login hands out is sealed with OAUTH_STATE_SECRET and nothing is stored, so rotating that secret signs every MCP client out. A raw Google Authorization: Bearer token and x-bing-api-key still work alongside it. (A local stdio MCP variant for Claude Desktop isn’t part of the Vercel deployment; re-add it later from a thin package over the same tools.)

5. Chrome extension

pnpm --filter @advance-labs/chrome-extension build → zip dist/ → upload to the Chrome Web Store (icons are generated; analysis is 100% local). See apps/chrome-extension/CHROME_STORE.md.

6. CI / previews

.github/workflows/ci.yml gates lint + typecheck + test + build on every push. Connect Vercel’s Git integration for per-PR preview deployments. Secrets live only in Vercel/GitHub env — never in git (.env* and .vercel are gitignored).



7. Releasing to npm

Six packages publish to npm (types, crawler, scoring, html-parser, net-guard, schema-validator). Everything else is private: true, and console + chrome-extension sit in the Changesets ignore list because they are apps, not libraries.

The flow. A push to main runs .github/workflows/release.yml, which collects pending changesets onto a changeset-release/main branch as a “Version Packages” commit. Merging that PR is what publishes — a release is always a deliberate act, never a side effect of landing a feature.

Three things that have actually bitten this repo

1. A commit with no changeset never publishes. This is the big one. Under Changesets a commit without a changeset file is invisible to the release pipeline: it merges, CI goes green, main is correct, and nothing ships. 7f9cb3e relicensed the whole repo MIT → Apache-2.0 this way, and for two days npm kept serving MIT tarballs while the repo, site and SECURITY.md all said Apache-2.0. If a change affects what a consumer receives — including licensing — it needs a changeset, not just a green build.

2. Never merge a Version Packages PR before NPM_TOKEN exists. The version commit deletes the changeset files. If the publish then fails there is no changeset left to retry with, and the repo sits ahead of npm with no way to trigger a release. Confirm the secret first:

Terminal window
gh api repos/Advance-Labs/aeo-toolkit/actions/secrets -q '.secrets[].name'

3. GitHub Actions could not open the Version Packages PR (fixed 2026-09-10). Every release run failed at HttpError: GitHub Actions is not permitted to create or approve pull requests, while still pushing the changeset-release/main branch successfully — only PR creation was blocked. The lock was at the organization level: the repo’s checkbox under Settings → Actions → General is greyed out until Advance-Labs org settings → Actions → General → Workflow permissions → “Allow GitHub Actions to create and approve pull requests” is ticked, because a repo can tighten an org policy but never loosen it. The default workflow permission is deliberately left at read-only; release.yml requests contents: write and pull-requests: write for itself.

If it regresses (the check is gh api repos/Advance-Labs/aeo-toolkit/actions/permissions/workflowcan_approve_pull_request_reviews), open the PR by hand:

Terminal window
gh pr create --base main --head changeset-release/main --title "chore: version packages"

Verify a release actually landed

Changesets reports success per package optimistically — it has claimed a package published, and even pushed a git tag for it, when the registry never received it (this happened to net-guard on the 0.2.1 release; a re-run of the workflow fixed it, since with no changesets pending the action goes straight to publishing whatever is missing). Do not trust the log. Ask the registry:

Terminal window
for p in types crawler scoring html-parser net-guard schema-validator; do
printf "%-18s " "$p"
curl -s "https://registry.npmjs.org/@advance-labs%2f$p" |
python3 -c "import json,sys;d=json.load(sys.stdin);lt=d['dist-tags']['latest'];print(lt, d['versions'][lt].get('license'))"
done

Licensing note

No package carries its own LICENSE file. pnpm pack copies the workspace-root LICENSE into each tarball, so the root file is the single source of truth for what ships. npm cannot amend an already-published version — a license correction only reaches consumers who upgrade, which is why such a fix should go out as a patch (on 0.x, ^0.2.0 accepts 0.2.x but not 0.3.0).

Becoming a billable SaaS (BUILT — ships dormant)

The commercial layer is fully built and ships dormant: Supabase Auth (magic-link), Stripe billing

  • plans, usage metering/quotas on the tool + MCP endpoints, and an account dashboard. With no new env set, the site behaves exactly as today — all five tools free and open, no sign-in, no paywall — so the code is safe to deploy now. It lights up only when its keys are present (the repo’s “lights up when creds are added” convention, e.g. ga-gsc).

Two independent switches, each derived purely from whether its keys exist (no separate on/off flag):

SwitchOn when…Unlocks
AUTH_ENABLEDNEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY are set/login, sessions, /account
BILLING_ENABLEDSTRIPE_SECRET_KEY is setcheckout, the customer portal, plan/quota gating

Until BILLING_ENABLED is true, checkEntitlement() returns “allow” for every request, so adding these keys is the only thing that can ever wall a tool — and unsetting STRIPE_SECRET_KEY rolls back instantly.

Activation runbook (the full, authoritative steps): see ACTIVATION.md. In short:

  1. Apply the billing schemapsql "$POSTGRES_URL" -f apps/console/supabase/schema-billing.sql (adds profiles, subscriptions, usage_events with RLS policies + the profile-seeding trigger; additive — it does not touch the existing schema.sql).
  2. Enable Auth — set NEXT_PUBLIC_SUPABASE_ANON_KEY (the Supabase anon/publishable key; NEXT_PUBLIC_SUPABASE_URL is already set by the Vercel Supabase integration), and add https://aeo.advancelabs.dev/auth/callback to Supabase’s allowed redirect URLs (enable Email magic-link).
  3. Enable Billing — create a Stripe product + recurring monthly price per paid plan, then set STRIPE_SECRET_KEY, NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, STRIPE_PRICE_PRO, STRIPE_PRICE_AGENCY.
  4. Webhook — add a Stripe endpoint at https://aeo.advancelabs.dev/api/billing/webhook for checkout.session.completed + customer.subscription.{created,updated,deleted}, and set STRIPE_WEBHOOK_SECRET to its signing secret (the handler verifies every call’s signature).
  5. Tune pricing (optional) — defaults are Free $0 / Pro $29 / Agency $99 in apps/console/src/lib/billing/plans.ts; /pricing and the gate both read from that one file.

Roll back any time by unsetting STRIPE_SECRET_KEY (and the anon key) and redeploying — the schema and Stripe data are untouched and reactivate the moment the keys return.