Tracks job applications through a pipeline (Wishlist → Applied → Phone Screen → Interview → Offer → Rejected/Withdrawn), with a stats dashboard as a core feature. See PLAN.md for the full spec and the reasoning behind the data model.
- Node.js 20+ — https://nodejs.org (or via
nvm). Not currently installed. - A Supabase project — https://supabase.com/dashboard
- A Google Maps Platform key for office addresses and the dashboard map — optional, see Office locations. Without it addresses still save; they just get no map pin.
-
In the Supabase SQL editor, run
supabase/bootstrap.sql. Creates all tables, indexes, theupdated_attrigger, theprofilesprovisioning trigger, and the RLS policies. Safe to re-run. An already-provisioned database instead needs the numbered files it has not yet run, in order:002_profiles.sql,003_dashboard.sql,004_application_location.sql.Skipping one shows up at runtime, not at build time — saving an application against a database missing
004fails with "Could not find the 'location_lat' column of 'applications' in the schema cache".npm testchecks that every column inschema.tsis declared by some file insupabase/, but it cannot know which files a given database has run. -
In Authentication → Sign In / Providers: enable Google (Client ID and Secret from Google Cloud Console, with
https://<project-ref>.supabase.co/auth/v1/callbackas an authorized redirect URI), and disable Email.Disabling Email matters. Removing the password fields from the UI does not remove the password path — while the Email provider is on, anyone can still register straight against the Supabase API. SSO-only has to be enforced at the provider, not the form.
-
cp .env.example .envand fill in the values. -
Then:
npm install
npm run db:push # confirms schema.ts and the live DB agree (expect no changes)
npm run devUse npm run build:check rather than npm run build while the dev server is
running. A plain build writes to the same .next the dev server serves from and
corrupts its chunks, producing a blank page and a __webpack_modules__[moduleId] is not a function exception. build:check targets .next-build instead.
The publishable key is browser-safe and bound by RLS. The secret key bypasses
RLS — keep it server-side and never prefix it NEXT_PUBLIC_.
Needs the gcloud CLI and a GCP
project with billing enabled.
gcloud auth login
gcloud config set project <PROJECT_ID>
./scripts/gcp/setup.sh # once: APIs, image repo, service accounts, empty secrets
./scripts/gcp/secrets.sh # values from .env - or set them in the console
npm run deploy # build, push, deployWhat goes into Secret Manager: five values.
Four are build-time: NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY, NEXT_PUBLIC_LOGO_DEV_TOKEN and
NEXT_PUBLIC_GOOGLE_MAPS_BROWSER_KEY. Cloud Build fetches them itself
(availableSecrets in cloudbuild.yaml) and passes them to docker build;
they exist only inside that build step. Secret Manager here is a place to keep
configuration together, not a vault — every one of these is public by design
once the bundle ships. Because Next inlines NEXT_PUBLIC_*, changing one
means redeploying - adding a secret version alone changes nothing that is
running.
One is runtime: GOOGLE_MAPS_API_KEY, mounted into the container by Cloud Run
(--set-secrets) and read by the runtime service account, which is the only
identity granted access to it. It stays out of the bundle on purpose — address
lookup and the map are called from the server so that key is never handed to a
browser. Rotating it needs a new revision, not a rebuild.
What deliberately does not: SUPABASE_SECRET_KEY and DATABASE_URL.
Both bypass RLS, and the app needs neither at runtime.
If you create secrets in the console, paste the bare value. A trailing newline would be inlined into the JavaScript; the build strips one defensively, but it is better not to have it.
After the first deploy, take the service URL it prints and:
- Supabase → Authentication → URL Configuration: set the Site URL, and add
https://<service-url>/auth/callbackto the redirect URLs. Without this, Supabase sends users back to the old Site URL after Google sign-in. - Google's redirect URI stays the Supabase one
(
https://<project-ref>.supabase.co/auth/v1/callback) - nothing to change. - Optional: with a custom domain, redeploy with
npm run deploy -- --substitutions=_SITE_URL=https://your.domain.
Defaults (region northamerica-northeast1, TZ=America/Toronto, scale to
zero, max 2 instances) live in cloudbuild.yaml under substitutions. Put
the region near your Supabase project's: every page load makes Supabase calls.
Every application records where the office is. It is required unless the role is fully remote — and a remote role may still record one, since "remote" often means remote within a region, and a head office you visit quarterly is worth knowing. The address field suggests real addresses as you type, and the dashboard plots every one of them on a map.
How it is wired. Picking a suggestion stores Google's place id alongside the text; the coordinates are then looked up again on the server when the form is saved, so nothing a browser sends can move a pin. Change the address later and the pin moves with it, on every path — the form, and the MCP tools. When the lookup fails or no key is configured, the address is saved as plain text with no pin, and the next save of that application retries.
"Remote" is not an address. Google answers almost anything: it resolves
Remote to a holiday villa in Greece and Anywhere to a tour operator in Costa
Rica, both with real coordinates. Text that is only a placeholder is refused
before the lookup is made (isPlaceholderLocation in src/lib/geo/places.ts),
so those applications keep their text and stay off the map. A placeholder next
to a real place is fine — "Montreal (remote)" still finds Montreal.
Over MCP, the same thing happens deliberately rather than by typing:
lookup_location takes free text ("Shopify Montreal", "our Toronto office") and
returns the candidates Google matched, each with a placeId to pass to
create_application or set_application_location. The tool descriptions tell
the model to look the office up rather than guess at it.
The map pans and zooms. It is a Maps JavaScript map with a pin per office: drag to move, the zoom buttons or ctrl/⌘-scroll to zoom, Street View and fullscreen in the corner, and a click on a pin opens the applications at that address as links. Scrolling the page over the map scrolls the page — zooming takes a modifier — so the map cannot swallow a scroll aimed past it. It follows the app's light/dark. The list beside it carries the same places, and is the part that works with JavaScript off and the part a screen reader can use.
Two keys, and the difference matters. GOOGLE_MAPS_API_KEY is read only by
the server and never prefixed NEXT_PUBLIC_. It can spend Places quota on
geocoding, so it stays out of the bundle: that is why suggestions go through
/api/places/search and the still map arrives as an image from
/api/map/applications instead of a Google URL in the markup. Both endpoints
require a signed-in session — an open proxy to a metered API is somebody else's
free geocoder.
NEXT_PUBLIC_GOOGLE_MAPS_BROWSER_KEY is the opposite and is meant to be public,
because a map that pans has to be drawn by the browser. It is restricted to the
Maps JavaScript API and to this app's own domains, so the worst a scraped copy
can do is draw a map — it cannot geocode. Leave it unset and the map falls back
to the still image, which is also what shows while the library loads and if it
fails to load at all.
gcloud services enable places.googleapis.com static-maps-backend.googleapis.com \
maps-backend.googleapis.com
# Server key: geocoding and the still map. Never leaves the server, so it gets
# no referrer restriction - a request from a server has no Referer to check.
gcloud services api-keys create --display-name="job-tracker-places" \
--api-target=service=places.googleapis.com \
--api-target=service=static-maps-backend.googleapis.com
# Browser key: the interactive map only, locked to the origins it may run on.
gcloud services api-keys create --display-name="job-tracker-maps-browser" \
--api-target=service=maps-backend.googleapis.com \
--allowed-referrers="http://localhost:3000/*","https://<your-service>/*"
# Print either one:
gcloud services api-keys get-key-string "$(gcloud services api-keys list \
--filter='displayName=job-tracker-places' --format='value(name)' | head -1)"Or in the console: APIs & Services → Library, enable Places API (New), Maps Static API and Maps JavaScript API; then Credentials → Create credentials → API key twice, restricting each under API restrictions as above, and the browser one under Application restrictions → Websites.
Put them in .env as GOOGLE_MAPS_API_KEY and
NEXT_PUBLIC_GOOGLE_MAPS_BROWSER_KEY, and for a deploy, in Secret Manager under
the same names (./scripts/gcp/secrets.sh does this from .env). Add a new
origin to the browser key's referrer list whenever the app gets one, or the map
silently refuses to draw there.
All three APIs are billable with a monthly free allowance. The address lookup fires at most once per 300ms of typing, the still map is cached five minutes per viewer, and dynamic map loads have their own free tier.
An address that could not be resolved — the lookup was down, the key was not set yet, or the text was a placeholder — keeps its text and simply has no pin. Save that application again and the address is resolved on the way through, so there is nothing to run and nothing to remember.
The app exposes an MCP server at /api/mcp, so Claude can fill the tracker from
a job posting: give it a URL and it reads the page itself, then calls
create_application with the fields it extracted. It can also search, move an
application along the pipeline, add notes and interview rounds, and resolve an
office address to a map pin (lookup_location, set_application_location).
Authorization is Supabase Auth's own OAuth 2.1 server, not something this app
implements — Claude discovers it via /.well-known/oauth-protected-resource,
you approve the connection once at /oauth/consent, and the access token it
receives is an ordinary Supabase JWT. So the same RLS policies that scope the
web UI scope the MCP tools, and the Cloud Run service still holds no secrets.
One-time Supabase setup (Authentication → OAuth Server in the dashboard):
- Enable the OAuth 2.1 server.
- Set the authorization URL path to
/oauth/consent. - Enable dynamic client registration.
Step 3 is what makes connecting a one-field operation. With it on, the client registers itself during discovery and the user pastes a URL and nothing else — no client id, no secret, no dashboard visit. With it off, every user has to be handed a client id out of band, which is not a thing you can ask of people.
The cost is that anyone can register a client and name it whatever they like,
so client_name on the consent screen proves nothing. That is handled where it
has to be: the consent screen treats the name as an unverified claim and vouches
for the redirect URI instead, warning plainly when the app is one it does not
recognise (src/lib/mcp/clients.ts). Nothing is granted until a signed-in user
approves it there.
In Claude: Settings → Connectors → Add custom connector, and paste:
https://<your-service>/api/mcp
That is the whole setup. Claude reads /.well-known/oauth-protected-resource,
finds Supabase, registers itself, and sends you to /oauth/consent to approve.
Revoke any time at /settings/connections.
If you cannot enable dynamic client registration
Register a client by hand and give its id to each user:
node scripts/supabase/register-oauth-client.mjs Claude
Needs SUPABASE_SECRET_KEY from .env. Its redirect URI list has to name every
client surface up front, because Supabase matches redirect URIs exactly and does
not accept wildcards — which is the other reason this does not scale.
Then: "Add this job to my tracker: <url>".
Supabase builds the consent redirect from the project's Site URL, so point that at your deployed origin (not
localhost:3000) before connecting Claude to the deployed app.
| Path | What |
|---|---|
src/db/schema.ts |
Single source of truth for the data model |
src/db/scope.ts |
Ownership predicate for Drizzle queries, which bypass RLS |
supabase/bootstrap.sql |
One-time DDL + RLS, mirrors schema.ts |
src/db/index.ts |
Drizzle client |
drizzle/ |
Generated migrations once Node is available — commit, never edit |
src/lib/applications/ |
Validation and write logic, shared by the form and the MCP tools |
src/lib/geo/ |
Places lookup, map URLs, and the pin grouping the map and its list share |
src/app/api/places/search/ |
Address suggestions, proxied so the key stays server-side |
src/app/api/map/applications/ |
The dashboard map, fetched as an image for the same reason |
src/lib/mcp/ |
MCP tool definitions and bearer-token verification |
src/app/api/mcp/route.ts |
The MCP endpoint |
src/app/oauth/consent/ |
Consent screen for Supabase's OAuth 2.1 server |
src/lib/mcp/clients.ts |
Recognises OAuth clients at the consent screen |
scripts/supabase/ |
Manual OAuth client registration, if DCR is off |
Dockerfile |
Three-stage build to a standalone Next server |
cloudbuild.yaml |
Build, push, deploy; pulls config from Secret Manager |
scripts/gcp/ |
One-time project setup and secret upload |
The 2019-era version (Express + Mongo + Angular). Its data model is the reason this one tracks meetings, recruiters, and recruiter-vs-direct sourcing; see the table at the top of PLAN.md.