π Integrations
π 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
| # | Integration | Purpose | Direction | Trigger / frequency | Auth & credentials | Protocol | Failure handling | Monitoring |
|---|---|---|---|---|---|---|---|---|
| I1 | Supabase Postgres (PostgREST) | System of record: all business data | Outbound from functions and from browsers | Every page render and API call | Anon key + user JWT cookie (RLS); service-role key on server (SUPABASE_SERVICE_ROLE_KEY) | HTTPS/JSON via @supabase/supabase-js 2.99.3 | Writes read their error (test-enforced); many reads discard errors; no retry | None beyond Supabase dashboard |
| I2 | Supabase Auth | Password sign-in, session refresh, invite / recovery link minting, e-mail change | Outbound | Every request (session refresh in src/proxy.ts); on sign-in / invite / reset | Same keys; admin API uses service-role key | HTTPS via @supabase/ssr 0.9 cookies | Forgot-password returns ok in every branch (anti-enumeration) | None |
| I3 | Supabase Storage (campaign-assets bucket) | Review cuts, finals, previews, brief/concept files, branding images | Outbound; browsers upload/download direct via signed URLs | On upload, page render (signed reads), nightly purge (deletes) | Service-role key server-side; time-limited signed URLs (1 h and 1 year) to browsers | HTTPS | Upload/remove errors read; purge aborts row deletion if object removal fails | None |
| I4 | Supabase built-in mailer | E-mail-address change confirmation only | Outbound (Supabase β user) | User changes e-mail | Supabase project SMTP settings (dashboard) | SMTP via Supabase | Error surfaced to user | None; acknowledged inconsistency |
| I5 | Resend | Transactional e-mail: campaign invite, password reset, supplier "changes requested", feedback to owner | Outbound | Event-driven, low volume | RESEND_API_KEY, sender RESEND_FROM_DOMAIN (env) | HTTPS REST via resend SDK 6.x | Best-effort; errors swallowed or logged to console; no retry, no queue | Resend dashboard only; not in repo |
| I6 | Vercel Cron | Nightly retention purge (03:00 UTC) and overdue-date sweep (07:00 UTC) | Inbound (Vercel β app) | Daily, vercel.json | Authorization: Bearer ${CRON_SECRET}; fail-closed if unset | HTTPS GET | Returns 500 with detail on failure; next day's run retries implicitly | None β nobody is alerted to a 401 or 500 |
| I7 | Vercel platform | Hosting, functions, edge, HSTS, env-var store | β | Continuous | Vercel project settings; .env.ops tokens for scripts/ops.sh | β | Platform | Vercel function logs only |
| I8 | Google Fonts (build time) | Inter, JetBrains Mono via next/font/google, self-hosted after build | Outbound at build only | Each build | None | HTTPS | Build-time dependency | n/a |
| I9 | External humans via token links | Suppliers upload cuts (/upload/[token]); clients/DAM teams view handover and confirm archive ingest (/preview/[token]) | Inbound | Ad hoc | Bearer tokens in URL (campaign_upload_links, campaign_share_links); expiry checked | HTTPS (browser) | Expired/unknown token refused | Accesses logged to activity_log / log_share_link_access RPC |
| I10 | First-party telemetry /api/track | Client interaction trail into activity_log | Inbound (browser β own API) | User interactions | Session cookie; dropped when signed out | HTTPS POST, keepalive | Fire-and-forget, never throws | Admin β Activity |
| I11 | GitHub / GitHub Actions; ops CLIs (gh, vercel, psql) | CI, manual acceptance walk, operator deploy/SQL | Dev/ops time | Push / PR / manual | GH_TOKEN, VERCEL_TOKEN, SUPABASE_DB_URL in local .env.ops (gitignored); repo secrets for the walk | β | n/a | GitHub 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:
| Measure | Count | Command |
|---|---|---|
src files (outside src/lib/supabase) importing any Supabase client or package | 168 of 465 (36%) | grep -rlE "@/lib/supabase/|@supabase/" src β¦ | wc -l |
| β¦importing the server (cookie) client | 114 | grep -rl "@/lib/supabase/server'" |
| β¦importing the service-role client | 98 | grep -rl "@/lib/supabase/service'" |
| β¦importing the browser client | 24 | grep -rl "@/lib/supabase/client'" |
| API route files using the service-role client | 67 of 83 | grep -l createServiceClient |
Files using Storage directly (.storage) | 23 | grep -rl "\.storage" |
| Signed-URL / signed-upload-URL call sites | 22 | grep -rn "createSignedUrl|createSignedUrls|createSignedUploadUrl" |
| Postgres function (RPC) calls | 1 | grep -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 })atsrc/app/(app)/workspace/settings/settings-view.tsx:56andsrc/app/(app)/user-settings/user-settings-form.tsx:40), acknowledged inARCHITECTURE.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-previewsuploads 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 site | Data sent | Error handling | Evidence | |
|---|---|---|---|---|
src/app/api/campaigns/[id]/invite/route.ts:77-120 | Campaign invitation (HTML) | Invitee e-mail, campaign name, inviter name, invite link with bearer token, org logo URL | Reads { error }; returns emailSent: false to the UI and logs it in activity_log | [Observed] |
src/app/api/auth/forgot-password/route.ts:33-56 | Password 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-66 | Feedback to OWNER_EMAIL (text) | Submitter e-mail, message, page path | try/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 token | try/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 atinvite/route.ts:86,113].emailSupplierNotesignores the returned value (review-notify.ts:92) and then recordssupplier_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_DOMAINis unset, all four sites send fromonboarding@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.
campaignNameandinviterNameare interpolated into the HTML body withoutescapeHtml(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 inCLAUDE.mdBranding. [Observed] - Host-derived links. When
NEXT_PUBLIC_APP_URLis unset, invite and reset links fall back to the requestOriginheader / request URL (invite/route.ts:67,forgot-password/route.ts:22,admin/invite/route.ts:31);NEXT_PUBLIC_APP_URLis mandatory perCLAUDE.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.mdand not verifiable from the repository. [Unavailable] β requires access to the Resend account and DNS.
I6. Vercel Cron β scheduled jobs
| Job | Schedule | Does | Credentials | Failure behaviour | Evidence |
|---|---|---|---|---|---|
GET /api/cron/purge-review | 0 3 * * * | Deletes superseded review cuts (>14 days) and archived campaigns' files (30 days after the later of archive and DAM ingest), objects then rows | CRON_SECRET bearer; 401 if unset or wrong | 500 with detail; nothing deleted on partial failure; next run retries | vercel.json; route.ts:28-31, 98-149 |
GET /api/cron/overdue | 0 7 * * * | Marks slipped task dates, releases moved ones, inserts in-app notifications (no e-mail) | Same | 500 with detail on write failure; read errors ignored | src/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]
I9. External parties via bearer-token links
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
| Flag | Integration | Detail |
|---|---|---|
| Single vendor, no abstraction | Supabase (DB, Auth, Storage) | 168 files, 95 RLS policies, Auth admin and Storage signing APIs |
| Single vendor, no abstraction | Resend | 4 inline call sites, inline templates |
| Fragile | Resend | Best-effort only; no retry; false-positive audit entry for supplier notes; sandbox sender fallback |
| Fragile | Persisted 1-year signed URLs | Non-revocable; will expire inside stored records |
| Silent failure | Vercel Cron | Missing CRON_SECRET or failing job alerts nobody |
| Undocumented / dead | google-auth-library | Declared, never imported |
| Docs drift | Cron | .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.mdonly.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.exampleand.env.ops.exampleread 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
| ID | Risk | Severity | Effort |
|---|---|---|---|
| R1 | Total 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 | π‘ Medium | L |
| R2 | No 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 | π High | S |
| R3 | Supplier "changes requested" e-mail ignores Resend's returned error and still writes a "sent" audit entry β false record of communication with an external party | π‘ Medium | S |
| R4 | E-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) | π‘ Medium | M |
| R5 | One-year signed URLs stored in the database: not revocable if leaked; images break when they expire | π‘ Medium | M |
| R6 | Unescaped campaign and inviter names in invite e-mail HTML | βͺ Low | S |
| R7 | Unused google-auth-library dependency adds supply-chain surface with no purpose | βͺ Low | S |
| R8 | E-mail-change flow depends on Supabase's built-in mailer and per-deployment SMTP settings, unlike every other mail path | βͺ Low | S |
| R9 | Build depends on Google Fonts availability (next/font/google) | βͺ Low | S |
β Recommendations
| Priority | Recommendation | Effort |
|---|---|---|
| P1 | Add 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 failures | S |
| P1 | Fix emailSupplierNotes to read Resend's { error } before logging supplier_notes_emailed; do the same in forgot-password (log, do not surface) | S |
| P2 | Introduce 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 generateLink | M |
| P2 | Stop persisting long-lived signed URLs; store object paths and sign at read time (batch createSignedUrls), with short expiries | M |
| P2 | Remove google-auth-library (and date-fns) from package.json | S |
| P3 | Introduce a thin data-access layer for the most-used tables and Storage paths so Supabase specifics are localised; consider a Supabase health/latency probe | L |
| P3 | Correct .env.example and ARCHITECTURE.md to describe both cron jobs; document key-rotation procedure for the service-role, Resend and cron secrets | S |
| P4 | Consider self-hosting font files in-repo to remove the build-time network dependency | S |