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.
| Piece | Target | How |
|---|---|---|
apps/console — all tools + MCP + blogging cron | Vercel (one project) | Root Directory = apps/console, framework Next.js, install/build at repo root with pnpm + Turbo |
apps/chrome-extension | Chrome Web Store | pnpm --filter @advance-labs/chrome-extension build → zip dist/ → upload |
Current deployment (live)
- Project:
advancelabs/aeo-toolkit(Vercel team Advance Labs), Root Directoryapps/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’sSUPABASE_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; theai-visibility+backlinkMCP 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
# 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-toolkitThen 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:
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
nextat a patched release (the repo runs 15.5.x;15.1.xis rejected).
Set env vars + deploy
# generate the app secrets (one-time)openssl rand -base64 32 # TOKEN_ENCRYPTION_KEYopenssl rand -base64 32 # OAUTH_STATE_SECRETopenssl rand -hex 16 # CRON_SECRET
# add each (reads value from stdin); repeat per var, see apps/console/.env.exampleprintf '%s' "<value>" | vercel env add TOKEN_ENCRYPTION_KEY production
# deploy from the repo ROOTvercel deploy --prod --yesAfter 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).
vercel install supabase --name aeo-toolkit -e production -e previewvercel install neon --name aeo-toolkit-db --plan free -e production -e previewvercel install upstash/upstash-kv -e production -e previewvercel integration discover # list all available providersMarketplace database options for this team:
| Type | Providers (slug) |
|---|---|
| Postgres | Supabase (supabase), Neon (neon), Prisma Postgres (prisma/prisma-postgres), Nile, Amazon Aurora (aws/aws-apg, aws/aws-dsql) |
| Serverless SQLite | Turso (tursocloud/database) |
| Redis / KV | Redis (redis), Upstash for Redis (upstash/upstash-kv) |
| NoSQL / reactive | DynamoDB (aws/aws-dynamodb), Convex (convex) |
Code compatibility:
@advance-labs/storagetalks 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 — implementTokenStore/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)
vercel install supabase …(accept the marketplace terms once, in the browser orvercel integration accept-terms supabase --yes).- Alias the URL: the integration syncs
NEXT_PUBLIC_SUPABASE_URL, but the code readsSUPABASE_URL— addSUPABASE_URLwith the same value (SUPABASE_SERVICE_ROLE_KEYmatches as-is). - Apply the schema (
apps/console/supabase/schema.sql) using the syncedPOSTGRES_URL_NON_POOLING:(NoTerminal window vercel env pull /tmp/prod.env --environment production --yesPG=$(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 editorrm -f /tmp/prod.envpsql? Use the Supabase dashboard SQL editor, or a one-off Node client with thepostgrespackage.) - 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
| Service | Lights up | Env to set |
|---|---|---|
| ✅ Supabase | token persistence + blog store | (done — see above) |
| Google Cloud OAuth | /tools/chat, ga-gsc MCP | GOOGLE_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 keys | blogging cron | GROQ_API_KEY, ANTHROPIC_API_KEY/OPENAI_API_KEY, GOOGLE_ACCESS_TOKEN, GA4_PROPERTY_ID, SITE_URL, GSC_SITE_URL |
| Upstash (optional) | distributed rate limiting | vercel install upstash/upstash-kv, or UPSTASH_REDIS_REST_URL + ..._TOKEN (in-memory fallback otherwise) |
| Custom domain | pretty URL | attach 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/mcphttps://<domain>/api/mcp/search/mcp(/api/mcp/ga-gsc/mcpstill 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:
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/workflow
→ can_approve_pull_request_reviews), open the PR by hand:
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:
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'))"doneLicensing 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):
| Switch | On when… | Unlocks |
|---|---|---|
AUTH_ENABLED | NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY are set | /login, sessions, /account |
BILLING_ENABLED | STRIPE_SECRET_KEY is set | checkout, 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:
- Apply the billing schema —
psql "$POSTGRES_URL" -f apps/console/supabase/schema-billing.sql(addsprofiles,subscriptions,usage_eventswith RLS policies + the profile-seeding trigger; additive — it does not touch the existingschema.sql). - Enable Auth — set
NEXT_PUBLIC_SUPABASE_ANON_KEY(the Supabase anon/publishable key;NEXT_PUBLIC_SUPABASE_URLis already set by the Vercel Supabase integration), and addhttps://aeo.advancelabs.dev/auth/callbackto Supabase’s allowed redirect URLs (enable Email magic-link). - 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. - Webhook — add a Stripe endpoint at
https://aeo.advancelabs.dev/api/billing/webhookforcheckout.session.completed+customer.subscription.{created,updated,deleted}, and setSTRIPE_WEBHOOK_SECRETto its signing secret (the handler verifies every call’s signature). - Tune pricing (optional) — defaults are Free $0 / Pro $29 / Agency $99 in
apps/console/src/lib/billing/plans.ts;/pricingand 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.