Axiomancer
Changelog

May 7, 2026

Layer admin sign-in and authorization updates, Locus email fixes, and Overwatch least-privilege admin authentication.

May 7, 2026LayerFix

Dashboard sign-in goes straight to Google Workspace

The Layer dashboard login page now starts a Google Workspace OAuth flow directly instead of routing through Auth0 Universal Login. Click Continue with Google Workspace on the sign-in page, approve on Google's consent screen, and you land in the dashboard with a Supabase session — no Auth0 detour and no callback loop back to the login page.

Previously the dashboard's "Sign in with Auth0" entrypoint sent users into Auth0, but the dashboard middleware and layout require a Supabase session. After Auth0 login, users did not receive the Supabase cookies the dashboard needs, so the dashboard appeared broken or bounced back to login. The new entrypoint completes the Supabase OAuth handshake and sets the dashboard cookies in one step.

Auth0 delegation is unchanged for /auth/login and for integration OAuth flows — Slack, Atlassian, and the other connectors that broker through Auth0 keep working exactly as before. Only the dashboard sign-in entrypoint moved. No action is required on your part; the next sign-in will use the new flow automatically.

May 7, 2026LocusUpdate

Locus admin authorization prefers stable user IDs and role claims

Locus admin authorization now resolves through stable identity claims first and only falls back to the email allowlist for compatibility. Self-hosted and preview deployments can pin /admin/* access — pages and admin API routes alike — to immutable Supabase user IDs or to explicit role claims on app_metadata / user_metadata, instead of relying on the email address an identity provider happens to surface for a given account.

Two new environment variables drive the new paths, and both are checked before ADMIN_EMAILS:

  • ADMIN_USER_IDS — comma-separated list of Supabase user UUIDs. The most robust option: a user's ID never changes when they rotate their email, get aliased through SSO, or move providers. Authorized users are matched case-insensitively against user.id.
  • ADMIN_ROLE_CLAIMS — comma-separated list of role names. Any of role, roles, admin_role, admin_roles, app_role, or app_roles on app_metadata or user_metadata (string or string array) can carry the claim, so however your identity provider stamps roles into Supabase will work without extra mapping. Comparison is case-insensitive.
# Preferred: pin admin to immutable Supabase user IDs
ADMIN_USER_IDS=00000000-0000-0000-0000-000000000001,00000000-0000-0000-0000-000000000002

# Or: authorize anyone whose JWT carries `role: "admin"` (or `roles: ["admin", ...]`)
ADMIN_ROLE_CLAIMS=admin,ops

# Legacy fallback — still works, but logs a warning per request
ADMIN_EMAILS=alice@example.com,bob@example.com

The order of precedence is ADMIN_USER_IDSADMIN_ROLE_CLAIMSADMIN_EMAILS. If none of the three are configured, every admin page and admin API route continues to fail closed and the server logs a warning telling you which variables to populate. When access is granted via the legacy ADMIN_EMAILS path, the server now also logs a per-request warning naming the user ID, so you can audit who is still relying on email-based authorization before you remove the variable.

No action is required if you only consume Locus through the authenticated API; only operators of self-hosted and preview deployments need to set the new variables. Existing ADMIN_EMAILS configurations keep working unchanged — set ADMIN_USER_IDS or ADMIN_ROLE_CLAIMS alongside (or instead of) ADMIN_EMAILS when you want admin access to survive an email change at your identity provider.

May 7, 2026OverwatchUpdate

Overwatch admin auth gains least-privilege paths

Admin authorization for sensitive Overwatch endpoints — API key management, billing webhooks, and other operator-only routes — now resolves through stable identity claims and scoped machine secrets first, with the broad ADMIN_API_KEY retained only as a compatibility fallback. Self-hosted and preview deployments can pin admin access to immutable Supabase user IDs, to explicit role claims on app_metadata, or to dedicated per-automation machine keys, instead of relying on a single shared admin secret.

Three new environment variables drive the new paths, and all three are checked before ADMIN_API_KEY:

  • ADMIN_USER_IDS — comma-separated list of Supabase user UUIDs. The most robust option: a user's ID never changes when they rotate their email, get aliased through SSO, or move providers. Authorized users are matched case-insensitively against user.id on the active Supabase session.
  • ADMIN_ROLE_CLAIMS — comma-separated list of role names. Any of role, roles, admin_role, admin_roles, app_role, or app_roles on app_metadata (string or string array) can carry the claim. user_metadata is intentionally not trusted here, because it is commonly user-writable in Supabase and must not grant admin. Comparison is case-insensitive.
  • ADMIN_MACHINE_KEYS — comma-separated list of scoped machine secrets, presented in the X-API-Key or Authorization: Bearer … header just like the legacy ADMIN_API_KEY. Multiple secrets are supported so each automation, cron, or webhook signer can carry its own credential and be rotated independently. Comparison is case-sensitive to preserve full secret entropy and uses a constant-time hash compare.
# Preferred: pin admin to immutable Supabase user IDs
ADMIN_USER_IDS=00000000-0000-0000-0000-000000000001,00000000-0000-0000-0000-000000000002

# Or: authorize anyone whose JWT carries `role: "admin"` on app_metadata
ADMIN_ROLE_CLAIMS=admin,ops

# Per-automation machine secrets — one per caller, rotate independently
ADMIN_MACHINE_KEYS=svc-billing-webhook-…,svc-cron-runner-…

# Legacy fallback — still works, but prefer the scoped variables above
ADMIN_API_KEY=legacy-shared-secret

The order of precedence is ADMIN_USER_IDSADMIN_ROLE_CLAIMSADMIN_MACHINE_KEYSADMIN_API_KEY. If none of the four are configured, requireAuth continues to fail closed with 403 Admin access not configured. Authenticated requests that don't match any configured path return 401 Unauthorized. The per-IP rate limit on admin auth attempts is unchanged — 10 attempts per minute, returning 429 Too many auth attempts past the threshold — and the constant-time hash compare on machine secrets is preserved across both ADMIN_MACHINE_KEYS and the legacy ADMIN_API_KEY path.

No action is required if you only consume Overwatch through the public REST API with a regular X-API-Key; the admin paths only gate operator-level routes. Only operators of self-hosted and preview deployments need to set the new variables. Existing ADMIN_API_KEY configurations keep working unchanged — set ADMIN_USER_IDS, ADMIN_ROLE_CLAIMS, or ADMIN_MACHINE_KEYS alongside (or instead of) ADMIN_API_KEY when you want admin access tied to a real identity or split into per-caller machine credentials.

May 7, 2026LocusFix

Location names escaped in Locus free-report confirmation emails

The confirmation email sent to recipients of a free Location Report now HTML-escapes the geocoded place name before interpolating it into the email body, closing a path where a maliciously crafted Mapbox place_name could inject markup into the rendered confirmation message. The email subject line continues to render the place name as readable text with control characters stripped.

No user-facing behavior changes for legitimate report requests — place names render normally in both the subject and body. No action is required on your part.

May 7, 2026LayerFix

Provider authentication required to commit virtual-card authorizations

The virtual-card authorization endpoint now requires a provider-authenticated request before it will commit a balance change. Dashboard and session-based callers can still POST to the endpoint to evaluate an authorization against the configured spend limit, but the response now returns committed: false and the running spend total is left untouched. Only the card provider — currently Stripe Issuing — can drive a real commit by presenting the shared x-virtual-card-provider-secret header on the webhook callback.

This closes a path where an authenticated dashboard user could call the endpoint with commit: true and move the cumulative spend total without any provider-side authorization actually clearing. Read-only evaluation flows from the dashboard are unchanged. No action is required on your part — existing webhook subscriptions continue to work, and per-vendor spend caps, limit windows, and the audit log entries on virtual-card events are unaffected.

May 7, 2026LayerFix

Tightened production CSP on the Layer dashboard

Hardened the production Content-Security-Policy on the Layer dashboard'unsafe-eval' is no longer permitted in script-src outside local development. 'unsafe-inline' is retained in production so Next.js App Router hydration and RSC bootstrap scripts continue to run; without it, dashboard pages render as static HTML and client components — including the Continue with Google Workspace button — never hydrate. 'unsafe-eval' stays scoped to development builds so the local next dev workflow keeps working, while production responses now serve the stricter policy on every route.

The other security headers shipped alongside CSP — Strict-Transport-Security, X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, and a Permissions-Policy that disables camera, microphone, and geolocation — are unchanged.

No user-facing behavior changes. Existing integrations, billing, embedded video, and the support widget continue to work, and browsers benefit from the stricter script-src automatically on the next page load. No action is required on your part.

May 7, 2026LayerFix

Removed HSTS preload directive from Layer apps

Dropped the preload directive from the Strict-Transport-Security header on the Layer dashboard and the Rogue Stack app surface. The header now serves max-age=63072000; includeSubDomains — a two-year HTTPS-only commitment that still applies to the apex domain and every subdomain — without advertising readiness for the browser preload list.

The preload directive is a one-way promise: once a domain is submitted to the HSTS preload list, browsers refuse plaintext HTTP to it (and every subdomain) before the first request. Removing the directive in code is a prerequisite for stepping back out of that commitment cleanly, and lets us hold off on submission until every operational subdomain — including any internal tooling that may not yet terminate TLS — has been independently verified to serve HTTPS.

No browser-facing behavior changes for traffic that's already on HTTPS, which is every production route on Layer. Browsers still upgrade http:// requests for the next two years and continue to refuse mixed content. The directive can be re-added in a follow-up rollout once subdomain HTTPS coverage is confirmed end-to-end. No action is required on your part.

May 7, 2026LayerUpdate

Dashboard tables ignore nested controls and stay on in-range pages

Tables across the Layer dashboard — integrations, spend, access reviews, contracts, and friends — now feel right on the edges they used to bite. Clicking a checkbox, button, link, menu, dropdown, or other interactive control nested inside a row no longer also fires the row's primary navigation, so opening a row menu or selecting a row's checkbox stops short of also drilling into the row. Paginated tables also clamp to the last available page when filters or data changes would otherwise leave you stranded on an empty out-of-range page, so toggling a filter on a deep page lands you on real rows instead of a blank table.

No action is required on your part. The change applies automatically to every list view in the dashboard.

May 7, 2026LayerFix

Hardened post-login next redirect handling

The post-login next redirect on the Layer dashboard sign-in page and the Supabase OAuth callback now decode the parameter before validating it and reject anything that isn't a same-origin path. Encoded protocol-relative payloads (for example %2F%2Fexample.com), backslash-prefixed forms, and control characters all fall back to /dashboard instead of bouncing the browser to an attacker-controlled destination. The shared helper is reused by the email/password sign-in path, the Continue with Google Workspace button, and the Supabase OAuth callback, so all three flows enforce the same allowlist.

No user-facing behavior changes for legitimate sign-ins — same-origin next paths under / still resolve as before. No action is required on your part.

May 7, 2026LayerFix

Public API proxy matches only declared prefixes

The public API surface on the Layer dashboard now only matches a request when the path is an exact declared prefix or a child path beneath it, instead of any path that happens to share the same opening characters. Requests that previously slipped through on a coincidental string match — for example, a path that began with the same letters as a public API prefix but was a sibling rather than a child — now fall through to the authenticated dashboard surface as intended.

No user-facing behavior changes for legitimate API consumers; existing endpoints under /api/mcp, the discovery extension, and the rest of the public surface continue to work unchanged.

May 7, 2026LocusFix

Hardened Content-Security-Policy and Permissions-Policy on Locus

Extended the security-header pass that landed on the Locus marketing surface yesterday across the entire authenticated app at locus.axiomancer.io. The Content-Security-Policy now includes explicit clickjacking, base-tag, and form-action defenses (frame-ancestors 'none', base-uri 'self', object-src 'none', form-action 'self') and an allowlist tuned to the actual provider stack the Explorer, dashboard, and API console rely on — Supabase (REST and realtime), Mapbox tiles, Sentry, PostHog, Amplitude, Mixpanel, Intercom, Stripe Checkout, and Mux video. The obsolete X-XSS-Protection header has been removed; modern browsers ignore it and certain values can introduce their own bugs, so CSP is the canonical defense going forward.

Permissions-Policy now denies a much broader set of powerful browser APIs by default — accelerometer, ambient light, autoplay, battery, camera, display capture, encrypted media, geolocation, gyroscope, magnetometer, microphone, MIDI, payment, picture-in-picture, public-key credentials, screen wake lock, USB, web share, and XR spatial tracking — with fullscreen scoped to first-party only. None of these APIs were ever used by Locus features, so no in-app workflow is affected; the policy just makes the lack of access explicit at the browser level.

X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and Referrer-Policy: strict-origin-when-cross-origin continue to be served on every response. No user-facing behavior changes — the Explorer rails, neighborhood scoring panels, API console, Stripe checkout, Mapbox-rendered maps, and embedded video all work as before. Browsers benefit from the stricter policy automatically on the next page load. No action is required on your part.

May 7, 2026OverwatchFix

DB-level idempotency for AIS positions and port events

The Overwatch live database now enforces insert idempotency for ais_positions and port_events at the row level, so AIS workers and Edge Functions can retry batches after transient errors without producing duplicate rows. A BEFORE INSERT trigger on each table now skips an incoming row when a matching record already exists, rather than failing the insert with a unique-constraint violation.

Two stable identities are checked in order:

  • record_id — when the incoming row carries a non-empty stable APRS URN (the same record_id documented on the ports schema), an existing row with the same record_id causes the new insert to be silently skipped.
  • Natural key — for AIS positions, the tuple (source, imo_number, timestamp). For port events, the tuple (imo_number, port_id, event_type, timestamp). If a row with the same natural key is already present, the incoming row is skipped.

Concurrent inserts for the same identity are serialized with a transaction-scoped advisory lock taken before the duplicate probe, so two parallel workers cannot both pass the check before either row is visible. Existing indexes on ais_positions(record_id), ais_positions(imo_number, timestamp), ais_positions(source, timestamp), port_events(record_id), and port_events(port_id, imo_number, timestamp desc) already cover the duplicate probes — no new indexes are added by this change.

What this means for ingestion observability: the duplicate-key path that surfaced as a 23505 error in the VesselFinder ingestion partial example below will no longer appear in metadata.db_write_errors. Skipped duplicates are not failures — they are absorbed by the trigger and counted toward records_stored only on the first successful insert. Retry-driven re-runs of an AIS or port-events batch are now safe to call repeatedly without inflating row counts or generating spurious partial-status runs.

No user-facing behavior changes for public REST API consumers — vessel positions and port events continue to flow into the live database, and data retention windows are unaffected. No action is required on your part.

May 7, 2026OverwatchFix

VesselFinder ingestion surfaces per-batch DB write failures as partial

The Overwatch VesselFinder ingestion job now reports database write failures explicitly instead of swallowing them. Previously, if the vessels upsert or the ais_positions insert returned a Postgres error mid-run, the run would still complete with status: 'success' in ingestion_logs and records_stored would silently exclude the failed rows, leaving operators with no signal that anything was wrong.

Each failed batch is now captured with its operation, message, code, details, and hint, written to the ingestion_logs row in metadata.db_write_errors and joined into the error column. When at least one batch fails but others succeed, the run lands as status: 'partial' — matching the convention already used by the route-forecasts cron — and the HTTP response returns 207 Multi-Status with the same db_write_errors array in the body. Runs where no batches fail still return 200 with status: 'success', and runs that throw before any batch is attempted still return 500 with status: 'failed' and any partial errors collected up to that point preserved in metadata.db_write_errors.

{
  "fetched": 4820,
  "stored": 4612,
  "ports": 12,
  "status": "partial",
  "db_write_errors": [
    {
      "operation": "ais_positions insert",
      "message": "duplicate key value violates unique constraint",
      "code": "23505",
      "details": "Key (mmsi, timestamp) already exists.",
      "hint": null
    }
  ]
}

No user-facing behavior changes for public REST API consumers — vessel data continues to flow into the live database — and operators monitoring ingestion_logs gain visibility into transient batch failures that were previously invisible. No action is required on your part.

May 7, 2026OverwatchUpdate

Database-level idempotency for AIS positions and port events

Overwatch ingestion is now idempotent at the database layer for ais_positions and port_events. Duplicate INSERTs for the same row — whether driven by VesselFinder ingestion retries, historical backfill, or process-events re-runs — are now skipped silently by a BEFORE INSERT trigger instead of either succeeding twice or surfacing as duplicate key value violates unique constraint errors in ingestion_logs.

Each incoming row is matched against an existing row in two passes:

  • If record_id is non-empty, the trigger looks up that record_id and skips the new row when one already exists. record_id is the stable provenance identifier already documented on port_events (Stable APRS URN for deduplication and audit trails) and now governs duplicate suppression for AIS positions on the same basis.
  • Otherwise, the trigger falls back to a stable natural key. AIS positions dedupe on (source, imo_number, timestamp). Port events dedupe on (imo_number, port_id, event_type, timestamp).

A transaction-scoped advisory lock serializes the duplicate check on each identity, so two concurrent workers attempting to insert the same row cannot both pass the check before either row becomes visible. The check is bounded to the duplicate probe and released at commit, so unrelated writes are unaffected.

{
  "fetched": 4820,
  "stored": 4612,
  "ports": 12,
  "status": "success",
  "db_write_errors": []
}

For consumers of the public REST API, nothing changes — vessel and port-event reads return the same rows they did before, just without the rare duplicate that previously slipped through during retry storms. Operators monitoring ingestion_logs will see fewer duplicate key entries in the db_write_errors array introduced by today's VesselFinder ingestion fix, since duplicate inserts no longer reach the INSERT itself. No action is required on your part.

May 7, 2026OverwatchFix

River-level fetcher resolves port slugs to UUIDs before writing

The Overwatch river-levels ingestion job now resolves port slugs to real ports.id UUIDs before writing readings into port_weather, instead of stuffing the slug string into the port_id column. Previously, the port_weather upsert used r.port_slug directly as port_id, which silently failed the foreign-key relationship to ports and produced rows that no downstream join — port detail pages, alerting, congestion analytics — could resolve back to a real port.

The function now batches all distinct slugs from a run into a single select id, slug from ports where slug in (...), builds a slug→UUID map, and skips any reading whose slug doesn't resolve. Unresolved slugs are logged server-side and returned in a new missing_port_slugs array on the response so operators can see immediately when a slug in the source feed has drifted from the ports catalog. The original slug is preserved on each successfully written row in metadata.port_slug for traceability. The upsert key is unchanged (port_id, forecast_time), so re-running the job continues to deduplicate cleanly.

{
  "stored": 9,
  "alerts": ["MISS_LOWER: River: Lower Mississippi at 4.8 ft (low)"],
  "missing_port_slugs": ["new-orleans-temp"],
  "readings": [...]
}

No user-facing behavior changes for /api/v1/river-levels consumers — the public read endpoint already returns gauge readings directly and is unaffected. The fix improves the correctness of derived data in port_weather, so downstream port and congestion endpoints that join against river readings now return rows for the right ports. No action is required on your part.

May 7, 2026OverwatchFix

Email verification confirmation page escapes the recipient address

The Overwatch email-channel verification confirmation page at /api/v1/alerts/channels/email/verify now HTML-escapes the recipient email address before interpolating it into the rendered confirmation HTML. Both the "Already verified" and "Verified" pages run the stored email_to value through an entity escape for &, <, >, ", and ' before it lands in the response, closing a path where a maliciously crafted address persisted into alert_email_verifications could inject markup into the page rendered to the verifying recipient.

Legitimate addresses render normally — alice@example.com continues to display as plain text in bold — and the magic-link verification flow itself is unchanged: recipients still click through from the verification email, land on the confirmation page, and have the corresponding email channel marked active. No action is required on your part.

May 7, 2026OverwatchFix

Sentry PII and server-side local variables disabled outside development

Overwatch Sentry initialization across the client, edge, and server runtimes no longer captures personally identifiable information by default in production. sendDefaultPii is now false on all three Sentry clients — previously the Next.js wizard default of true meant Sentry would attach IP addresses, request headers, cookies, and user-context fields to every event. Server-side includeLocalVariables is also now scoped to NODE_ENV === 'development', so production stack frames no longer carry the surrounding local-variable snapshot that could include API keys, query parameters, or per-request secrets read into local scope.

Error reporting itself is unchanged: production exceptions still flow to Sentry with full stack traces, request URLs, and the existing enableLogs: true console forwarding for middleware and edge-runtime errors. Trace and replay sample rates (tracesSampleRate, replaysSessionSampleRate, replaysOnErrorSampleRate) are unchanged. Local development still receives full PII and local-variable snapshots so debugging in a next dev run keeps working exactly as before.

No user-facing behavior changes. Operators of self-hosted and preview deployments who relied on PII fields in Sentry events for incident triage can opt in per-environment by setting sendDefaultPii: true in their own Sentry init overrides — but the default for production is now privacy-preserving. No action is required on your part.

May 7, 2026LayerFix
May 7, 2026LayerFix

Provider authentication required for virtual card authorization commits

Tightened the virtual card authorization endpoint so that only the card provider — not ordinary dashboard or session users — can commit authorization rollups against a card's hard limit. Requests from authenticated users without the provider secret now run as a read-only evaluation: the response still returns the approval decision and remaining balance, but committed is false and the spend rollup is left untouched.

To commit a rollup, the caller must pass a matching shared secret in the x-virtual-card-provider-secret header. Layer looks up the secret per provider (for example, VIRTUAL_CARD_AUTH_STRIPE_ISSUING_SECRET for stripe_issuing) and falls back to VIRTUAL_CARD_AUTH_WEBHOOK_SECRET or the existing STRIPE_ISSUING_WEBHOOK_SECRET. Comparison is constant-time. If no secret is configured for the provider, commits are rejected with 403 Provider authentication required for committed authorizations. Existing webhook deliveries from your card provider continue to work as long as the matching secret is set in your environment — no dashboard changes are required.

On this page