πŸ”Œ Integrations

πŸ“‹ Summary

Midshift talks to very few outside systems, which keeps its operational surface small. Almost everything runs on two vendors: Supabase (database, logins and file storage) and Vercel (hosting and the two nightly scheduled jobs). The only other runtime service is Resend, used to send four kinds of e-mail (campaign invitations, password resets, notes to suppliers and feedback to the owner). There are no payment, analytics, CRM, AI or digital-asset-management integrations; links to file stores and Figma are stored as plain text by design. The main concerns are that Supabase is woven through more than a third of the source files with no abstraction layer, that e-mail sending is "best effort" with no retries, no delivery tracking and in one case a log entry claiming success that may not have happened, and that nothing monitors whether the integrations (including the nightly jobs) are actually working.

🎯 Rating

🟑 ●●●○○ 3 / 5 β€” Amber. A small, sensible set of integrations with credentials held correctly in environment variables and good fail-closed behaviour on scheduled jobs, but with deep single-vendor coupling, no retry or monitoring for any integration, and e-mail failures that are largely invisible.

πŸ” Findings

1. Integration inventory

#IntegrationPurposeDirectionTrigger / frequencyAuth & credentialsProtocolFailure handlingMonitoring
I1Supabase Postgres (PostgREST)System of record: all business dataOutbound from functions and from browsersEvery page render and API callAnon key + user JWT cookie (RLS); service-role key on server (SUPABASE_SERVICE_ROLE_KEY)HTTPS/JSON via @supabase/supabase-js 2.99.3Writes read their error (test-enforced); many reads discard errors; no retryNone beyond Supabase dashboard
I2Supabase AuthPassword sign-in, session refresh, invite / recovery link minting, e-mail changeOutboundEvery request (session refresh in src/proxy.ts); on sign-in / invite / resetSame keys; admin API uses service-role keyHTTPS via @supabase/ssr 0.9 cookiesForgot-password returns ok in every branch (anti-enumeration)None
I3Supabase Storage (campaign-assets bucket)Review cuts, finals, previews, brief/concept files, branding imagesOutbound; browsers upload/download direct via signed URLsOn upload, page render (signed reads), nightly purge (deletes)Service-role key server-side; time-limited signed URLs (1 h and 1 year) to browsersHTTPSUpload/remove errors read; purge aborts row deletion if object removal failsNone
I4Supabase built-in mailerE-mail-address change confirmation onlyOutbound (Supabase β†’ user)User changes e-mailSupabase project SMTP settings (dashboard)SMTP via SupabaseError surfaced to userNone; acknowledged inconsistency
I5ResendTransactional e-mail: campaign invite, password reset, supplier "changes requested", feedback to ownerOutboundEvent-driven, low volumeRESEND_API_KEY, sender RESEND_FROM_DOMAIN (env)HTTPS REST via resend SDK 6.xBest-effort; errors swallowed or logged to console; no retry, no queueResend dashboard only; not in repo
I6Vercel CronNightly retention purge (03:00 UTC) and overdue-date sweep (07:00 UTC)Inbound (Vercel β†’ app)Daily, vercel.jsonAuthorization: Bearer ${CRON_SECRET}; fail-closed if unsetHTTPS GETReturns 500 with detail on failure; next day's run retries implicitlyNone β€” nobody is alerted to a 401 or 500
I7Vercel platformHosting, functions, edge, HSTS, env-var storeβ€”ContinuousVercel project settings; .env.ops tokens for scripts/ops.shβ€”PlatformVercel function logs only
I8Google Fonts (build time)Inter, JetBrains Mono via next/font/google, self-hosted after buildOutbound at build onlyEach buildNoneHTTPSBuild-time dependencyn/a
I9External humans via token linksSuppliers upload cuts (/upload/[token]); clients/DAM teams view handover and confirm archive ingest (/preview/[token])InboundAd hocBearer tokens in URL (campaign_upload_links, campaign_share_links); expiry checkedHTTPS (browser)Expired/unknown token refusedAccesses logged to activity_log / log_share_link_access RPC
I10First-party telemetry /api/trackClient interaction trail into activity_logInbound (browser β†’ own API)User interactionsSession cookie; dropped when signed outHTTPS POST, keepaliveFire-and-forget, never throwsAdmin β†’ Activity
I11GitHub / GitHub Actions; ops CLIs (gh, vercel, psql)CI, manual acceptance walk, operator deploy/SQLDev/ops timePush / PR / manualGH_TOKEN, VERCEL_TOKEN, SUPABASE_DB_URL in local .env.ops (gitignored); repo secrets for the walkβ€”n/aGitHub UI

Not present [Observed]: payments, analytics or session replay (Clarity removed, CLAUDE.md "Interaction recording"), error tracking (app_errors table has no writer, ARCHITECTURE.md Β§9), LLM/AI providers (/api/ask imports only Supabase and src/lib/ask-intent.ts), DAM/file-store APIs (file_store_url is plain text by decision), Figma API (plugin cut, migration 092), inbound third-party webhooks (grep -ril webhook src outside the harness β†’ none).

google-auth-library is declared but never imported anywhere in src, scripts or e2e. [Observed] grep -rln "google-auth\|googleapis\|GoogleAuth" matches only package.json, package-lock.json, ARCHITECTURE.md and docs/DESIGN_CHECKS.md. There is no Google API integration; the dependency is dead weight and unexplained attack surface. ARCHITECTURE.md Β§11 records the same.

2. Detail by integration

I1–I3. Supabase (database, auth, storage) β€” deep coupling

Client construction is centralised in src/lib/supabase/ (client.ts browser, server.ts RSC/route, service.ts service-role, middleware.ts proxy, env.ts normalises URLs/keys) [Observed]. Usage is not:

MeasureCountCommand
src files (outside src/lib/supabase) importing any Supabase client or package168 of 465 (36%)grep -rlE "@/lib/supabase/|@supabase/" src … | wc -l
…importing the server (cookie) client114grep -rl "@/lib/supabase/server'"
…importing the service-role client98grep -rl "@/lib/supabase/service'"
…importing the browser client24grep -rl "@/lib/supabase/client'"
API route files using the service-role client67 of 83grep -l createServiceClient
Files using Storage directly (.storage)23grep -rl "\.storage"
Signed-URL / signed-upload-URL call sites22grep -rn "createSignedUrl|createSignedUrls|createSignedUploadUrl"
Postgres function (RPC) calls1grep -rn "\.rpc("

There is no repository or adapter layer: PostgREST query-builder chains (.from().select().eq()) appear directly in pages, route handlers, library modules and 16 client components. Migrating off Supabase, or absorbing a breaking SDK change, would touch roughly 170 files. The project also leans on Supabase-specific features (RLS policies β€” 95 in baseline.sql; Auth admin generateLink; Storage signed upload URLs), so the coupling is semantic as well as syntactic. [Observed] Supabase is a mainstream, well-supported choice, so this is a strategic lock-in point rather than an immediate defect. [Inferred]

Auth flows [Observed]:

  • Sign-in: supabase.auth.signInWithPassword (src/app/login/login-form.tsx:27, src/components/auth/portal-login-form.tsx:51).
  • Session refresh on every request: updateSession β†’ auth.getUser() (src/lib/supabase/middleware.ts:32).
  • Invites, password recovery and admin reset links are minted with auth.admin.generateLink (src/app/api/admin/invite/route.ts:33, src/app/api/auth/forgot-password/route.ts:25, src/app/api/admin/users/[id]/reset-link/route.ts:26) and delivered by Resend or shown to the admin β€” deliberately avoiding Supabase's rate-limited mailer.
  • E-mail change still uses the built-in mailer (auth.updateUser({ email }) at src/app/(app)/workspace/settings/settings-view.tsx:56 and src/app/(app)/user-settings/user-settings-form.tsx:40), acknowledged in ARCHITECTURE.md Β§10. This depends on Supabase SMTP being configured per deployment (docs/NEW_DEPLOYMENT.md "What the migrations do NOT cover").
  • Dev-only auto sign-in mints real sessions with the service-role key, gated on NODE_ENV === 'development' (middleware.ts:41-60; src/app/api/auth/dev-login/route.ts:15).

Storage [Observed]:

  • Uploads go browser β†’ Storage via one-time signed upload URLs from four routes (upload/[token]/url, campaigns/[id]/upload-asset, review/upload-url, files/upload-url), so file bytes do not pass through functions. Exception: POST /api/campaigns/[id]/upload-previews uploads server-side (route.ts:134-136).
  • One-year signed read URLs are persisted in database columns (e.g. ad_unit_frames.image_url, reference_image_url): src/lib/upload-asset.ts:49, upload-previews/route.ts:145, render-push/route.ts:222, review/files/[fileId]/route.ts:180-184, and branding images (src/lib/branding.ts:124). A leaked URL cannot be revoked short of deleting the object, and the URLs expire after a year, at which point stored images break unless re-signed. [Observed]/[Inferred]
  • Final-grid and concept pages sign each file individually (3 calls per final file, src/lib/final-load.ts:54-57) rather than using the batch API (see Module 3, P4).
  • The nightly purge removes objects before rows and refuses to delete rows if any removal fails (src/app/api/cron/purge-review/route.ts:128-143) β€” good resilience design.

Resilience: if Supabase is down, the entire application is down (pages, APIs, sign-in and files). There are no retries, timeouts (AbortSignal), circuit breakers or cached fallbacks anywhere in src [Observed] grep -rniE "retry|backoff|AbortSignal.timeout" β†’ only comments about idempotent re-submission. This is normal for this class of app; it means availability equals the lower of Vercel's and Supabase's.

Credentials: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY (public by design), SUPABASE_SERVICE_ROLE_KEY (server only; src/lib/supabase/env.ts reads it only in server modules). Managed in Vercel env vars, optionally pushed from a local .env.ops by scripts/ops.sh env-sync. No rotation procedure is documented. [Observed] .env.example, .env.ops.example; gitleaks clean (BRIEF).

I5. Resend β€” transactional e-mail

Call siteE-mailData sentError handlingEvidence
src/app/api/campaigns/[id]/invite/route.ts:77-120Campaign invitation (HTML)Invitee e-mail, campaign name, inviter name, invite link with bearer token, org logo URLReads { error }; returns emailSent: false to the UI and logs it in activity_log[Observed]
src/app/api/auth/forgot-password/route.ts:33-56Password reset (HTML)E-mail, one-hour recovery token link.catch(() => {}) β€” fully silent by design (anti-enumeration); a returned { error } is also ignored[Observed]
src/app/api/feedback/route.ts:51-66Feedback to OWNER_EMAIL (text)Submitter e-mail, message, page pathtry/catch β†’ console.error; feedback row is stored regardless[Observed]
src/lib/review-notify.ts:67-113"Changes requested" to an external supplier (text)Supplier label, file name/version, unresolved review notes, upload link with tokentry/catch β†’ console.error; then writes supplier_notes_emailed to the audit trail[Observed]

Findings:

  • No abstraction. new Resend(process.env.RESEND_API_KEY) and the from-address fallback are repeated in four places, with two hand-built HTML templates inline in route handlers. [Observed] Swapping provider, adding retries or adding logging means editing four files.
  • The supplier e-mail can log success that did not happen. The Resend SDK returns API failures as { error } rather than throwing [Inferred from the SDK's documented contract; the invite route relies on exactly that at invite/route.ts:86,113]. emailSupplierNotes ignores the returned value (review-notify.ts:92) and then records supplier_notes_emailed, whose comment states "the row means it went" (review-notify.ts:104-107). A rejected send (bad domain, quota, invalid address) therefore leaves an audit entry saying notes were sent to an outside party. [Observed]/[Inferred]
  • Unsafe fallback sender. If RESEND_FROM_DOMAIN is unset, all four sites send from onboarding@resend.dev (Resend's sandbox sender, which only delivers to the account owner) β€” mail silently fails to reach anyone else. [Observed] fallback string; [Inferred] sandbox behaviour.
  • Unescaped values in the invite HTML. campaignName and inviterName are interpolated into the HTML body without escapeHtml (invite/route.ts:113, 131), although the organisation name is escaped (:85). A campaign or user name containing markup would be rendered in the recipient's mail client. Impact is limited (only editors/admins set those names) but it contradicts the stated rule in CLAUDE.md Branding. [Observed]
  • Host-derived links. When NEXT_PUBLIC_APP_URL is unset, invite and reset links fall back to the request Origin header / request URL (invite/route.ts:67, forgot-password/route.ts:22, admin/invite/route.ts:31); NEXT_PUBLIC_APP_URL is mandatory per CLAUDE.md, so this is a misconfiguration risk rather than a default-on flaw. [Observed]
  • No bounce/complaint webhook, no delivery status, no retry, no queue. If Resend is down, invites report emailSent: false (the inviter can copy the returned link), resets and supplier notes are silently lost. [Observed]
  • Sender-domain DNS (SPF/DKIM) is a dashboard task listed in docs/NEW_DEPLOYMENT.md and not verifiable from the repository. [Unavailable] β€” requires access to the Resend account and DNS.

I6. Vercel Cron β€” scheduled jobs

JobScheduleDoesCredentialsFailure behaviourEvidence
GET /api/cron/purge-review0 3 * * *Deletes superseded review cuts (>14 days) and archived campaigns' files (30 days after the later of archive and DAM ingest), objects then rowsCRON_SECRET bearer; 401 if unset or wrong500 with detail; nothing deleted on partial failure; next run retriesvercel.json; route.ts:28-31, 98-149
GET /api/cron/overdue0 7 * * *Marks slipped task dates, releases moved ones, inserts in-app notifications (no e-mail)Same500 with detail on write failure; read errors ignoredsrc/app/api/cron/overdue/route.ts:26-108

Fail-closed is the right default, but it has a silent failure mode: a deployment without CRON_SECRET simply never purges files and never raises overdue alerts, and nothing tells anyone. CLAUDE.md acknowledges this; there is no health check or alert. [Observed] .env.example describes CRON_SECRET as gating only purge-review β€” it gates both. [Observed] docs drift. ARCHITECTURE.md Β§2 likewise lists one cron. Vercel cron execution history is visible only in the Vercel dashboard. [Unavailable] β€” requires Vercel access to confirm the jobs run.

The bearer comparison uses !== (not constant-time); with a long random secret this is a negligible risk. [Observed] route.ts:30.

I8. Build-time font fetch

src/app/layout.tsx:17 imports Inter and JetBrains_Mono from next/font/google; Next.js downloads and self-hosts them at build, so no runtime request leaves for Google [Observed] code comment layout.tsx:21. The build therefore depends on Google Fonts availability and outbound network; offline/air-gapped builds will fail or fall back. [Inferred]

Suppliers and client/DAM teams have no accounts; they reach the system through URL tokens (src/app/upload/[token], src/app/preview/[token], routes under src/app/api/upload/[token]/* and src/app/api/preview/[token]/custody). Upload tokens are checked for expiry (src/app/api/upload/[token]/url/route.ts:14-17); preview access is counted via log_share_link_access (src/app/preview/[token]/page.tsx:51); tokens are redacted when logged (src/lib/activity.ts redactPath) and Referrer-Policy: strict-origin-when-cross-origin is set (next.config.ts). [Observed] These are the system's only inbound integrations with third parties and are reasonably designed; token strength and rate-limiting are covered in the security module.

3. Flags

FlagIntegrationDetail
Single vendor, no abstractionSupabase (DB, Auth, Storage)168 files, 95 RLS policies, Auth admin and Storage signing APIs
Single vendor, no abstractionResend4 inline call sites, inline templates
FragileResendBest-effort only; no retry; false-positive audit entry for supplier notes; sandbox sender fallback
FragilePersisted 1-year signed URLsNon-revocable; will expire inside stored records
Silent failureVercel CronMissing CRON_SECRET or failing job alerts nobody
Undocumented / deadgoogle-auth-libraryDeclared, never imported
Docs driftCron.env.example and ARCHITECTURE.md describe one cron; there are two

🧾 Evidence

Commands run (from /home/user/midshift-audit):

  • grep -rn "google-auth-library\|GoogleAuth\|from 'resend'\|new Resend\|fetch(\?https?:" src scripts` β†’ Resend in 4 files; no Google imports.
  • grep -rln "google-auth\|googleapis\|GoogleAuth" --exclude-dir=node_modules . β†’ package.json, package-lock.json, ARCHITECTURE.md, docs/DESIGN_CHECKS.md only.
  • grep -rn "fetch(" src | grep -v "fetch('/\|fetch(\/|src/app/qa"β†’ only same-origin calls andsrc/lib/handover-zip.ts:40` (fetching Supabase signed URLs in the browser).
  • grep -rn "https\?://…" src -o β†’ no external API hosts in application code.
  • Supabase coupling counts: see table in Β§2 (I1–I3).
  • grep -rn "inviteUserByEmail\|generateLink\|updateUser({ *email\|resetPasswordForEmail\|signInWith" src β†’ sites cited above.
  • grep -rn "createSignedUploadUrl" src β†’ 4 routes; grep -rn "60 \* 60 \* 24 \* 365" src β†’ 1-year signing sites cited above.
  • grep -rniE "retry|backoff" src, grep -rn "AbortSignal.timeout\|signal:" src β†’ no retry/timeout logic.
  • grep -rn "process.env\.[A-Z_]*" src -oh β†’ env vars in use: Supabase Γ—3, RESEND_API_KEY, RESEND_FROM_DOMAIN, NEXT_PUBLIC_APP_URL, OWNER_EMAIL, GHOST_EMAIL, CRON_SECRET, DEV_USER_EMAIL, DEV_USER_PASSWORD, NEXT_PUBLIC_UNIT_COMMENTS, NEXT_PUBLIC_PRODUCT_NAME, NEXT_PUBLIC_QA, NEXT_PUBLIC_QA_TODAY, NODE_ENV.
  • .env.example and .env.ops.example read with values masked.
  • Files read: vercel.json, src/lib/supabase/*.ts, src/app/api/cron/purge-review/route.ts, src/app/api/cron/overdue/route.ts, src/app/api/auth/forgot-password/route.ts, src/app/api/feedback/route.ts, src/app/api/campaigns/[id]/invite/route.ts, src/lib/review-notify.ts, src/lib/notify.ts, src/lib/activity.ts, src/lib/track.ts, src/lib/handover-zip.ts, src/lib/branding.ts:60-68, src/app/api/auth/dev-login/route.ts, src/app/api/admin/invite/route.ts, src/app/api/upload/[token]/url/route.ts, src/app/api/ask/route.ts (imports), next.config.ts, ARCHITECTURE.md Β§Β§2, 9–11, docs/NEW_DEPLOYMENT.md Β§2.

⚠️ Risks

IDRiskSeverityEffort
R1Total dependence on Supabase for data, auth and files, coded directly against the SDK in ~170 files; vendor outage = full outage; platform change or major SDK upgrade is expensive🟑 MediumL
R2No monitoring of any integration: failing crons, rejected e-mails and Supabase errors are visible only in platform logs nobody watches; a missing CRON_SECRET silently disables retention and overdue alerts🟠 HighS
R3Supplier "changes requested" e-mail ignores Resend's returned error and still writes a "sent" audit entry β€” false record of communication with an external party🟑 MediumS
R4E-mail is best-effort with no retry or queue; password resets and supplier notes are silently lost when Resend is unavailable or misconfigured (incl. sandbox-sender fallback)🟑 MediumM
R5One-year signed URLs stored in the database: not revocable if leaked; images break when they expire🟑 MediumM
R6Unescaped campaign and inviter names in invite e-mail HTMLβšͺ LowS
R7Unused google-auth-library dependency adds supply-chain surface with no purposeβšͺ LowS
R8E-mail-change flow depends on Supabase's built-in mailer and per-deployment SMTP settings, unlike every other mail pathβšͺ LowS
R9Build depends on Google Fonts availability (next/font/google)βšͺ LowS

βœ… Recommendations

PriorityRecommendationEffort
P1Add monitoring: an error tracker for functions, a cron heartbeat/health check that alerts on missing CRON_SECRET or non-200 runs, and alerting on e-mail send failuresS
P1Fix emailSupplierNotes to read Resend's { error } before logging supplier_notes_emailed; do the same in forgot-password (log, do not surface)S
P2Introduce one sendEmail() module wrapping Resend: shared templates with mandatory escaping, required sender (fail at startup if RESEND_FROM_DOMAIN is unset), structured logging, simple retry; route the e-mail-change flow through it via generateLinkM
P2Stop persisting long-lived signed URLs; store object paths and sign at read time (batch createSignedUrls), with short expiriesM
P2Remove google-auth-library (and date-fns) from package.jsonS
P3Introduce a thin data-access layer for the most-used tables and Storage paths so Supabase specifics are localised; consider a Supabase health/latency probeL
P3Correct .env.example and ARCHITECTURE.md to describe both cron jobs; document key-rotation procedure for the service-role, Resend and cron secretsS
P4Consider self-hosting font files in-repo to remove the build-time network dependencyS