diff --git a/.cursor/commands/fix-bug.md b/.cursor/commands/fix-bug.md
new file mode 100644
index 0000000000000..949008dc3c3e5
--- /dev/null
+++ b/.cursor/commands/fix-bug.md
@@ -0,0 +1,85 @@
+# fix-bug
+
+$ARGUMENTS
+
+---
+
+## **Mission Briefing: Root Cause Analysis & Remediation Protocol**
+
+Previous, simpler attempts to resolve this issue have failed. Standard procedures are now suspended. You will initiate a **deep diagnostic protocol.**
+
+Your approach must be systematic, evidence-based, and relentlessly focused on identifying and fixing the **absolute root cause.** Patching symptoms is a critical failure.
+
+---
+
+## **Phase 0: Reconnaissance & State Baseline (Read-Only)**
+
+- **Directive:** Perform a non-destructive scan of the repository, runtime environment, and configurations. Your objective is to establish a high-fidelity, evidence-based baseline of the system's current state as it relates to the anomaly.
+- **Output:** Produce a concise digest of your findings.
+- **Constraint:** **No mutations are permitted during this phase.**
+
+---
+
+## **Phase 1: Isolate the Anomaly**
+
+- **Directive:** Your first and most critical goal is to create a **minimal, reproducible test case** that reliably and predictably triggers the bug.
+- **Actions:**
+ 1. **Define Correctness:** Clearly state the expected, non-buggy behavior.
+ 2. **Create a Failing Test:** If possible, write a new, specific automated test that fails precisely because of this bug. This test will become your signal for success.
+ 3. **Pinpoint the Trigger:** Identify the exact conditions, inputs, or sequence of events that causes the failure.
+- **Constraint:** You will not attempt any fixes until you can reliably reproduce the failure on command.
+
+---
+
+## **Phase 2: Root Cause Analysis (RCA)**
+
+- **Directive:** With a reproducible failure, you will now methodically investigate the failing pathway to find the definitive root cause.
+- **Evidence-Gathering Protocol:**
+ 1. **Formulate a Testable Hypothesis:** State a clear, simple theory about the cause.
+ 2. **Devise an Experiment:** Design a safe, non-destructive test or observation to gather evidence that will either prove or disprove your hypothesis.
+ 3. **Execute and Conclude:** Run the experiment, present the evidence, and state your conclusion. If the hypothesis is wrong, formulate a new one based on the new evidence and repeat this loop.
+- **Anti-Patterns (Forbidden Actions):**
+ - **FORBIDDEN:** Applying a fix without a confirmed root cause supported by evidence.
+ - **FORBIDDEN:** Re-trying a previously failed fix without new data.
+ - **FORBIDDEN:** Patching a symptom (e.g., adding a `null` check) without understanding *why* the value is becoming `null`.
+
+---
+
+## **Phase 3: Remediation**
+
+- **Directive:** Design and implement a minimal, precise fix that durably hardens the system against the confirmed root cause.
+- **Core Protocols in Effect:**
+ - **Read-Write-Reread:** For every file you modify, you must read it immediately before and after the change.
+ - **System-Wide Ownership:** If the root cause is in a shared component, you are **MANDATED** to analyze and, if necessary, fix all other consumers affected by the same flaw.
+
+---
+
+## **Phase 4: Verification & Regression Guard**
+
+- **Directive:** Prove that your fix has resolved the issue without creating new ones.
+- **Verification Steps:**
+ 1. **Confirm the Fix:** Re-run the specific failing test case from Phase 1. It **MUST** now pass.
+ 2. **Run Full Quality Gates:** Execute the entire suite of relevant tests and linters to ensure no regressions have been introduced elsewhere.
+ 3. **Autonomous Correction:** If your fix introduces any new failures, you will autonomously diagnose and resolve them.
+
+---
+
+## **Phase 5: Mandatory Zero-Trust Self-Audit**
+
+- **Directive:** Your remediation is complete, but your work is **NOT DONE.** You will now conduct a skeptical, zero-trust audit of your own fix.
+- **Audit Protocol:**
+ 1. **Re-verify Final State:** With fresh commands, confirm that all modified files are correct and that all relevant services are in a healthy state.
+ 2. **Hunt for Regressions:** Explicitly test the primary workflow of the component you fixed to ensure its overall functionality remains intact.
+
+---
+
+## **Phase 6: Final Report & Verdict**
+
+- **Directive:** Conclude your mission with a structured "After-Action Report."
+- **Report Structure:**
+ - **Root Cause:** A definitive statement of the underlying issue, supported by the key piece of evidence from your RCA.
+ - **Remediation:** A list of all changes applied to fix the issue.
+ - **Verification Evidence:** Proof that the original bug is fixed and that no new regressions were introduced.
+ - **Final Verdict:** Conclude with one of:
+ - `"Self-Audit Complete. Root cause has been addressed, and system state is verified. No regressions identified. Mission accomplished."`
+ - `"Self-Audit Complete. CRITICAL ISSUE FOUND during audit. Halting work. [Describe issue and recommend immediate diagnostic steps]."`
diff --git a/.cursor/commands/request.md b/.cursor/commands/request.md
new file mode 100644
index 0000000000000..3efb627c1d4bb
--- /dev/null
+++ b/.cursor/commands/request.md
@@ -0,0 +1,111 @@
+# request
+
+$ARGUMENTS
+
+---
+
+You are working on a large production codebase. Your job is to behave like a disciplined senior engineer, not an autocomplete tool.
+
+Follow these rules strictly:
+
+## Core operating mode
+- Do NOT start coding immediately.
+- First, read only the files required to understand the task.
+- Keep the active working set small and relevant.
+- Prefer minimal, local, reversible changes.
+- Never make broad speculative refactors.
+- Never invent requirements, APIs, behaviors, or architecture.
+- If something is unclear, identify the uncertainty explicitly and infer the safest narrow assumption from the codebase.
+- Do not optimize for cleverness. Optimize for correctness, maintainability, and predictability.
+
+## Scope control
+Treat the change-set as the unit of work, not the whole repository.
+For each task:
+- Work on one bounded feature, bug, or refactor slice only.
+- Touch as few files as possible.
+- Avoid unrelated cleanup.
+- Do not expand scope unless required by compilation, tests, or an explicit dependency.
+
+## Required workflow
+For every non-trivial task, follow this exact sequence:
+
+### Phase 1 — Understanding
+Read the relevant files and then output:
+
+1. Scope
+2. Current behavior
+3. Desired behavior
+4. Files that must be changed
+5. Files that should not be changed
+6. Invariants that must remain true
+7. Assumptions
+8. Unknowns / risks
+9. Validation plan
+10. Step-by-step implementation plan
+
+Do not code yet.
+
+### Phase 2 — Implementation
+After the plan is complete:
+- Execute only the approved plan
+- Keep functions small and explicit
+- Preserve module boundaries
+- Reuse existing patterns from nearby code
+- Avoid introducing new abstractions unless clearly justified
+- If a better design is tempting but not necessary, do not do it
+
+### Phase 3 — Validation
+After coding:
+- Run formatting
+- Run linting / static checks
+- Run the narrowest relevant tests first
+- Run broader tests only if needed
+- Report exactly what passed, what failed, and why
+
+## Architecture discipline
+Respect the existing architecture unless the task explicitly changes it.
+When editing a module, preserve:
+- its responsibility
+- its public contract
+- ownership boundaries
+- concurrency / async assumptions
+- error handling conventions
+- persistence and serialization rules
+
+If changing any interface or contract:
+- identify all affected callers
+- update them consistently
+- add or update tests for the contract
+
+## Rust-specific rules
+When working in Rust:
+- Prefer explicitness over magic
+- Keep ownership and lifetimes simple
+- Avoid unnecessary generics or trait indirection
+- Keep async boundaries clear
+- Do not hide stateful behavior
+- Use typed errors consistently with existing project patterns
+- Be careful with Arc/Mutex/RwLock/channel usage
+- Check for race conditions, deadlocks, duplicate side effects, and retry/idempotency issues
+
+## Quality bar
+Every change must be:
+- understandable by another engineer
+- locally testable
+- easy to review
+- consistent with the rest of the codebase
+
+## Required output format before coding
+Return exactly these sections:
+
+### Scope
+### Relevant files
+### Current behavior
+### Target behavior
+### Invariants
+### Assumptions
+### Risks / unknowns
+### Validation plan
+### Implementation plan
+
+If the task is ambiguous, do not guess broadly. Constrain scope, state assumptions, and proceed safely.
\ No newline at end of file
diff --git a/.cursor/commands/review.md b/.cursor/commands/review.md
new file mode 100644
index 0000000000000..6e10e8923677c
--- /dev/null
+++ b/.cursor/commands/review.md
@@ -0,0 +1,30 @@
+# review
+
+Review all changes made today across all benches, modules, and packages with a strict second-pass audit.
+
+Your tasks:
+1. Inspect every changed area for:
+ - unused, dead, obsolete, disabled, or partially removed code
+ - missing cleanup after refactors
+ - broken or incomplete integrations
+ - missing edge-case handling
+ - regressions caused by the recent changes
+
+2. Remove any code that is no longer needed.
+
+3. Check unit tests across all affected areas:
+ - add missing tests required by the new changes
+ - fix tests that are now broken
+ - remove or update outdated tests
+ - verify the test suite still matches the intended behavior
+
+4. Review the main architecture files and every package/module Architecture file:
+ - update any file that is outdated
+ - ensure documentation matches the current implementation exactly
+ - fix inconsistencies between architecture docs and code
+
+Execution rules:
+- Do not stop at the first pass.
+- After making fixes, restart the review from the beginning and re-check everything again.
+- Continue iterating until there is nothing left to fix, clean up, update, or align.
+- Stop only when the code, tests, and architecture documentation are all fully consistent and complete.
\ No newline at end of file
diff --git a/.cursor/commands/security-audit.md b/.cursor/commands/security-audit.md
new file mode 100644
index 0000000000000..6109255d0b839
--- /dev/null
+++ b/.cursor/commands/security-audit.md
@@ -0,0 +1,121 @@
+# /security-audit
+
+$ARGUMENTS
+
+---
+
+## Mission: Adversarial Security Review
+
+You are a principal engineer and adversarial reviewer. Review the changes with a hostile mindset, assuming there is a bug, exploit, race condition, or abuse path unless proven otherwise.
+
+Your job is to find what the implementer missed.
+
+---
+
+## Scope
+
+- Review the current task changes AND impacted flows, dependencies, and interfaces.
+- Consider the full stack: TypeScript, React Native, Node.js, APIs, database, caches, workers, third-party integrations, crypto/wallet operations.
+- Be strict, skeptical, and specific. No generic praise. No feature summaries unless needed for a finding.
+
+---
+
+## Audit Categories
+
+### 1. Functional Correctness
+- Logic bugs, broken edge cases, invalid assumptions
+- Bad state transitions, off-by-one / nil / undefined issues
+- Numeric precision issues (especially with token amounts)
+- Timezone / date issues, pagination / ordering issues
+- Schema mismatch issues, frontend/backend contract drift
+
+### 2. Concurrency & State Safety
+- Race conditions, deadlocks, lost updates
+- Double-spend / double-submit, duplicate event handling
+- Eventual consistency gaps, stale cache reads
+- Non-atomic writes, unsafe async behavior
+- Relay event ordering issues
+
+### 3. Security
+- Auth bypass, broken authorization / tenant isolation, IDOR
+- Injection (SQL, NoSQL, command, template, HTML, JS, prompt, log)
+- XSS / CSRF / CORS issues
+- Insecure secret handling, weak crypto or signature validation
+- Replay attacks, insecure randomness, unsafe deserialization
+- Rate limit bypass, abuse of public endpoints
+- Wallet/signature validation flaws
+
+### 4. Reliability & Operations
+- Missing retries, retry storms, no timeout / bad timeout
+- No backoff / jitter, missing circuit breaker
+- Idempotency gaps, duplicate side effects
+- Unhandled errors, silent failures, resource leaks
+- Poor observability / missing logs / missing metrics
+- Backwards compatibility issues
+
+### 5. Billing, Credits & Abuse
+- Free usage bypass, double credit spending
+- Negative balance paths, rounding exploits
+- Referral / rewards abuse, replayed reward claims
+- Race conditions in balance updates
+- Webhook forgery, unpaid resource consumption
+
+### 6. Performance
+- N+1 queries, unbounded loops, unbounded memory growth
+- Blocking operations on hot paths, poor indexing
+- Excessive locking, repeated RPC / DB calls
+- Large payload issues, frontend rendering bottlenecks
+
+---
+
+## Output Format
+
+For every finding:
+- **Title**
+- **Severity:** Critical / High / Medium / Low
+- **Why it matters**
+- **Exact vulnerable flow or code pattern**
+- **Exact fix**
+- **Test to add**
+- **Exploit scenario** (if relevant)
+
+---
+
+## If No Issues Found
+
+Do NOT stop at "looks good." Return:
+- Residual risks
+- What was checked
+- Hardening improvements
+- Tests still worth adding
+
+---
+
+## Mandatory Checklist
+
+- [ ] Inputs validated and normalized?
+- [ ] Auth/authz enforced server-side?
+- [ ] Tenant boundaries in every read/write?
+- [ ] All external calls have timeout, retry, idempotency?
+- [ ] State changes atomic where needed?
+- [ ] Duplicate requests/events safe?
+- [ ] Secrets excluded from code, logs, responses?
+- [ ] Logs useful but sanitized?
+- [ ] Balances/credits updated exactly once?
+- [ ] Frontend does not trust client-side checks?
+- [ ] Token math safe for edge values (string amounts)?
+- [ ] Failure modes observable and recoverable?
+
+---
+
+## Final Output
+
+1. **Merge decision:** Safe to merge / Safe with follow-ups / Do not merge
+2. **Top 3 must-fix items**
+3. **Tests missing before production**
+
+## Execution rules:
+- Do not stop at the first pass.
+- After making fixes, restart the review from the beginning and re-check everything again.
+- Continue iterating until there is nothing left to fix, clean up, update, or align.
+- Stop only when the code, tests, and architecture documentation are all fully consistent and complete.
\ No newline at end of file
diff --git a/.cursor/rules/karpathy-guidelines.mdc b/.cursor/rules/karpathy-guidelines.mdc
new file mode 100644
index 0000000000000..edd317f72af15
--- /dev/null
+++ b/.cursor/rules/karpathy-guidelines.mdc
@@ -0,0 +1,70 @@
+---
+description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.
+alwaysApply: true
+---
+
+# Karpathy behavioral guidelines
+
+Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
+
+**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
+
+## 1. Think Before Coding
+
+**Don't assume. Don't hide confusion. Surface tradeoffs.**
+
+Before implementing:
+- State your assumptions explicitly. If uncertain, ask.
+- If multiple interpretations exist, present them - don't pick silently.
+- If a simpler approach exists, say so. Push back when warranted.
+- If something is unclear, stop. Name what's confusing. Ask.
+
+## 2. Simplicity First
+
+**Minimum code that solves the problem. Nothing speculative.**
+
+- No features beyond what was asked.
+- No abstractions for single-use code.
+- No "flexibility" or "configurability" that wasn't requested.
+- No error handling for impossible scenarios.
+- If you write 200 lines and it could be 50, rewrite it.
+
+Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
+
+## 3. Surgical Changes
+
+**Touch only what you must. Clean up only your own mess.**
+
+When editing existing code:
+- Don't "improve" adjacent code, comments, or formatting.
+- Don't refactor things that aren't broken.
+- Match existing style, even if you'd do it differently.
+- If you notice unrelated dead code, mention it - don't delete it.
+
+When your changes create orphans:
+- Remove imports/variables/functions that YOUR changes made unused.
+- Don't remove pre-existing dead code unless asked.
+
+The test: Every changed line should trace directly to the user's request.
+
+## 4. Goal-Driven Execution
+
+**Define success criteria. Loop until verified.**
+
+Transform tasks into verifiable goals:
+- "Add validation" → "Write tests for invalid inputs, then make them pass"
+- "Fix the bug" → "Write a test that reproduces it, then make it pass"
+- "Refactor X" → "Ensure tests pass before and after"
+
+For multi-step tasks, state a brief plan:
+```
+1. [Step] → verify: [check]
+2. [Step] → verify: [check]
+3. [Step] → verify: [check]
+```
+
+Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
+
+---
+
+**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
diff --git a/.cursor/skills/babysit/SKILL.md b/.cursor/skills/babysit/SKILL.md
new file mode 100644
index 0000000000000..e8884f89f6497
--- /dev/null
+++ b/.cursor/skills/babysit/SKILL.md
@@ -0,0 +1,14 @@
+---
+name: babysit
+description: >-
+ Keep a PR merge-ready by triaging comments, resolving clear conflicts, and
+ fixing CI in a loop.
+---
+# Babysit PR
+Your job is to get this PR to a merge-ready state.
+
+Check PR status, comments, and latest CI and resolve any issues until the PR is ready to merge.
+
+1. Comments: Review every comment (including Bugbot) before acting. Fix only comments you agree with; explain when you disagree or are unsure.
+2. Merge conflicts: When there are conflicts, sync with base branch. Resolve merge conflicts only when intent is clearly the same, otherwise stop and ask for clarification.
+3. CI: Fix CI issues that come up with small scoped fixes. Push them and re-watch CI until mergeable + green + comments triaged.
diff --git a/.cursor/skills/create-hook/SKILL.md b/.cursor/skills/create-hook/SKILL.md
new file mode 100644
index 0000000000000..f519075a71980
--- /dev/null
+++ b/.cursor/skills/create-hook/SKILL.md
@@ -0,0 +1,239 @@
+---
+name: create-hook
+description: >-
+ Create Cursor hooks. Use when you want to create a hook, write hooks.json, add
+ hook scripts, or automate behavior around agent events.
+---
+# Creating Cursor Hooks
+
+Create hooks when you want Cursor to run custom logic before or after agent events. Hooks are scripts or prompt-based checks that exchange JSON over stdin/stdout and can observe, block, modify, or follow up on behavior.
+
+When the user asks for a hook, don't stop at describing the format. Gather the missing requirements, then create or update the hook files directly.
+
+## Gather Requirements
+
+Before you write anything, determine:
+
+1. **Scope**: Should this be a project hook or a user hook?
+2. **Trigger**: Which event should run the hook?
+3. **Behavior**: Should it audit, deny/allow, rewrite input, inject context, or continue a workflow?
+4. **Implementation**: Should it be a command hook (script) or a prompt hook?
+5. **Filtering**: Does it need a matcher so it only runs for certain tools, commands, or subagent types?
+6. **Safety**: Should failures fail open or fail closed?
+
+Infer these from the conversation when possible. Only ask for the missing pieces.
+
+## Choose the Right Location
+
+- **Project hooks**: `.cursor/hooks.json` and `.cursor/hooks/*`
+- **User hooks**: `~/.cursor/hooks.json` and `~/.cursor/hooks/*`
+
+Path behavior matters:
+
+- **Project hooks** run from the project root, so use paths like `.cursor/hooks/my-hook.sh`
+- **User hooks** run from `~/.cursor/`, so use paths like `./hooks/my-hook.sh` or `hooks/my-hook.sh`
+
+Prefer **project hooks** when the behavior should be shared with the repository and checked into version control.
+
+## Choose the Hook Event
+
+Use the narrowest event that matches the user's goal.
+
+### Common Agent events
+
+- `sessionStart`, `sessionEnd`: set up or audit a session
+- `preToolUse`, `postToolUse`, `postToolUseFailure`: work across all tools
+- `subagentStart`, `subagentStop`: control or continue Task/subagent workflows
+- `beforeShellExecution`, `afterShellExecution`: gate or audit terminal commands
+- `beforeMCPExecution`, `afterMCPExecution`: gate or audit MCP tool calls
+- `beforeReadFile`, `afterFileEdit`: control file reads or post-process edits
+- `beforeSubmitPrompt`: validate prompts before they are sent
+- `preCompact`: observe context compaction
+- `stop`: handle agent completion
+- `afterAgentResponse`, `afterAgentThought`: track agent output or reasoning
+
+### Tab events
+
+- `beforeTabFileRead`: control file access for inline completions
+- `afterTabFileEdit`: post-process edits made by Tab
+
+### Quick event chooser
+
+- **Block or approve shell commands** -> `beforeShellExecution`
+- **Audit shell output** -> `afterShellExecution`
+- **Format files after edits** -> `afterFileEdit`
+- **Block or rewrite a specific tool call** -> `preToolUse`
+- **Add follow-up context after a tool succeeds** -> `postToolUse`
+- **Control whether subagents can run** -> `subagentStart`
+- **Chain subagent loops** -> `subagentStop`
+- **Check prompts for secrets or policy violations** -> `beforeSubmitPrompt`
+- **Protect MCP calls** -> `beforeMCPExecution`
+
+## Hooks File Format
+
+Create a `hooks.json` file with schema version 1:
+
+```json
+{
+ "version": 1,
+ "hooks": {
+ "afterFileEdit": [
+ {
+ "command": ".cursor/hooks/format.sh"
+ }
+ ]
+ }
+}
+```
+
+Each hook definition can include:
+
+- `command`: shell command or script path
+- `type`: `"command"` or `"prompt"` (defaults to `"command"`)
+- `timeout`: timeout in seconds
+- `matcher`: filter for when the hook runs
+- `failClosed`: block the action when the hook crashes, times out, or returns invalid JSON
+- `loop_limit`: mainly for `stop` and `subagentStop` follow-up loops
+
+## Matchers
+
+Use matchers to avoid running the hook on every event.
+
+- `preToolUse` / `postToolUse` / `postToolUseFailure`: match on tool type such as `Shell`, `Read`, `Write`, `Task`, or MCP tools in `MCP: ...` form
+- `subagentStart` / `subagentStop`: match on subagent type such as `generalPurpose`, `explore`, or `shell`
+- `beforeShellExecution` / `afterShellExecution`: match on the full shell command string
+- `beforeReadFile`: match on tool type such as `Read` or `TabRead`
+- `afterFileEdit`: match on tool type such as `Write` or `TabWrite`
+- `beforeSubmitPrompt`: matches the value `UserPromptSubmit`
+
+Important matcher warning:
+
+- Matchers use JavaScript-style regular expressions, not POSIX/grep syntax
+- Do not use POSIX classes like `[[:space:]]`; use JavaScript equivalents like `\s`
+- If the matcher is at all tricky, start by getting the hook working without one or with a very simple matcher, then tighten it after the hook is confirmed to load and fire
+
+If the user wants a hook for only one risky command family, prefer script-side filtering for the first working version and add a matcher afterward only if it is simple and clearly correct.
+
+## Command Hooks
+
+Command hooks are the default. They receive JSON on stdin and can return JSON on stdout.
+
+Before using a command hook, verify that every executable it depends on will actually run in the hook environment:
+
+- the script itself has a valid shebang and is executable
+- any helper binary it calls is already installed and on `$PATH`
+- if the script depends on tools like `jq`, `python3`, `node`, or repo-local CLIs, verify that explicitly before finishing
+
+Do not assume a binary exists just because it is common on your machine.
+
+### Minimal project-level example
+
+```json
+{
+ "version": 1,
+ "hooks": {
+ "beforeShellExecution": [
+ {
+ "command": ".cursor/hooks/approve-network.sh",
+ "matcher": "curl|wget|nc ",
+ "failClosed": true
+ }
+ ]
+ }
+}
+```
+
+```bash
+#!/bin/bash
+input=$(cat)
+command=$(echo "$input" | jq -r '.command // empty')
+
+if [[ "$command" =~ curl|wget|nc ]]; then
+ echo '{
+ "permission": "ask",
+ "user_message": "This command may make a network request. Please review it before continuing.",
+ "agent_message": "A hook flagged this shell command as a possible network call."
+ }'
+ exit 0
+fi
+
+echo '{ "permission": "allow" }'
+exit 0
+```
+
+Important behavior:
+
+- Exit code `0`: success
+- Exit code `2`: block the action, same as returning deny
+- Other non-zero exit codes: fail open by default unless `failClosed: true`
+
+Always make hook scripts executable after creating them.
+
+## Prompt Hooks
+
+Prompt hooks are useful when the policy is easier to describe than to script.
+
+```json
+{
+ "version": 1,
+ "hooks": {
+ "beforeShellExecution": [
+ {
+ "type": "prompt",
+ "prompt": "Does this command look safe to execute? Only allow read-only operations. Here is the hook input: $ARGUMENTS",
+ "timeout": 10
+ }
+ ]
+ }
+}
+```
+
+Use prompt hooks for lightweight policy decisions. Prefer command hooks when the logic must be deterministic or when the user needs exact, auditable behavior.
+
+## Event Output Cheat Sheet
+
+Use the event's supported output fields only.
+
+- `preToolUse`: can return `permission`, `user_message`, `agent_message`, and `updated_input`
+- `postToolUse`: can return `additional_context`; for MCP tools it can also return `updated_mcp_tool_output`
+- `subagentStart`: can return `permission` and `user_message`
+- `subagentStop`: can return `followup_message`
+- `beforeShellExecution` / `beforeMCPExecution`: can return `permission`, `user_message`, and `agent_message`
+
+When the user wants to rewrite a tool call, prefer `preToolUse`. When they want to gate only shell commands, prefer `beforeShellExecution`.
+
+## Implementation Workflow
+
+1. Pick the correct location and event
+2. Create or update the correct `hooks.json` file
+3. Start with no matcher or the simplest safe matcher
+4. Create the script under the matching hooks directory
+5. Read stdin JSON and implement the required behavior
+6. Make the script executable
+7. Verify any helper executables the script uses are installed and on `$PATH`
+8. Trigger the relevant action to test the hook
+9. Verify behavior in Cursor's **Hooks** settings tab or the **Hooks** output channel
+
+If you are editing an existing hooks setup, preserve unrelated hooks and only change the minimum necessary entries.
+
+## Validation and Troubleshooting
+
+- Cursor watches `hooks.json` and reloads on save
+- If hooks still do not load, restart Cursor
+- Double-check relative paths:
+ - project hooks -> relative to the project root
+ - user hooks -> relative to `~/.cursor/`
+- If the hook does not appear to load at all, suspect matcher/config parsing first; remove the matcher and confirm the base hook works before tightening it
+- If the script runs external commands, verify each one is installed and reachable from the hook process with `command -v` or equivalent
+- If the hook should block on failure, set `failClosed: true`
+- If a command hook should intentionally block, returning exit code `2` is valid
+
+## Final Checklist
+
+- [ ] Used the correct hook location and path style
+- [ ] Chose the narrowest correct event
+- [ ] Added a matcher when appropriate
+- [ ] Returned only fields supported by that hook event
+- [ ] Made the script executable
+- [ ] Tested the hook by triggering the real event
+- [ ] Checked the Hooks tab or Hooks output channel if debugging was needed
diff --git a/.cursor/skills/create-rule/SKILL.md b/.cursor/skills/create-rule/SKILL.md
new file mode 100644
index 0000000000000..baa87c723ae5d
--- /dev/null
+++ b/.cursor/skills/create-rule/SKILL.md
@@ -0,0 +1,164 @@
+---
+name: create-rule
+description: >-
+ Create Cursor rules for persistent AI guidance. Use when you want to create a
+ rule, add coding standards, set up project conventions, configure
+ file-specific patterns, create RULE.md files, or asks about .cursor/rules/ or
+ AGENTS.md.
+---
+# Creating Cursor Rules
+
+Create project rules in `.cursor/rules/` to provide persistent context for the AI agent.
+
+## Gather Requirements
+
+Before creating a rule, determine:
+
+1. **Purpose**: What should this rule enforce or teach?
+2. **Scope**: Should it always apply, or only for specific files?
+3. **File patterns**: If file-specific, which glob patterns?
+
+### Inferring from Context
+
+If you have previous conversation context, infer rules from what was discussed. You can create multiple rules if the conversation covers distinct topics or patterns. Don't ask redundant questions if the context already provides the answers.
+
+### Required Questions
+
+If the user hasn't specified scope, ask:
+- "Should this rule always apply, or only when working with specific files?"
+
+If they mentioned specific files and haven't provided concrete patterns, ask:
+- "Which file patterns should this rule apply to?" (e.g., `**/*.ts`, `backend/**/*.py`)
+
+It's very important that we get clarity on the file patterns.
+
+Use the AskQuestion tool when available to gather this efficiently.
+
+---
+
+## Rule File Format
+
+Rules are `.mdc` files in `.cursor/rules/` with YAML frontmatter:
+
+```
+.cursor/rules/
+ typescript-standards.mdc
+ react-patterns.mdc
+ api-conventions.mdc
+```
+
+### File Structure
+
+```markdown
+---
+description: Brief description of what this rule does
+globs: **/*.ts # File pattern for file-specific rules
+alwaysApply: false # Set to true if rule should always apply
+---
+
+# Rule Title
+
+Your rule content here...
+```
+
+### Frontmatter Fields
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `description` | string | What the rule does (shown in rule picker) |
+| `globs` | string | File pattern - rule applies when matching files are open |
+| `alwaysApply` | boolean | If true, applies to every session |
+
+---
+
+## Rule Configurations
+
+### Always Apply
+
+For universal standards that should apply to every conversation:
+
+```yaml
+---
+description: Core coding standards for the project
+alwaysApply: true
+---
+```
+
+### Apply to Specific Files
+
+For rules that apply when working with certain file types:
+
+```yaml
+---
+description: TypeScript conventions for this project
+globs: **/*.ts
+alwaysApply: false
+---
+```
+
+---
+
+## Best Practices
+
+### Keep Rules Concise
+
+- **Under 50 lines**: Rules should be concise and to the point
+- **One concern per rule**: Split large rules into focused pieces
+- **Actionable**: Write like clear internal docs
+- **Concrete examples**: Ideally provide concrete examples of how to fix issues
+
+---
+
+## Example Rules
+
+### TypeScript Standards
+
+```markdown
+---
+description: TypeScript coding standards
+globs: **/*.ts
+alwaysApply: false
+---
+
+# Error Handling
+
+\`\`\`typescript
+// ❌ BAD
+try {
+ await fetchData();
+} catch (e) {}
+
+// ✅ GOOD
+try {
+ await fetchData();
+} catch (e) {
+ logger.error('Failed to fetch', { error: e });
+ throw new DataFetchError('Unable to retrieve data', { cause: e });
+}
+\`\`\`
+```
+
+### React Patterns
+
+```markdown
+---
+description: React component patterns
+globs: **/*.tsx
+alwaysApply: false
+---
+
+# React Patterns
+
+- Use functional components
+- Extract custom hooks for reusable logic
+- Colocate styles with components
+```
+
+---
+
+## Checklist
+
+- [ ] File is `.mdc` format in `.cursor/rules/`
+- [ ] Frontmatter configured correctly
+- [ ] Content under 500 lines
+- [ ] Includes concrete examples
diff --git a/.cursor/skills/create-skill/SKILL.md b/.cursor/skills/create-skill/SKILL.md
new file mode 100644
index 0000000000000..25d82cde52065
--- /dev/null
+++ b/.cursor/skills/create-skill/SKILL.md
@@ -0,0 +1,498 @@
+---
+name: create-skill
+description: >-
+ Guides users through creating effective Agent Skills for Cursor. Use when you
+ want to create, write, or author a new skill, or asks about skill structure,
+ best practices, or SKILL.md format.
+---
+# Creating Skills in Cursor
+
+This skill guides you through creating effective Agent Skills for Cursor. Skills are markdown files that teach the agent how to perform specific tasks: reviewing PRs using team standards, generating commit messages in a preferred format, querying database schemas, or any specialized workflow.
+
+## Before You Begin: Gather Requirements
+
+Before creating a skill, gather essential information from the user about:
+
+1. **Purpose and scope**: What specific task or workflow should this skill help with?
+2. **Target location**: Should this be a personal skill (~/.cursor/skills/) or project skill (.cursor/skills/)?
+3. **Trigger scenarios**: When should the agent automatically apply this skill?
+4. **Key domain knowledge**: What specialized information does the agent need that it wouldn't already know?
+5. **Output format preferences**: Are there specific templates, formats, or styles required?
+6. **Existing patterns**: Are there existing examples or conventions to follow?
+
+### Inferring from Context
+
+If you have previous conversation context, infer the skill from what was discussed. You can create skills based on workflows, patterns, or domain knowledge that emerged in the conversation.
+
+### Gathering Additional Information
+
+If you need clarification, use the AskQuestion tool when available:
+
+```
+Example AskQuestion usage:
+- "Where should this skill be stored?" with options like ["Personal (~/.cursor/skills/)", "Project (.cursor/skills/)"]
+- "Should this skill include executable scripts?" with options like ["Yes", "No"]
+```
+
+If the AskQuestion tool is not available, ask these questions conversationally.
+
+---
+
+## Skill File Structure
+
+### Directory Layout
+
+Skills are stored as directories containing a `SKILL.md` file:
+
+```
+skill-name/
+├── SKILL.md # Required - main instructions
+├── reference.md # Optional - detailed documentation
+├── examples.md # Optional - usage examples
+└── scripts/ # Optional - utility scripts
+ ├── validate.py
+ └── helper.sh
+```
+
+### Storage Locations
+
+| Type | Path | Scope |
+|------|------|-------|
+| Personal | ~/.cursor/skills/skill-name/ | Available across all your projects |
+| Project | .cursor/skills/skill-name/ | Shared with anyone using the repository |
+
+**IMPORTANT**: Never create skills in `~/.cursor/skills-cursor/`. This directory is reserved for Cursor's internal built-in skills and is managed automatically by the system.
+
+### SKILL.md Structure
+
+Every skill requires a `SKILL.md` file with YAML frontmatter and markdown body:
+
+```markdown
+---
+name: your-skill-name
+description: Brief description of what this skill does and when to use it
+---
+
+# Your Skill Name
+
+## Instructions
+Clear, step-by-step guidance for the agent.
+
+## Examples
+Concrete examples of using this skill.
+```
+
+### Required Metadata Fields
+
+| Field | Requirements | Purpose |
+|-------|--------------|---------|
+| `name` | Max 64 chars, lowercase letters/numbers/hyphens only | Unique identifier for the skill |
+| `description` | Max 1024 chars, non-empty | Helps agent decide when to apply the skill |
+
+---
+
+## Writing Effective Descriptions
+
+The description is **critical** for skill discovery. The agent uses it to decide when to apply your skill.
+
+### Description Best Practices
+
+1. **Write in third person** (the description is injected into the system prompt):
+ - ✅ Good: "Processes Excel files and generates reports"
+ - ❌ Avoid: "I can help you process Excel files"
+ - ❌ Avoid: "You can use this to process Excel files"
+
+2. **Be specific and include trigger terms**:
+ - ✅ Good: "Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDF files or when the user mentions PDFs, forms, or document extraction."
+ - ❌ Vague: "Helps with documents"
+
+3. **Include both WHAT and WHEN**:
+ - WHAT: What the skill does (specific capabilities)
+ - WHEN: When the agent should use it (trigger scenarios)
+
+### Description Examples
+
+```yaml
+# PDF Processing
+description: Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDF files or when the user mentions PDFs, forms, or document extraction.
+
+# Excel Analysis
+description: Analyze Excel spreadsheets, create pivot tables, generate charts. Use when analyzing Excel files, spreadsheets, tabular data, or .xlsx files.
+
+# Git Commit Helper
+description: Generate descriptive commit messages by analyzing git diffs. Use when the user asks for help writing commit messages or reviewing staged changes.
+
+# Code Review
+description: Review code for quality, security, and best practices following team standards. Use when reviewing pull requests, code changes, or when the user asks for a code review.
+```
+
+---
+
+## Core Authoring Principles
+
+### 1. Concise is Key
+
+The context window is shared with conversation history, other skills, and requests. Every token competes for space.
+
+**Default assumption**: The agent is already very smart. Only add context it doesn't already have.
+
+Challenge each piece of information:
+- "Does the agent really need this explanation?"
+- "Can I assume the agent knows this?"
+- "Does this paragraph justify its token cost?"
+
+**Good (concise)**:
+```markdown
+## Extract PDF text
+
+Use pdfplumber for text extraction:
+
+\`\`\`python
+import pdfplumber
+
+with pdfplumber.open("file.pdf") as pdf:
+ text = pdf.pages[0].extract_text()
+\`\`\`
+```
+
+**Bad (verbose)**:
+```markdown
+## Extract PDF text
+
+PDF (Portable Document Format) files are a common file format that contains
+text, images, and other content. To extract text from a PDF, you'll need to
+use a library. There are many libraries available for PDF processing, but we
+recommend pdfplumber because it's easy to use and handles most cases well...
+```
+
+### 2. Keep SKILL.md Under 500 Lines
+
+For optimal performance, the main SKILL.md file should be concise. Use progressive disclosure for detailed content.
+
+### 3. Progressive Disclosure
+
+Put essential information in SKILL.md; detailed reference material in separate files that the agent reads only when needed.
+
+```markdown
+# PDF Processing
+
+## Quick start
+[Essential instructions here]
+
+## Additional resources
+- For complete API details, see [reference.md](reference.md)
+- For usage examples, see [examples.md](examples.md)
+```
+
+**Keep references one level deep** - link directly from SKILL.md to reference files. Deeply nested references may result in partial reads.
+
+### 4. Set Appropriate Degrees of Freedom
+
+Match specificity to the task's fragility:
+
+| Freedom Level | When to Use | Example |
+|---------------|-------------|---------|
+| **High** (text instructions) | Multiple valid approaches, context-dependent | Code review guidelines |
+| **Medium** (pseudocode/templates) | Preferred pattern with acceptable variation | Report generation |
+| **Low** (specific scripts) | Fragile operations, consistency critical | Database migrations |
+
+---
+
+## Common Patterns
+
+### Template Pattern
+
+Provide output format templates:
+
+```markdown
+## Report structure
+
+Use this template:
+
+\`\`\`markdown
+# [Analysis Title]
+
+## Executive summary
+[One-paragraph overview of key findings]
+
+## Key findings
+- Finding 1 with supporting data
+- Finding 2 with supporting data
+
+## Recommendations
+1. Specific actionable recommendation
+2. Specific actionable recommendation
+\`\`\`
+```
+
+### Examples Pattern
+
+For skills where output quality depends on seeing examples:
+
+```markdown
+## Commit message format
+
+**Example 1:**
+Input: Added user authentication with JWT tokens
+Output:
+\`\`\`
+feat(auth): implement JWT-based authentication
+
+Add login endpoint and token validation middleware
+\`\`\`
+
+**Example 2:**
+Input: Fixed bug where dates displayed incorrectly
+Output:
+\`\`\`
+fix(reports): correct date formatting in timezone conversion
+
+Use UTC timestamps consistently across report generation
+\`\`\`
+```
+
+### Workflow Pattern
+
+Break complex operations into clear steps with checklists:
+
+```markdown
+## Form filling workflow
+
+Copy this checklist and track progress:
+
+\`\`\`
+Task Progress:
+- [ ] Step 1: Analyze the form
+- [ ] Step 2: Create field mapping
+- [ ] Step 3: Validate mapping
+- [ ] Step 4: Fill the form
+- [ ] Step 5: Verify output
+\`\`\`
+
+**Step 1: Analyze the form**
+Run: \`python scripts/analyze_form.py input.pdf\`
+...
+```
+
+### Conditional Workflow Pattern
+
+Guide through decision points:
+
+```markdown
+## Document modification workflow
+
+1. Determine the modification type:
+
+ **Creating new content?** → Follow "Creation workflow" below
+ **Editing existing content?** → Follow "Editing workflow" below
+
+2. Creation workflow:
+ - Use docx-js library
+ - Build document from scratch
+ ...
+```
+
+### Feedback Loop Pattern
+
+For quality-critical tasks, implement validation loops:
+
+```markdown
+## Document editing process
+
+1. Make your edits
+2. **Validate immediately**: \`python scripts/validate.py output/\`
+3. If validation fails:
+ - Review the error message
+ - Fix the issues
+ - Run validation again
+4. **Only proceed when validation passes**
+```
+
+---
+
+## Utility Scripts
+
+Pre-made scripts offer advantages over generated code:
+- More reliable than generated code
+- Save tokens (no code in context)
+- Save time (no code generation)
+- Ensure consistency across uses
+
+```markdown
+## Utility scripts
+
+**analyze_form.py**: Extract all form fields from PDF
+\`\`\`bash
+python scripts/analyze_form.py input.pdf > fields.json
+\`\`\`
+
+**validate.py**: Check for errors
+\`\`\`bash
+python scripts/validate.py fields.json
+# Returns: "OK" or lists conflicts
+\`\`\`
+```
+
+Make clear whether the agent should **execute** the script (most common) or **read** it as reference.
+
+---
+
+## Anti-Patterns to Avoid
+
+### 1. Windows-Style Paths
+- ✅ Use: `scripts/helper.py`
+- ❌ Avoid: `scripts\helper.py`
+
+### 2. Too Many Options
+```markdown
+# Bad - confusing
+"You can use pypdf, or pdfplumber, or PyMuPDF, or..."
+
+# Good - provide a default with escape hatch
+"Use pdfplumber for text extraction.
+For scanned PDFs requiring OCR, use pdf2image with pytesseract instead."
+```
+
+### 3. Time-Sensitive Information
+```markdown
+# Bad - will become outdated
+"If you're doing this before August 2025, use the old API."
+
+# Good - use an "old patterns" section
+## Current method
+Use the v2 API endpoint.
+
+## Old patterns (deprecated)
+
+Legacy v1 API
+...
+
+```
+
+### 4. Inconsistent Terminology
+Choose one term and use it throughout:
+- ✅ Always "API endpoint" (not mixing "URL", "route", "path")
+- ✅ Always "field" (not mixing "box", "element", "control")
+
+### 5. Vague Skill Names
+- ✅ Good: `processing-pdfs`, `analyzing-spreadsheets`
+- ❌ Avoid: `helper`, `utils`, `tools`
+
+---
+
+## Skill Creation Workflow
+
+When helping a user create a skill, follow this process:
+
+### Phase 1: Discovery
+
+Gather information about:
+1. The skill's purpose and primary use case
+2. Storage location (personal vs project)
+3. Trigger scenarios
+4. Any specific requirements or constraints
+5. Existing examples or patterns to follow
+
+If you have access to the AskQuestion tool, use it for efficient structured gathering. Otherwise, ask conversationally.
+
+### Phase 2: Design
+
+1. Draft the skill name (lowercase, hyphens, max 64 chars)
+2. Write a specific, third-person description
+3. Outline the main sections needed
+4. Identify if supporting files or scripts are needed
+
+### Phase 3: Implementation
+
+1. Create the directory structure
+2. Write the SKILL.md file with frontmatter
+3. Create any supporting reference files
+4. Create any utility scripts if needed
+
+### Phase 4: Verification
+
+1. Verify the SKILL.md is under 500 lines
+2. Check that the description is specific and includes trigger terms
+3. Ensure consistent terminology throughout
+4. Verify all file references are one level deep
+5. Test that the skill can be discovered and applied
+
+---
+
+## Complete Example
+
+Here's a complete example of a well-structured skill:
+
+**Directory structure:**
+```
+code-review/
+├── SKILL.md
+├── STANDARDS.md
+└── examples.md
+```
+
+**SKILL.md:**
+```markdown
+---
+name: code-review
+description: Review code for quality, security, and maintainability following team standards. Use when reviewing pull requests, examining code changes, or when the user asks for a code review.
+---
+
+# Code Review
+
+## Quick Start
+
+When reviewing code:
+
+1. Check for correctness and potential bugs
+2. Verify security best practices
+3. Assess code readability and maintainability
+4. Ensure tests are adequate
+
+## Review Checklist
+
+- [ ] Logic is correct and handles edge cases
+- [ ] No security vulnerabilities (SQL injection, XSS, etc.)
+- [ ] Code follows project style conventions
+- [ ] Functions are appropriately sized and focused
+- [ ] Error handling is comprehensive
+- [ ] Tests cover the changes
+
+## Providing Feedback
+
+Format feedback as:
+- 🔴 **Critical**: Must fix before merge
+- 🟡 **Suggestion**: Consider improving
+- 🟢 **Nice to have**: Optional enhancement
+
+## Additional Resources
+
+- For detailed coding standards, see [STANDARDS.md](STANDARDS.md)
+- For example reviews, see [examples.md](examples.md)
+```
+
+---
+
+## Summary Checklist
+
+Before finalizing a skill, verify:
+
+### Core Quality
+- [ ] Description is specific and includes key terms
+- [ ] Description includes both WHAT and WHEN
+- [ ] Written in third person
+- [ ] SKILL.md body is under 500 lines
+- [ ] Consistent terminology throughout
+- [ ] Examples are concrete, not abstract
+
+### Structure
+- [ ] File references are one level deep
+- [ ] Progressive disclosure used appropriately
+- [ ] Workflows have clear steps
+- [ ] No time-sensitive information
+
+### If Including Scripts
+- [ ] Scripts solve problems rather than punt
+- [ ] Required packages are documented
+- [ ] Error handling is explicit and helpful
+- [ ] No Windows-style paths
diff --git a/.cursor/skills/create-subagent/SKILL.md b/.cursor/skills/create-subagent/SKILL.md
new file mode 100644
index 0000000000000..05cfc5025c397
--- /dev/null
+++ b/.cursor/skills/create-subagent/SKILL.md
@@ -0,0 +1,225 @@
+---
+name: create-subagent
+description: >-
+ Create custom subagents for specialized AI tasks. Use when you want to create
+ a new type of subagent, set up task-specific agents, configure code reviewers,
+ debuggers, or domain-specific assistants with custom prompts.
+disable-model-invocation: true
+---
+# Creating Custom Subagents
+
+This skill guides you through creating custom subagents for Cursor. Subagents are specialized AI assistants that run in isolated contexts with custom system prompts.
+
+## When to Use Subagents
+
+Subagents help you:
+- **Preserve context** by isolating exploration from your main conversation
+- **Specialize behavior** with focused system prompts for specific domains
+- **Reuse configurations** across projects with user-level subagents
+
+### Inferring from Context
+
+If you have previous conversation context, infer the subagent's purpose and behavior from what was discussed. Create the subagent based on specialized tasks or workflows that emerged in the conversation.
+
+## Subagent Locations
+
+| Location | Scope | Priority |
+|----------|-------|----------|
+| `.cursor/agents/` | Current project | Higher |
+| `~/.cursor/agents/` | All your projects | Lower |
+
+When multiple subagents share the same name, the higher-priority location wins.
+
+**Project subagents** (`.cursor/agents/`): Ideal for codebase-specific agents. Check into version control to share with your team.
+
+**User subagents** (`~/.cursor/agents/`): Personal agents available across all your projects.
+
+## Subagent File Format
+
+Create a `.md` file with YAML frontmatter and a markdown body (the system prompt):
+
+```markdown
+---
+name: code-reviewer
+description: Reviews code for quality and best practices
+---
+
+You are a code reviewer. When invoked, analyze the code and provide
+specific, actionable feedback on quality, security, and best practices.
+```
+
+### Required Fields
+
+| Field | Description |
+|-------|-------------|
+| `name` | Unique identifier (lowercase letters and hyphens only) |
+| `description` | When to delegate to this subagent (be specific!) |
+
+## Writing Effective Descriptions
+
+The description is **critical** - the AI uses it to decide when to delegate.
+
+```yaml
+# ❌ Too vague
+description: Helps with code
+
+# ✅ Specific and actionable
+description: Expert code review specialist. Proactively reviews code for quality, security, and maintainability. Use immediately after writing or modifying code.
+```
+
+Include "use proactively" to encourage automatic delegation.
+
+## Example Subagents
+
+### Code Reviewer
+
+```markdown
+---
+name: code-reviewer
+description: Expert code review specialist. Proactively reviews code for quality, security, and maintainability. Use immediately after writing or modifying code.
+---
+
+You are a senior code reviewer ensuring high standards of code quality and security.
+
+When invoked:
+1. Run git diff to see recent changes
+2. Focus on modified files
+3. Begin review immediately
+
+Review checklist:
+- Code is clear and readable
+- Functions and variables are well-named
+- No duplicated code
+- Proper error handling
+- No exposed secrets or API keys
+- Input validation implemented
+- Good test coverage
+- Performance considerations addressed
+
+Provide feedback organized by priority:
+- Critical issues (must fix)
+- Warnings (should fix)
+- Suggestions (consider improving)
+
+Include specific examples of how to fix issues.
+```
+
+### Debugger
+
+```markdown
+---
+name: debugger
+description: Debugging specialist for errors, test failures, and unexpected behavior. Use proactively when encountering any issues.
+---
+
+You are an expert debugger specializing in root cause analysis.
+
+When invoked:
+1. Capture error message and stack trace
+2. Identify reproduction steps
+3. Isolate the failure location
+4. Implement minimal fix
+5. Verify solution works
+
+Debugging process:
+- Analyze error messages and logs
+- Check recent code changes
+- Form and test hypotheses
+- Add strategic debug logging
+- Inspect variable states
+
+For each issue, provide:
+- Root cause explanation
+- Evidence supporting the diagnosis
+- Specific code fix
+- Testing approach
+- Prevention recommendations
+
+Focus on fixing the underlying issue, not the symptoms.
+```
+
+### Data Scientist
+
+```markdown
+---
+name: data-scientist
+description: Data analysis expert for SQL queries, BigQuery operations, and data insights. Use proactively for data analysis tasks and queries.
+---
+
+You are a data scientist specializing in SQL and BigQuery analysis.
+
+When invoked:
+1. Understand the data analysis requirement
+2. Write efficient SQL queries
+3. Use BigQuery command line tools (bq) when appropriate
+4. Analyze and summarize results
+5. Present findings clearly
+
+Key practices:
+- Write optimized SQL queries with proper filters
+- Use appropriate aggregations and joins
+- Include comments explaining complex logic
+- Format results for readability
+- Provide data-driven recommendations
+
+For each analysis:
+- Explain the query approach
+- Document any assumptions
+- Highlight key findings
+- Suggest next steps based on data
+
+Always ensure queries are efficient and cost-effective.
+```
+
+## Subagent Creation Workflow
+
+### Step 1: Decide the Scope
+
+- **Project-level** (`.cursor/agents/`): For codebase-specific agents shared with team
+- **User-level** (`~/.cursor/agents/`): For personal agents across all projects
+
+### Step 2: Create the File
+
+```bash
+# For project-level
+mkdir -p .cursor/agents
+touch .cursor/agents/my-agent.md
+
+# For user-level
+mkdir -p ~/.cursor/agents
+touch ~/.cursor/agents/my-agent.md
+```
+
+### Step 3: Define Configuration
+
+Write the frontmatter with the required fields (`name` and `description`).
+
+### Step 4: Write the System Prompt
+
+The body becomes the system prompt. Be specific about:
+- What the agent should do when invoked
+- The workflow or process to follow
+- Output format and structure
+- Any constraints or guidelines
+
+### Step 5: Test the Agent
+
+Ask the AI to use your new agent:
+
+```
+Use the my-agent subagent to [task description]
+```
+
+## Best Practices
+
+1. **Design focused subagents**: Each should excel at one specific task
+2. **Write detailed descriptions**: Include trigger terms so the AI knows when to delegate
+3. **Check into version control**: Share project subagents with your team
+4. **Use proactive language**: Include "use proactively" in descriptions
+
+## Troubleshooting
+
+### Subagent Not Found
+- Ensure file is in `.cursor/agents/` or `~/.cursor/agents/`
+- Check file has `.md` extension
+- Verify YAML frontmatter syntax is valid
diff --git a/.cursor/skills/karpathy-guidelines/SKILL.md b/.cursor/skills/karpathy-guidelines/SKILL.md
new file mode 100644
index 0000000000000..6a62d04417531
--- /dev/null
+++ b/.cursor/skills/karpathy-guidelines/SKILL.md
@@ -0,0 +1,67 @@
+---
+name: karpathy-guidelines
+description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.
+license: MIT
+---
+
+# Karpathy Guidelines
+
+Behavioral guidelines to reduce common LLM coding mistakes, derived from [Andrej Karpathy's observations](https://x.com/karpathy/status/2015883857489522876) on LLM coding pitfalls.
+
+**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
+
+## 1. Think Before Coding
+
+**Don't assume. Don't hide confusion. Surface tradeoffs.**
+
+Before implementing:
+- State your assumptions explicitly. If uncertain, ask.
+- If multiple interpretations exist, present them - don't pick silently.
+- If a simpler approach exists, say so. Push back when warranted.
+- If something is unclear, stop. Name what's confusing. Ask.
+
+## 2. Simplicity First
+
+**Minimum code that solves the problem. Nothing speculative.**
+
+- No features beyond what was asked.
+- No abstractions for single-use code.
+- No "flexibility" or "configurability" that wasn't requested.
+- No error handling for impossible scenarios.
+- If you write 200 lines and it could be 50, rewrite it.
+
+Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
+
+## 3. Surgical Changes
+
+**Touch only what you must. Clean up only your own mess.**
+
+When editing existing code:
+- Don't "improve" adjacent code, comments, or formatting.
+- Don't refactor things that aren't broken.
+- Match existing style, even if you'd do it differently.
+- If you notice unrelated dead code, mention it - don't delete it.
+
+When your changes create orphans:
+- Remove imports/variables/functions that YOUR changes made unused.
+- Don't remove pre-existing dead code unless asked.
+
+The test: Every changed line should trace directly to the user's request.
+
+## 4. Goal-Driven Execution
+
+**Define success criteria. Loop until verified.**
+
+Transform tasks into verifiable goals:
+- "Add validation" → "Write tests for invalid inputs, then make them pass"
+- "Fix the bug" → "Write a test that reproduces it, then make it pass"
+- "Refactor X" → "Ensure tests pass before and after"
+
+For multi-step tasks, state a brief plan:
+```
+1. [Step] → verify: [check]
+2. [Step] → verify: [check]
+3. [Step] → verify: [check]
+```
+
+Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
diff --git a/.cursor/skills/migrate-to-skills/SKILL.md b/.cursor/skills/migrate-to-skills/SKILL.md
new file mode 100644
index 0000000000000..1cb37f807e3f9
--- /dev/null
+++ b/.cursor/skills/migrate-to-skills/SKILL.md
@@ -0,0 +1,134 @@
+---
+name: migrate-to-skills
+description: >-
+ Convert 'Applied intelligently' Cursor rules (.cursor/rules/*.mdc) and slash
+ commands (.cursor/commands/*.md) to Agent Skills format (.cursor/skills/). Use
+ when you want to migrate rules or commands to skills, convert .mdc rules to
+ SKILL.md format, or consolidate commands into the skills directory.
+disable-model-invocation: true
+---
+# Migrate Rules and Slash Commands to Skills
+
+Convert Cursor rules ("Applied intelligently") and slash commands to Agent Skills format.
+
+**CRITICAL: Preserve the exact body content. Do not modify, reformat, or "improve" it - copy verbatim.**
+
+## Locations
+
+| Level | Source | Destination |
+|-------|--------|-------------|
+| Project | `{workspaceFolder}/**/.cursor/rules/*.mdc`, `{workspaceFolder}/.cursor/commands/*.md` |
+| User | `~/.cursor/commands/*.md` |
+
+Notes:
+- Cursor rules inside the project can live in nested directories. Be thorough in your search and use glob patterns to find them.
+- Ignore anything in ~/.cursor/worktrees
+- Ignore anything in ~/.cursor/skills-cursor. This is reserved for Cursor's internal built-in skills and is managed automatically by the system.
+
+## Finding Files to Migrate
+
+**Rules**: Migrate if rule has a `description` but NO `globs` and NO `alwaysApply: true`.
+
+**Commands**: Migrate all - they're plain markdown without frontmatter.
+
+## Conversion Format
+
+### Rules: .mdc → SKILL.md
+
+```markdown
+# Before: .cursor/rules/my-rule.mdc
+---
+description: What this rule does
+globs:
+alwaysApply: false
+---
+# Title
+Body content...
+```
+
+```markdown
+# After: .cursor/skills/my-rule/SKILL.md
+---
+name: my-rule
+description: What this rule does
+---
+# Title
+Body content...
+```
+
+Changes: Add `name` field, remove `globs`/`alwaysApply`, keep body exactly.
+
+### Commands: .md → SKILL.md
+
+```markdown
+# Before: .cursor/commands/commit.md
+# Commit current work
+Instructions here...
+```
+
+```markdown
+# After: .cursor/skills/commit/SKILL.md
+---
+name: commit
+description: Commit current work with standardized message format
+disable-model-invocation: true
+---
+# Commit current work
+Instructions here...
+```
+
+Changes: Add frontmatter with `name` (from filename), `description` (infer from content), and `disable-model-invocation: true`, keep body exactly.
+
+**Note:** The `disable-model-invocation: true` field prevents the model from automatically invoking this skill. Slash commands are designed to be explicitly triggered by the user via the `/` menu, not automatically suggested by the model.
+
+## Notes
+
+- `name` must be lowercase with hyphens only
+- `description` is critical for skill discovery
+- Optionally delete originals after verifying migration works
+
+### Migrate a Rule (.mdc → SKILL.md)
+
+1. Read the rule file
+2. Extract the `description` from the frontmatter
+3. Extract the body content (everything after the closing `---` of the frontmatter)
+4. Create the skill directory: `.cursor/skills/{skill-name}/` (skill name = filename without .mdc)
+5. Write `SKILL.md` with new frontmatter (`name` and `description`) + the EXACT original body content (preserve all whitespace, formatting, code blocks verbatim)
+6. Delete the original rule file
+
+### Migrate a Command (.md → SKILL.md)
+
+1. Read the command file
+2. Extract description from the first heading (remove `#` prefix)
+3. Create the skill directory: `.cursor/skills/{skill-name}/` (skill name = filename without .md)
+4. Write `SKILL.md` with new frontmatter (`name`, `description`, and `disable-model-invocation: true`) + blank line + the EXACT original file content (preserve all whitespace, formatting, code blocks verbatim)
+5. Delete the original command file
+
+**CRITICAL: Copy the body content character-for-character. Do not reformat, fix typos, or "improve" anything.**
+
+## Workflow
+
+If you have the Task tool available:
+DO NOT start to read all of the files yourself. That function should be delegated to the subagents. Your job is to dispatch the subagents for each category of files and wait for the results.
+
+1. [ ] Create the skills directories if they don't exist (`.cursor/skills/` for project, `~/.cursor/skills/` for user)
+2. Dispatch three fast general purpose subagents (NOT explore) in parallel to do the following steps for project rules (pattern: `{workspaceFolder}/**/.cursor/rules/*.mdc`), user commands (pattern: `~/.cursor/commands/*.md`), and project commands (pattern: `{workspaceFolder}/**/.cursor/commands/*.md`):
+ I. [ ] Find files to migrate in the given pattern
+ II. [ ] For rules, check if it's an "applied intelligently" rule (has `description`, no `globs`, no `alwaysApply: true`). Commands are always migrated. DO NOT use the terminal to read files. Use the read tool.
+ III. [ ] Make a list of files to migrate. If empty, done.
+ IV. [ ] For each file, read it, then write the new skill file preserving the body content EXACTLY. DO NOT use the terminal to write these files. Use the edit tool.
+ V. [ ] Delete the original file. DO NOT use the terminal to delete these files. Use the delete tool.
+ VI. [ ] Return a list of all the skill files that were migrated along with the original file paths.
+3. [ ] Wait for all subagents to complete and summarize the results to the user. IMPORTANT: Make sure to let them know if they want to undo the migration, to ask you to.
+4. [ ] If the user asks you to undo the migration, do the opposite of the above steps to restore the original files.
+
+
+If you don't have the Task tool available:
+1. [ ] Create the skills directories if they don't exist (`.cursor/skills/` for project, `~/.cursor/skills/` for user)
+2. [ ] Find files to migrate in both project (`.cursor/`) and user (`~/.cursor/`) directories
+3. [ ] For rules, check if it's an "applied intelligently" rule (has `description`, no `globs`, no `alwaysApply: true`). Commands are always migrated. DO NOT use the terminal to read files. Use the read tool.
+4. [ ] Make a list of files to migrate. If empty, done.
+5. [ ] For each file, read it, then write the new skill file preserving the body content EXACTLY. DO NOT use the terminal to write these files. Use the edit tool.
+6. [ ] Delete the original file. DO NOT use the terminal to delete these files. Use the delete tool.
+7. [ ] Summarize the results to the user. IMPORTANT: Make sure to let them know if they want to undo the migration, to ask you to.
+8. [ ] If the user asks you to undo the migration, do the opposite of the above steps to restore the original files.
diff --git a/.cursor/skills/shell/SKILL.md b/.cursor/skills/shell/SKILL.md
new file mode 100644
index 0000000000000..bcf9bcdb28a65
--- /dev/null
+++ b/.cursor/skills/shell/SKILL.md
@@ -0,0 +1,24 @@
+---
+name: shell
+description: >-
+ Runs the rest of a /shell request as a literal shell command. Use only when
+ the user explicitly invokes /shell and wants the following text executed
+ directly in the terminal.
+disable-model-invocation: true
+---
+# Run Shell Commands
+
+Use this skill only when the user explicitly invokes `/shell`.
+
+## Behavior
+
+1. Treat all user text after the `/shell` invocation as the literal shell command to run.
+2. Execute that command immediately with the terminal tool.
+3. Do not rewrite, explain, or "improve" the command before running it.
+4. Do not inspect the repository first unless the command itself requires repository context.
+5. If the user invokes `/shell` without any following text, ask them which command to run.
+
+## Response
+
+- Run the command first.
+- Then briefly report the exit status and any important stdout or stderr.
diff --git a/.cursor/skills/statusline/SKILL.md b/.cursor/skills/statusline/SKILL.md
new file mode 100644
index 0000000000000..0499a918da447
--- /dev/null
+++ b/.cursor/skills/statusline/SKILL.md
@@ -0,0 +1,194 @@
+---
+name: statusline
+description: >-
+ Configure a custom status line in the CLI. Use when the user mentions status
+ line, statusline, statusLine, CLI status bar, prompt footer customization, or
+ wants to add session context above the prompt.
+---
+# CLI Status Line
+
+The CLI supports a user-configurable status line rendered above the prompt. A command is spawned on each conversation update, receives a JSON payload on stdin describing the session, and its stdout is displayed as the status line. The spec is aligned with [Claude Code's status line](https://code.claude.com/docs/en/statusline).
+
+## Configuration
+
+Add a `statusLine` entry to `~/.cursor/cli-config.json`:
+
+```json
+{
+ "statusLine": {
+ "type": "command",
+ "command": "~/.cursor/statusline.sh",
+ "padding": 2
+ }
+}
+```
+
+The `command` field supports full paths, `~` expansion, and shell-style argument splitting. You can point it at a script file or use an inline command like `jq -r '...'`.
+
+| Field | Required | Default | Description |
+|-------|----------|---------|-------------|
+| `type` | yes | — | Must be `"command"` |
+| `command` | yes | — | Path to an executable or inline command. `~` is expanded. |
+| `padding` | no | `0` | Horizontal inset (in characters) for the status line container. |
+| `updateIntervalMs` | no | `300` | Minimum interval between invocations. Clamped to >= 300ms. |
+| `timeoutMs` | no | `2000` | Maximum time the command may run before it is killed. |
+
+## Stdin payload
+
+The command receives a JSON object on stdin. The TypeScript interface is `StatusLinePayload` in `packages/agent-cli/src/hooks/use-status-line.ts`.
+
+### Full JSON schema
+
+```json
+{
+ "session_id": "abc123",
+ "session_name": "my session",
+ "transcript_path": "/path/to/transcript.jsonl",
+ "render_width_chars": 120,
+ "cwd": "/Users/me/project",
+ "model": {
+ "id": "claude-4-opus",
+ "display_name": "Claude 4 Opus",
+ "param_summary": "(Thinking)",
+ "max_mode": true
+ },
+ "workspace": {
+ "current_dir": "/Users/me/project",
+ "project_dir": "/Users/me/project/.cursor/transcripts",
+ "added_dirs": []
+ },
+ "version": "1.2.3",
+ "output_style": {
+ "name": "default"
+ },
+ "context_window": {
+ "total_input_tokens": 15234,
+ "total_output_tokens": null,
+ "context_window_size": 200000,
+ "used_percentage": 34.5,
+ "remaining_percentage": 65.5,
+ "current_usage": null
+ },
+ "vim": {
+ "mode": "NORMAL"
+ },
+ "worktree": {
+ "name": "my-feature",
+ "path": "/Users/me/.cursor/worktrees/repo/my-feature"
+ }
+}
+```
+
+### Available fields
+
+| Field | Description |
+|-------|-------------|
+| `session_id` | Unique session identifier |
+| `session_name` | Custom session name. Absent if no name has been set |
+| `transcript_path` | Path to conversation transcript file |
+| `render_width_chars` | Usable terminal columns minus built-in padding |
+| `cwd`, `workspace.current_dir` | Current working directory (both contain the same value) |
+| `workspace.project_dir` | Directory where transcripts are stored |
+| `workspace.added_dirs` | Additional directories (empty array for now) |
+| `model.id`, `model.display_name` | Current model identifier and display name |
+| `model.param_summary` | Formatted parameter summary (e.g. "(Thinking)", "High"). Absent when empty |
+| `model.max_mode` | `true` when max mode is enabled. Absent otherwise |
+| `version` | CLI version string |
+| `output_style.name` | `"default"` or `"compact"` |
+| `context_window.total_input_tokens` | Estimated input tokens (derived from used_percentage) |
+| `context_window.total_output_tokens` | Cumulative output tokens (null when not tracked) |
+| `context_window.context_window_size` | Maximum context window size in tokens |
+| `context_window.used_percentage` | Percentage of context window used |
+| `context_window.remaining_percentage` | Percentage of context window remaining |
+| `context_window.current_usage` | Token counts from the last API call (null before first call) |
+| `vim.mode` | `"NORMAL"` or `"INSERT"` when vim mode is enabled |
+| `worktree.name` | Worktree name when running inside a worktree |
+| `worktree.path` | Absolute path to the worktree directory |
+
+### Fields that may be absent
+
+- `session_name` — only present when a custom name has been set
+- `model.param_summary` — only present when model has non-default parameters
+- `model.max_mode` — only present when max mode is enabled
+- `vim` — only present when vim mode is enabled
+- `worktree` — only present when running in a worktree
+
+### Fields that may be null
+
+- `context_window.current_usage` — null before the first API call
+- `context_window.used_percentage`, `context_window.remaining_percentage` — may be null early in the session
+
+## Stdout / rendering
+
+- **Multiple lines** are supported: each line of stdout renders as a separate row in the status area.
+- **ANSI color codes** are supported (use chalk, tput, `\033[32m`, etc.).
+- If the command exits non-zero with empty stdout, the status line is not updated (previous text is kept).
+- If the command times out or a new update arrives while the script is running, the in-flight process is killed.
+- The status line runs locally and does not consume API tokens.
+
+## Examples
+
+### Basic: model + context usage
+
+```bash
+#!/usr/bin/env bash
+payload=$(cat)
+model=$(echo "$payload" | jq -r '.model.display_name')
+pct=$(echo "$payload" | jq -r '.context_window.used_percentage // 0' | cut -d. -f1)
+printf "\033[90m%s ctx %s%%\033[0m" "$model" "$pct"
+```
+
+### Context progress bar
+
+```bash
+#!/usr/bin/env bash
+input=$(cat)
+MODEL=$(echo "$input" | jq -r '.model.display_name')
+PCT=$(echo "$input" | jq -r '.context_window.used_percentage // 0' | cut -d. -f1)
+
+BAR_WIDTH=10
+FILLED=$((PCT * BAR_WIDTH / 100))
+EMPTY=$((BAR_WIDTH - FILLED))
+BAR=""
+[ "$FILLED" -gt 0 ] && printf -v FILL "%${FILLED}s" && BAR="${FILL// /▓}"
+[ "$EMPTY" -gt 0 ] && printf -v PAD "%${EMPTY}s" && BAR="${BAR}${PAD// /░}"
+
+echo "[$MODEL] $BAR $PCT%"
+```
+
+### Multi-line with git info
+
+```bash
+#!/usr/bin/env bash
+input=$(cat)
+MODEL=$(echo "$input" | jq -r '.model.display_name')
+DIR=$(echo "$input" | jq -r '.workspace.current_dir')
+PCT=$(echo "$input" | jq -r '.context_window.used_percentage // 0' | cut -d. -f1)
+
+BRANCH=""
+git rev-parse --git-dir > /dev/null 2>&1 && BRANCH=" | 🌿 $(git branch --show-current 2>/dev/null)"
+
+echo -e "\033[36m[$MODEL]\033[0m 📁 ${DIR##*/}$BRANCH"
+echo -e "ctx $PCT%"
+```
+
+### Inline jq command (no script file)
+
+```json
+{
+ "statusLine": {
+ "type": "command",
+ "command": "jq -r '\"[\\(.model.display_name)] \\(.context_window.used_percentage // 0)% context\"'"
+ }
+}
+```
+
+## Testing
+
+Test a script with mock input:
+
+```bash
+echo '{"model":{"display_name":"Opus"},"context_window":{"used_percentage":25}}' | ./statusline.sh
+```
+
+The command is spawned with `child_process.spawn` (no shell on Unix, `shell: true` on Windows for .cmd/.bat compatibility). Updates are debounced at the configured interval. If a new update triggers while a script is running, the in-flight process is killed via `AbortController` and the new invocation starts immediately.
diff --git a/.cursor/skills/update-cli-config/SKILL.md b/.cursor/skills/update-cli-config/SKILL.md
new file mode 100644
index 0000000000000..5784a0b627350
--- /dev/null
+++ b/.cursor/skills/update-cli-config/SKILL.md
@@ -0,0 +1,87 @@
+---
+name: update-cli-config
+description: >-
+ View and modify Cursor CLI configuration settings in
+ ~/.cursor/cli-config.json. Use when the user wants to change CLI settings,
+ configure permissions, switch approval mode, enable vim mode, toggle display
+ options, configure sandbox, or manage any CLI preferences.
+metadata:
+ surfaces:
+ - cli
+---
+# Cursor CLI Configuration
+
+This skill explains how to view and modify Cursor CLI settings stored in `~/.cursor/cli-config.json`.
+
+## Config File Location
+
+The config file is `~/.cursor/cli-config.json`.
+
+Projects can layer overrides via `.cursor/cli.json` files. The CLI walks from the git root to the current working directory and merges each `.cursor/cli.json` it finds (deeper files take precedence). Project overrides only affect the current session; they are not written back to the home config.
+
+## How to Modify
+
+Read `~/.cursor/cli-config.json`, apply changes, and write it back. The file is standard JSON. Changes take effect after restarting the CLI.
+
+## Available Settings
+
+### `permissions` (required)
+Tool permission rules. Each entry is a string pattern.
+- `allow`: string[] — patterns for allowed tool calls (e.g. `"Shell(**)"`, `"Mcp(server-name, tool-name)"`)
+- `deny`: string[] — patterns for denied tool calls
+
+### `editor`
+- `vimMode`: boolean — enable vim keybindings in the CLI input
+- `defaultBehavior`: `"ide"` | `"agent"` — default behavior mode
+
+### `display` (optional)
+- `showLineNumbers`: boolean (default: false) — show line numbers in code output
+- `showThinkingBlocks`: boolean (default: false) — show model thinking/reasoning blocks
+- `showStatusIndicators`: boolean (default: false) — show status indicators in the UI
+
+### `channel` (optional)
+Release channel: `"prod"` | `"staging"` | `"lab"` | `"static"`
+
+### `maxMode` (optional)
+boolean (default: false) — enable max mode for higher-quality model responses
+
+### `approvalMode` (optional)
+Controls tool approval behavior:
+- `"allowlist"` (default) — require approval for tools not in the allow list
+- `"unrestricted"` — auto-approve all tool calls (yolo mode)
+
+### `sandbox` (optional)
+Sandbox execution environment settings:
+- `mode`: `"disabled"` | `"enabled"` (default: `"disabled"`)
+- `networkAccess`: `"user_config_only"` | `"user_config_with_defaults"` | `"allow_all"` — controls network access from sandbox
+- `networkAllowlist`: string[] — domains the sandbox is allowed to reach
+
+### `network` (optional)
+- `useHttp1ForAgent`: boolean (default: false) — use HTTP/1.1 instead of HTTP/2 for agent connections (enables SSE-based streaming)
+
+### `bedrock` (optional)
+AWS Bedrock integration settings:
+- `enabled`: boolean (default: false)
+- `mode`: `"access-key"` | `"team-role"` (default: `"access-key"`)
+- `region`: string — AWS region
+- `testModel`: string — model to use for testing
+- `teamRoleArn`: string — IAM role ARN for team mode
+- `teamExternalId`: string — external ID for STS assume-role
+
+### `attribution` (optional)
+Controls how agent work is attributed in git:
+- `attributeCommitsToAgent`: boolean (default: true) — attribute commits to the agent
+- `attributePRsToAgent`: boolean (default: true) — attribute PRs to the agent
+
+### `webFetchDomainAllowlist` (optional)
+string[] — domains the web fetch tool is allowed to access (e.g. `"docs.github.com"`, `"*.example.com"`, `"*"`)
+
+## Fields You Should NOT Modify
+
+These are internal/cached state and should not be edited manually:
+- `version` — config schema version
+- `model` / `selectedModel` / `modelParameters` / `hasChangedDefaultModel` — managed by the model picker
+- `privacyCache` — cached privacy mode state
+- `authInfo` — cached authentication info
+- `showSandboxIntro` — one-time UI flag
+- `conversationClassificationScoredConversations` — internal cache
diff --git a/.cursor/skills/update-cursor-settings/SKILL.md b/.cursor/skills/update-cursor-settings/SKILL.md
new file mode 100644
index 0000000000000..98ed1850163d9
--- /dev/null
+++ b/.cursor/skills/update-cursor-settings/SKILL.md
@@ -0,0 +1,122 @@
+---
+name: update-cursor-settings
+description: >-
+ Modify Cursor/VSCode user settings in settings.json. Use when you want to
+ change editor settings, preferences, configuration, themes, font size, tab
+ size, format on save, auto save, keybindings, or any settings.json values.
+metadata:
+ surfaces:
+ - ide
+---
+# Updating Cursor Settings
+
+This skill guides you through modifying Cursor/VSCode user settings. Use this when you want to change editor settings, preferences, configuration, themes, keybindings, or any `settings.json` values.
+
+## Settings File Location
+
+| OS | Path |
+|----|------|
+| macOS | ~/Library/Application Support/Cursor/User/settings.json |
+| Linux | ~/.config/Cursor/User/settings.json |
+| Windows | %APPDATA%\Cursor\User\settings.json |
+
+## Before Modifying Settings
+
+1. **Read the existing settings file** to understand current configuration
+2. **Preserve existing settings** - only add/modify what the user requested
+3. **Validate JSON syntax** before writing to avoid breaking the editor
+
+## Modifying Settings
+
+### Step 1: Read Current Settings
+
+```typescript
+// Read the settings file first
+const settingsPath = "~/Library/Application Support/Cursor/User/settings.json";
+// Use the Read tool to get current contents
+```
+
+### Step 2: Identify the Setting to Change
+
+Common setting categories:
+- **Editor**: `editor.fontSize`, `editor.tabSize`, `editor.wordWrap`, `editor.formatOnSave`
+- **Workbench**: `workbench.colorTheme`, `workbench.iconTheme`, `workbench.sideBar.location`
+- **Files**: `files.autoSave`, `files.exclude`, `files.associations`
+- **Terminal**: `terminal.integrated.fontSize`, `terminal.integrated.shell.*`
+- **Cursor-specific**: Settings prefixed with `cursor.` or `aipopup.`
+
+### Step 3: Update the Setting
+
+When modifying settings.json:
+1. Parse the existing JSON (handle comments - VSCode settings support JSON with comments)
+2. Add or update the requested setting
+3. Preserve all other existing settings
+4. Write back with proper formatting (2-space indentation)
+
+### Example: Changing Font Size
+
+If user says "make the font bigger":
+
+```json
+{
+ "editor.fontSize": 16
+}
+```
+
+### Example: Enabling Format on Save
+
+If user says "format my code when I save":
+
+```json
+{
+ "editor.formatOnSave": true
+}
+```
+
+### Example: Changing Theme
+
+If user says "use dark theme" or "change my theme":
+
+```json
+{
+ "workbench.colorTheme": "Default Dark Modern"
+}
+```
+
+## Important Notes
+
+1. **JSON with Comments**: VSCode/Cursor settings.json supports comments (`//` and `/* */`). When reading, be aware comments may exist. When writing, preserve comments if possible.
+
+2. **Restart May Be Required**: Some settings take effect immediately, others require reloading the window or restarting Cursor. Inform the user if a restart is needed.
+
+3. **Backup**: For significant changes, consider mentioning the user can undo via Ctrl/Cmd+Z in the settings file or by reverting git changes if tracked.
+
+4. **Workspace vs User Settings**:
+ - User settings (what this skill covers): Apply globally to all projects
+ - Workspace settings (`.vscode/settings.json`): Apply only to the current project
+
+5. **Commit Attribution**: When the user asks about commit attribution, clarify whether they want to edit the **CLI agent** or the **IDE agent**. For the CLI agent, modify `~/.cursor/cli-config.json`. For the IDE agent, it is controlled from the UI at **Cursor Settings > Agent > Attribution** (not settings.json).
+
+## Common User Requests → Settings
+
+| User Request | Setting |
+|--------------|---------|
+| "bigger/smaller font" | `editor.fontSize` |
+| "change tab size" | `editor.tabSize` |
+| "format on save" | `editor.formatOnSave` |
+| "word wrap" | `editor.wordWrap` |
+| "change theme" | `workbench.colorTheme` |
+| "hide minimap" | `editor.minimap.enabled` |
+| "auto save" | `files.autoSave` |
+| "line numbers" | `editor.lineNumbers` |
+| "bracket matching" | `editor.bracketPairColorization.enabled` |
+| "cursor style" | `editor.cursorStyle` |
+| "smooth scrolling" | `editor.smoothScrolling` |
+
+## Workflow
+
+1. Read ~/Library/Application Support/Cursor/User/settings.json
+2. Parse the JSON content
+3. Add/modify the requested setting(s)
+4. Write the updated JSON back to the file
+5. Inform the user the setting has been changed and whether a reload is needed
diff --git a/.gitignore b/.gitignore
index 5691dd146dea8..8117f20397a02 100644
--- a/.gitignore
+++ b/.gitignore
@@ -165,4 +165,17 @@ keys.json
# examples
examples/**/package-lock.json
examples/**/yarn.lock
-examples/**/pnpm-lock.yaml
\ No newline at end of file
+examples/**/pnpm-lock.yaml
+
+# traffic-one: auto-generated by traffic-one/deploy.sh (source lives in traffic-one/functions/)
+docker/volumes/functions/traffic-one/
+
+REMOTE_DEVELOPMENT.md
+
+# `.cursor/plans/` holds per-session plan/scratchpad artefacts generated by
+# Cursor's `/plan` and security-audit agents (see .cursor/commands/). These
+# are working notes with arbitrary PII, environment-specific IDs, and secrets
+# from interactive tool runs — they are explicitly for personal use and must
+# not be checked in. Skill / rule / hook files in `.cursor/` (which ARE
+# tracked and reviewed) live at other paths and are unaffected.
+.cursor/plans/*
\ No newline at end of file
diff --git a/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx b/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx
index c7a257207fe78..3d814cf6cb73c 100644
--- a/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx
+++ b/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx
@@ -148,16 +148,18 @@ export const HooksListing = () => {
})}
- {
- const hook = hooks.find((h) => h.title === selectedHook?.title)
- if (hook) setSelectedHookForDeletion(hook)
- }}
- onClose={() => setHook(null)}
- authConfig={authConfig!}
- />
+ {authConfig && (
+ {
+ const hook = hooks.find((h) => h.title === selectedHook?.title)
+ if (hook) setSelectedHookForDeletion(hook)
+ }}
+ onClose={() => setHook(null)}
+ authConfig={authConfig}
+ />
+ )}
{
const { payload } = newContentSnippet
- // No need to update the cache for non-SQL content
- if (payload.type !== 'sql') return
- if (!('chart' in payload.content)) return
+ if (!payload || payload.type !== 'sql') return
+ if (!payload.content || !('chart' in payload.content)) return
const newSnippet = {
...snippet,
@@ -153,7 +152,7 @@ const UtilityPanel = ({
sendEvent({
action: 'sql_editor_result_download_csv_clicked',
diff --git a/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.tsx b/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.tsx
index 2dc69ec7bf120..b3c7571f3a351 100644
--- a/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.tsx
+++ b/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.tsx
@@ -29,7 +29,9 @@ export const PoolingModesModal = () => {
const state = useDatabaseSelectorStateSnapshot()
const { data } = useSupavisorConfigurationQuery({ projectRef: projectRef })
- const primaryConfig = data?.find((x) => x.identifier === state.selectedDatabaseId)
+ const primaryConfig = Array.isArray(data)
+ ? data.find((x) => x.identifier === state.selectedDatabaseId)
+ : undefined
const navigateToPoolerSettings = () => {
const el = document.getElementById('connection-pooler')
diff --git a/apps/studio/lib/api/incident-banner.ts b/apps/studio/lib/api/incident-banner.ts
index 5191cc1b78a20..a073625e7f24e 100644
--- a/apps/studio/lib/api/incident-banner.ts
+++ b/apps/studio/lib/api/incident-banner.ts
@@ -102,13 +102,13 @@ async function fetchAllIncidents(apiKey: string, mode: string): Promise> {
const apiKey = process.env.INCIDENT_IO_API_KEY
if (!apiKey) {
- throw new Error('INCIDENT_IO_API_KEY is not set')
+ return []
}
const incidentMode = IS_PROD ? 'standard' : 'test'
diff --git a/apps/studio/lib/api/self-hosted/util.ts b/apps/studio/lib/api/self-hosted/util.ts
index 968db7800593b..69d5d07d6a5be 100644
--- a/apps/studio/lib/api/self-hosted/util.ts
+++ b/apps/studio/lib/api/self-hosted/util.ts
@@ -13,9 +13,10 @@ import { IS_PLATFORM } from '@/lib/constants'
/**
* Asserts that the current environment is self-hosted.
+ * Self-hosted platform mode (Docker with IS_PLATFORM + PLATFORM_PG_META_URL) is allowed.
*/
export function assertSelfHosted() {
- if (IS_PLATFORM) {
+ if (IS_PLATFORM && !process.env.PLATFORM_PG_META_URL) {
throw new Error('This function can only be called in self-hosted environments')
}
}
diff --git a/apps/studio/proxy.ts b/apps/studio/proxy.ts
index 3ea62613ced6e..b93c28e58fe25 100644
--- a/apps/studio/proxy.ts
+++ b/apps/studio/proxy.ts
@@ -33,6 +33,8 @@ const HOSTED_SUPPORTED_API_URLS = [
]
export function proxy(request: NextRequest) {
+ if (process.env.NEXT_PUBLIC_SELF_HOSTED_PLATFORM === 'true') return
+
if (
IS_PLATFORM &&
!HOSTED_SUPPORTED_API_URLS.some((url) => request.nextUrl.pathname.endsWith(url))
diff --git a/architecture/README.md b/architecture/README.md
new file mode 100644
index 0000000000000..37d31c69575bf
--- /dev/null
+++ b/architecture/README.md
@@ -0,0 +1,8 @@
+###### This `architecture` folder describes the existing architecture of the official supabase cloud. It does NOT represent the present or future architecture desired for this fork, it is just a starting point, a point of inspiration.
+
+##### [Click here to see the research on the Supabase Cloud Architecture](supabase_cloud_architecture_research.md)
+##### [Click here to see the research draft on the potential Traffic One Architecture](supabase_cloud_architecture_research.md)
+
+## Supabase Cloud Architecture Diagram
+
+
diff --git a/architecture/bunny_backend_as_a_service_plan.md b/architecture/bunny_backend_as_a_service_plan.md
new file mode 100644
index 0000000000000..3a3934f86f0ea
--- /dev/null
+++ b/architecture/bunny_backend_as_a_service_plan.md
@@ -0,0 +1,358 @@
+# Building a Multi-Tenant BaaS on bunny.net Infrastructure
+
+## Executive Summary
+
+A Supabase-style, multi-tenant BaaS where bunny.net provides CDN, object storage, edge compute, video, DNS and security (with your own managed Postgres for the data layer, called only from edge functions / Magic Containers — never exposed to end-user apps) is technically feasible and well-supported by bunny.net's API surface.
+
+Key facts:
+
+- **Programmatic provisioning works for everything** (Pull Zones, Storage Zones, Stream Libraries, DNS Zones, Shield/WAF, Edge Scripts, Magic Containers, hostnames, certificates) via a single REST API at `https://api.bunny.net`, with both an OpenAPI spec and an official Terraform provider (`BunnyWay/bunnynet`).
+- **No native multi-tenancy primitive** (no organizations/projects/workspaces). Tenant isolation lives in your control plane.
+- **500-zone-per-account hard cap** on Pull Zones / Storage Zones / Stream Libraries / DNS Zones is the biggest constraint. **Bunny for Platforms (B4P)** lifts this; engage sales at ~300-400 tenants. Migration to B4P is server-side: same account, same API, same resource IDs, no code changes (confirm in-place upgrade with sales).
+- **Authentication is asymmetric**: a single account-level API key controls the control plane; data planes (Storage HTTP API, Stream HTTP API) use per-resource passwords/keys safe to hand to tenants.
+- **Billing/usage data is exposed via API** (`/billing/summary`, `/statistics`, plus per-product metrics), sufficient for passthrough+markup billing per Pull Zone. No webhooks — poll-only.
+
+---
+
+## 1. Multi-Tenancy on bunny.net
+
+### 1.1 Provisioning per-tenant
+
+Single unified Core Platform API at `https://api.bunny.net`, authenticated with `AccessKey` header. OpenAPI spec at `docs.bunny.net/openapi`.
+
+Per-tenant resources:
+
+- **Pull Zones (CDN)** — Full CRUD via `/pullzone`. Manage hostnames (`AddHostname`, `LoadFreeCertificate` for Let's Encrypt, `AddCustomCertificate`), edge rules, blocked IPs, cache settings, per-region geo toggles. Pull-zone JSON exposes `MonthlyBandwidthUsed`, `MonthlyCharges` directly — usable for per-tenant metering.
+- **Storage Zones** — Full lifecycle via `/storagezone`. Each has its own RW password and RO password from the API — perfect for handing tenant-scoped credentials to client SDKs. Data plane at `https://storage.bunnycdn.com/{zone}/...` (or regional hosts) authenticates with the storage password, not the account key.
+- **Stream Video Libraries** — `/videolibrary` CRUD. Each library has its own Stream API key for the separate Stream API at `https://video.bunnycdn.com/library/{libraryId}/...`. Webhooks per library on encoding events.
+- **DNS Zones** — `/dnszone` CRUD with per-record CRUD. Records support `Accelerated: true` (CDN-accelerated CNAME flattening for apex), failover monitors, geolocation routing, weighted routing, and routing through an Edge Script (`ScriptId`).
+- **Edge Scripting** — `/compute/script` for create/deploy/env vars/secrets. Deno runtime, GitHub auto-deploy from `main`, npm imports, WASM. Standalone (own `*.bunny.run` hostname) or middleware (attached to a Pull Zone via `MiddlewareScriptId`). 500ms cold start cap.
+- **Magic Containers** — Dedicated API: Applications, Containers, AutoscalingSettings, ContainerRegistries, Endpoints (HTTP(S) or Anycast TCP/UDP), Volumes, Log Forwarding. Docker registries, per-region scaling, multi-container composition, persistent volumes. **Counts against a separate quota, not the 500 zone cap.**
+- **Shield / WAF** — `/shield/...`: AccessLists, ApiGuardian, BotDetection, DDoS, EventLogs, Metrics, RateLimiting, ShieldZone, UploadScanning, WAF. Custom rules, rate-limit rules, bot detection all CRUDable. WAF in API-only mode included with B4P.
+- **Optimizer** — Per Pull Zone ($9.50/zone/month flat), toggled via Pull Zone update API.
+- **SSL** — `LoadFreeCertificate` (Let's Encrypt), `AddCustomCertificate`, `RemoveCertificate`, `SetForceSSL`. Wildcard SSL auto-issuable when DNS is on bunny.net.
+
+Every primitive needed to provision/teardown per-tenant exists in REST + Terraform.
+
+### 1.2 No sub-accounts / orgs / workspaces
+
+bunny.net has team-member sub-users (humans invited to one shared account), but **no organizations/projects/workspaces, no per-tenant billing entity**. Tenant isolation is entirely your responsibility.
+
+### 1.3 Bunny for Platforms
+
+The program for SaaS/hosting companies wrapping bunny.net's stack. Removes the 500-zone cap (scales to "millions of hostnames"), increases API rate limits, adds per-domain pricing option, BYO wildcard SSL, WAF API-only mode, DDoS for every hostname, dedicated CSE, 100% SLA. Sales-gated (`bunny.net/contact-sales`). No public pricing; expect custom contract with monthly minimum commit.
+
+Customers known to use it: WP Rocket / RocketCDN (12,000+ sites), DreamHost managed CDN.
+
+**Migration path**: B4P runs on the same account/API/resource IDs. Engage at ~300-400 tenants — onboarding takes time.
+
+### 1.4 API rate limits and quotas
+
+- **Default zone caps**: 500 Pull Zones, 500 Storage Zones, 500 Video Libraries, 500 DNS Zones per account. Each independent. B4P removes them.
+- **Per-pull-zone hostname limit**: 10 (raisable on request).
+- **Storage HTTP API**: 100 concurrent connections per IP, 50 concurrent uploads per Storage Zone, 25 max FTP connections per IP. Per-server, multiple servers per region.
+- **Core API rate limits**: Not published as explicit RPM. Implement exponential backoff. B4P significantly increases limits.
+- **Edge Script cold start**: 500ms max.
+
+### 1.5 Authentication model
+
+| Surface | Auth credential | Scope | Multi-tenant use |
+| --- | --- | --- | --- |
+| Core Platform API | Account API Access Key | Full account, no scoping | Server-side only; never expose to tenants |
+| Storage HTTP API | Storage Zone Password (RW) or RO Password | Single Storage Zone | Safe per-tenant; pass RO key to client SDKs |
+| Stream HTTP API | Library-specific Stream API Key | Single Video Library | Safe per-tenant for upload/playback |
+| Statistics / Billing | Account API key | Account-wide | Server-side only |
+
+You can create additional account API keys via `/apikey`, but they inherit account scope — no RBAC. **All tenant CRUD must flow through your control plane.**
+
+### 1.6 Architecture: One-Pull-Zone-Per-Tenant (hard isolation)
+
+- One Pull Zone, one Storage Zone, one optional Stream Library, one Shield Zone, one DNS Zone per tenant.
+- Plus per-tenant Magic Container app (or Edge Script) for their backend code.
+- Pros: Cleanest billing passthrough (`MonthlyCharges` per Pull Zone). Per-tenant cache purges, edge rules, WAF rules, regional pricing tiers. Per-tenant Storage Zone passwords for direct SDK access. Trivial offboarding.
+- Cons: 500-zone cap until B4P. ~5-10 API calls per tenant onboarding.
+
+---
+
+## 2. Metrics & Billing Data
+
+### 2.1 Statistics API
+
+`GET https://api.bunny.net/statistics`
+
+Query params: `dateFrom`, `dateTo` (default last 30 days), `pullZone` (filter, -1 = all), `serverZoneId` (region filter), `hourly` (boolean), plus `loadErrors`, `loadOriginResponseTimes`, `loadOriginTraffic`, `loadRequestsServed`, `loadBandwidthUsed`, `loadGeographicTrafficDistribution`, `loadUserBalanceHistory`.
+
+Response: `TotalBandwidthUsed`, `TotalOriginTraffic`, `AverageOriginResponseTime`, `TotalRequestsServed`, `CacheHitRate`, plus time-series charts (bandwidth, requests, origin shield, errors 3xx/4xx/5xx, geo distribution).
+
+Hourly granularity available. Bunny bills hourly. Per-zone via `pullZone`. Per-region via `serverZoneId` (matters because bunny prices bandwidth across 5 regional tiers: EU/NA, Asia, South America, Africa, Oceania).
+
+### 2.2 Billing API
+
+- `GET /billing/summary` — Monthly per-Pull-Zone summary with `PullZoneId`, `MonthlyUsage` (USD), `MonthlyBandwidthUsed` (bytes). **Easiest passthrough+markup primitive.**
+- `GET /billing/details` — Line items.
+- `GET /billing/summary/pdf` — PDF invoices.
+- `POST /billing/recharge`, `POST /billing/configure-auto-recharge` — Top-ups.
+
+Resources also expose monetary fields directly: Pull Zone (`MonthlyCharges`, `MonthlyBandwidthUsed`), Storage Zone (`StorageUsed`, `FilesStored`, `MonthlyTraffic`), Stream library (`StorageUsage`, `TrafficUsage`).
+
+Magic Containers metrics via per-app Pods/Containers/Endpoints endpoints. Pricing dimensions: CPU-seconds, RAM-GB-hours, NVMe-GB-month, traffic-GB.
+
+### 2.3 Real-time / delay
+
+- Statistics API: 1-5 minute staleness on aggregations.
+- **No webhooks for billing or usage.** Poll-only via API. Stream library encoding webhooks exist.
+- Log Forwarding (Syslog UDP/TCP) is available but not needed — per-zone API gives everything.
+
+### 2.4 bunny.net pricing dimensions to track
+
+- **CDN bandwidth** — Per-region tiered (5 regional tiers). Standard vs Volume Tier. Region toggles per Pull Zone.
+- **Edge Storage** — Standard $0.01/GB/mo (first 2 regions, $0.005/GB/mo additional, up to 9). Edge SSD $0.02/GB/mo per region (up to 14). Free API egress.
+- **Stream** — $0.01/GB/mo storage; transcoding free; bandwidth at CDN rates.
+- **Magic Containers** — CPU $0.02/3600 CPU-sec, RAM $0.005/GB-hour, NVMe $0.10/GB/mo allocated, egress ~$0.01/GB, Anycast IP $2/mo per app.
+- **Edge Scripting** — Request + compute-time based.
+- **Optimizer** — $9.50/Pull Zone/mo flat.
+- **DNS** — Free.
+- **Account-wide** — $1/mo minimum if any zone is active.
+
+### 2.5 Simple metering implementation
+
+Magic Container cron, hourly:
+
+```
+1. GET /billing/summary → write per-PullZoneId rows to Postgres
+2. GET /statistics?pullZone={id}&serverZoneId={region}&hourly=true
+ for each tenant zone → write per-region usage
+3. List storage zones, video libraries → write storage/traffic
+4. List MC apps → write CPU/RAM/storage usage
+```
+
+~50-100 lines of code. Cross-foot daily against `/billing/summary`. No Syslog needed.
+
+---
+
+## 3. Prior Art
+
+No public Supabase-style BaaS on bunny.net was found. Closest prior art:
+
+- **WP Rocket / RocketCDN** — White-label CDN for ~12,000 WordPress sites, on B4P.
+- **DreamHost managed CDN** — Hundreds of thousands of customers, on B4P.
+- **HostBill bunny.net DNS module** — Third-party billing module for white-label DNS reselling.
+- **DanceLab** (Medium case study) — Video pipeline migrated from Firebase to Bunny Stream. Confirms operational practices: store library API keys server-side, never log them; verify webhook signatures with HMAC SHA-256.
+- **ALTCHA Sentinel on Magic Containers** — Notes Bunny Database (libSQL) is a bottleneck for distributed apps; explicitly recommends external Postgres + Redis. **Validates your architecture choice.**
+
+### 3.1 Open-source ecosystem
+
+- **Official Terraform Provider** — `BunnyWay/bunnynet`, ~1.6M downloads, ~70k/mo.
+- **Official OpenAPI spec** — `docs.bunny.net/openapi`. Auto-generates clients for any language.
+- **Official SDKs** — TypeScript, PHP, .NET, Java (Storage only); iOS (Stream); Edge Scripting SDK (`@bunny.net/edgescript-sdk`); Storage SDK (`@bunny.net/storage-sdk`); Magic Containers SDK.
+- **No official JS SDK for Core API** (Pull Zones, DNS, Shield, MC) — generate from OpenAPI or use `fetch`.
+- **Community SDKs** — Go (`simplesurance/bunny-go`), PHP (`ToshY/BunnyNet-PHP` — most complete), TypeScript (`dan-online/bunnycdn-stream`), Python (`dlt`), Elixir.
+- **GitHub Actions** — `BunnyWay/bunnynet-action`, `BunnyWay/bunny-magic-containers-action`.
+
+### 3.2 Pitfalls and gotchas
+
+- **No S3-compatible Storage API** yet (on roadmap).
+- **Cannot disable replication regions** once enabled — must re-create zone.
+- **Cannot rename files in Storage** — re-upload + delete.
+- **Storage Zone → Pull Zone is 1:1 for serving**.
+- **Bunny Database (libSQL) is preview** — not production-ready for distributed apps.
+- **Magic Containers egress** ($0.01/GB) considered expensive vs specialised VPS.
+- **Edge Script 500ms cold start cap** — lazy-load heavy modules.
+- **Edge Scripting SDK quirk** — `@bunny.net/edgescript-sdk` requires manual alias to `./node_modules/@bunny.net/edgescript-sdk/esm-bunny/lib.mjs` for production bundling.
+- **Some Billing endpoints partially undocumented** — `/billing/summary`, `/billing/details` work but not in current OpenAPI.
+- **No team-member API keys in Terraform** — only master account key.
+- **AUP forbids live streaming** without prior consent.
+- **Account suspends on negative balance** — control plane must monitor `/billing/summary` and auto-recharge to avoid mass-tenant outage.
+
+---
+
+## 4. Public API Surface
+
+All endpoints under `https://api.bunny.net` unless noted. `AccessKey: ` header.
+
+### 4.1 Account / Billing / Statistics / API Keys
+
+- `GET /statistics` — performance metrics.
+- `GET /billing/summary`, `/billing/details`, `/billing/summary/pdf`.
+- `POST /billing/checkout`, `POST /billing/auto-recharge`, `POST /billing/applycode`.
+- `GET/POST/DELETE /apikey` — API Keys CRUD (account-scoped only).
+- `GET/PUT /user`, `GET /user/auditlog`.
+- `GET /search`, `GET /countries`, `GET /region`.
+
+### 4.2 Pull Zones (`/pullzone`)
+
+- CRUD: `GET /pullzone`, `POST /pullzone`, `GET/POST/DELETE /pullzone/{id}`.
+- Hostname: `addHostname`, `removeHostname`, `setForceSSL`.
+- SSL: `loadFreeCertificate?hostname=...`, custom cert upload, delete.
+- Edge Rules: `addOrUpdate`, `delete`, `setEdgeRuleEnabled`.
+- Security: blocked IPs, blocked referrers, `setZoneSecurityEnabled`, `resetTokenKey`.
+- Per-zone Stats: WAF, Safehop, Optimizer, OriginShieldQueue.
+- Pull Zone object exposes: `EdgeScriptId`, `MiddlewareScriptId`, `MagicContainersAppId`, `MagicContainersEndpointId`, `StorageZoneId` — Pull Zone routes to MC, Edge Scripts, Storage, or external origin.
+
+### 4.3 Storage Zones (`/storagezone`)
+
+- CRUD on zones plus `resetPassword`, `resetReadOnlyPassword`, `checkAvailability`.
+- Data plane: `GET/PUT/DELETE` on `https://{region}.storage.bunnycdn.com/{zoneName}/{path}`. AccessKey = Storage Zone Password. Wrong region returns 401.
+
+### 4.4 DNS Zones (`/dnszone`)
+
+- CRUD on zones (BIND import supported).
+- Per-record CRUD: A, AAAA, CNAME, TXT, MX, SRV, CAA, PTR, NS, SVCB, TLSA, HTTPS, plus bunny-specific `RDR`.
+- Records: `Accelerated` (CNAME flattening), `MonitorType` (failover), `LatencyZone`, `SmartRoutingType`, `GeolocationLatitude/Longitude`, `Weight`, `ScriptId` (route through Edge Script), `AutoSslIssuance`.
+
+### 4.5 Stream Video Libraries
+
+**Core API** for management: `/videolibrary` CRUD. Returns `ApiKey`, `ReadOnlyApiKey`, hidden `PullZoneId` and `StorageZoneId`, encoder settings, DRM, watermark, encoding tier.
+
+**Stream API** (`video.bunnycdn.com/library/{libraryId}/...`, library ApiKey): videos CRUD, TUS resumable upload, fetch from URL, captions, heatmap, collections, OEmbed, thumbnails, reencode. Webhooks for `VideoQueued/Encoding/Finished/Failed` per library.
+
+### 4.6 Shield API (`/shield`)
+
+ShieldZone CRUD attached to a Pull Zone. WAF (rules, profiles, learning mode), RateLimiting (global counter sync across POPs, per-IP/UA/country/ASN/cookie), BotDetection, AccessLists, DDoS, ApiGuardian, UploadScanning. EventLogs and Metrics endpoints.
+
+### 4.7 Edge Scripting (`/compute/script`)
+
+Resources: Code, EdgeScript, Variable, Secret, Release. Standalone or middleware. GitHub auto-deploy from `main`. Local dev with Deno + `@bunny.net/edgescript-sdk`. Secrets encrypted at rest.
+
+### 4.8 Magic Containers
+
+Resources: Applications, AutoscalingSettings, ContainerRegistries, Containers, Endpoints, Limits, Nodes, Pods, Regions, RegionSettings, Volumes, Log Forwarding.
+
+- Docker images from any registry.
+- Multi-container composition (localhost between services).
+- Per-app autoscaling per region.
+- Reserved Instances (predictable pricing) flagged "coming soon".
+- Syslog log forwarding (UDP/TCP, 10-30s delay).
+- Templates: `BunnyWay/bunnynet-mc-templates`, `BunnyWay/mc-app-with-redis-template`.
+
+### 4.9 Optimizer
+
+Configured per Pull Zone via Pull Zone properties + Edge Rules. Sub-features: Automatic Optimization, Dynamic Images API, Image Classes, Watermarking, Burrow Smart Routing (Preview), HTML Prerender (Preview).
+
+### 4.10 Logging / Webhooks
+
+- Pull Zone access logs: per-zone toggle, permanent storage in a Storage Zone (daily file rotation), real-time Syslog forwarding.
+- Magic Containers Syslog log forwarding per-app.
+- Stream webhooks per library on encoding events.
+- `GET /user/auditlog` for account-level changes.
+- **No general-purpose webhook system for billing, zone-state, hostname-validation, or SSL events.** Poll for SSL issuance status via Pull Zone certificate field.
+
+### 4.11 Dashboard-only / API gaps
+
+- Sub-user / team-member management — no public API.
+- Bunny for Platforms admin features — partially gated to dashboard / CSM.
+- Account closure / payment-method removal — dashboard-only.
+- Live streaming (RTMP) — not supported.
+- S3-compatible Storage API — on roadmap.
+- CDN cache pre-warming — not supported.
+- Per-zone scoped API keys (RBAC) — does not exist.
+
+---
+
+## 5. Architecture (One-Pull-Zone-Per-Tenant)
+
+### 5.1 Per-tenant resource graph
+
+Per tenant onboarding, your control plane provisions:
+
+1. **DNS Zone** (if tenant brings custom domain)
+2. **Storage Zone** — gets RW/RO passwords
+3. **Stream Library** — gets ApiKey/ReadOnlyApiKey (only if tenant uses video)
+4. **Magic Container App** — origin for tenant's backend code; gets `*.b-cdn.net` URL
+5. **Pull Zone** — origin = MC endpoint; attach tenant's hostname; `LoadFreeCertificate` for SSL
+6. **Shield Zone** — attach to Pull Zone for WAF/rate-limiting
+7. **Postgres provisioning** — CREATE DATABASE/SCHEMA + role with scoped permissions
+8. **Inject secrets into MC app**: `DATABASE_URL`, `JWT_SIGNING_SECRET`, `BUNNY_STORAGE_KEY` (RW), `BUNNY_STREAM_KEY`
+9. **Generate tenant credentials bundle** for AI agent / tenant dashboard:
+ - CDN URL: `https://api.tenant.com` (their Pull Zone)
+ - Storage RO key + zone name + region
+ - Stream library RO key + library ID
+ - JWT signing key (public verification key for their frontend)
+
+### 5.2 Tenant app flow
+
+```
+Tenant's React/RN app
+ ├─→ Bunny CDN (their Pull Zone) — static assets
+ ├─→ Bunny Storage (their Zone, RO key) — direct media reads
+ ├─→ Bunny Stream (their library key) — video upload/playback
+ └─→ api.tenant.com (their Pull Zone → their MC app) — all API calls
+ ├─ Authorization: Bearer validated in MC code
+ └─→ your Postgres (tenant-scoped DB/role, never exposed)
+
+Tenant's Next.js SSR (if applicable)
+ └─→ Their own MC app (separate or same) — same flow
+```
+
+### 5.3 Auth model
+
+- **Storage / Stream / CDN reads**: bunny-native auth (Storage RO password, Stream RO key, optional Bunny Token Authentication signed URLs). No proxy needed.
+- **API calls (MC app)**: tenant implements JWT validation inside their MC code using the signing secret you injected. You don't run a shared auth gateway.
+- **Front the MC with a Pull Zone**: gives tenant a custom domain, SSL, Shield/WAF rate limits, optionally Bunny Token Authentication as a first gate before requests hit MC.
+
+### 5.4 Control plane
+
+One shared Magic Container app (or set of apps) running:
+
+- **Provisioning service** — REST calls to `api.bunny.net` to create/delete tenant resources. State in your Postgres (tenant_id ↔ PullZoneId/StorageZoneId/etc.).
+- **Metering cron** — hourly poll of `/billing/summary` and `/statistics` per tenant zone, write to Postgres.
+- **Billing service** — monthly aggregate, apply markup, generate invoices.
+- **SSL/state poller** — every 5 min check certificate status, hostname validation, account balance.
+- **Auto-recharge guard** — monitor `/billing/summary` balance, trigger `POST /billing/recharge` when below threshold to prevent suspension.
+
+### 5.5 IaC strategy
+
+- **Terraform** for *your own* infra: shared MC apps (control plane, metering), shared Storage Zone for logs, base DNS, base Pull Zones.
+- **REST API directly** from control plane for *per-tenant* provisioning. Keep state in your Postgres. Terraform is awkward for runtime per-tenant ops.
+
+---
+
+## 6. MVP Plan
+
+### Phase 0 — Setup
+
+1. Create bunny.net account, generate master API key.
+2. Provision managed Postgres cluster (e.g., Neon, RDS, or self-hosted).
+3. Set up control-plane repo: TypeScript or Go service, Postgres schema (`tenants`, `bunny_resources`, `usage_metrics`, `invoices`).
+4. Generate Bunny API client from OpenAPI spec.
+
+### Phase 1 — Single tenant end-to-end
+
+Goal: one hardcoded tenant with full resource graph working.
+
+1. Provision script that calls REST API in order: Storage Zone → Stream Library → MC app (with hello-world container) → Pull Zone (origin = MC endpoint) → Shield Zone → DNS records → SSL.
+2. MC container template: Node/Bun HTTP server, JWT validation middleware, Postgres connection pool, one example endpoint querying tenant schema.
+3. Postgres provisioning: `CREATE SCHEMA tenant_`, role with scoped grants, inject `DATABASE_URL` as MC secret.
+4. Smoke test: deploy a React app that signs a JWT, calls `api.test.com`, gets data from Postgres.
+
+### Phase 2 — Multi-tenant + control plane
+
+1. Tenant CRUD API (`POST /tenants` triggers full provisioning, `DELETE /tenants/:id` tears down).
+2. Persist all bunny resource IDs in Postgres.
+3. Tenant credentials bundle endpoint — returns CDN URL, Storage RO key, Stream key, JWT signing key.
+4. Idempotency + retry on every bunny API call (provisioning is multi-step; failures must be recoverable).
+5. SSL polling: cron every 5 min, check Pull Zone certificate status, mark tenant ready.
+
+### Phase 3 — Metering + billing
+
+1. Hourly cron: `GET /billing/summary`, `GET /statistics?pullZone={id}&hourly=true` per tenant, write to `usage_metrics`.
+2. Daily reconciliation against `/billing/summary` totals.
+3. Monthly invoice generator: aggregate, apply markup, create invoice row.
+4. Auto-recharge guard: monitor account balance, top up if below floor.
+
+### Phase 4 — Dashboard
+
+1. Customer-facing dashboard: tenant management, view usage/billing, manage custom domains, view logs (proxy from bunny logs).
+2. Admin dashboard: tenant overview, account balance, manual top-up, support actions.
+
+### Critical path checks before launch
+
+- Confirm with bunny sales: in-place upgrade path to B4P from your existing account.
+- Verify `/billing/summary` accuracy on a real tenant for a full billing cycle.
+- Load-test provisioning: time to fully provision one tenant should be <60s.
+- Failure-mode test: what happens if tenant goes negative on usage / your account hits balance floor / a Pull Zone fails SSL issuance.
+- Decide tenant pricing tiers and bunny region exposure (Volume Tier vs Standard Tier vs B4P per-domain).
+
+### When to engage Bunny for Platforms
+
+At ~300 active tenants. Onboarding takes weeks. Don't wait until you hit 500.
\ No newline at end of file
diff --git a/architecture/supabase_cloud_architecture.svg b/architecture/supabase_cloud_architecture.svg
new file mode 100644
index 0000000000000..a0e54c186879a
--- /dev/null
+++ b/architecture/supabase_cloud_architecture.svg
@@ -0,0 +1,163 @@
+
\ No newline at end of file
diff --git a/architecture/supabase_cloud_architecture_research.md b/architecture/supabase_cloud_architecture_research.md
new file mode 100644
index 0000000000000..be5412f85d0fe
--- /dev/null
+++ b/architecture/supabase_cloud_architecture_research.md
@@ -0,0 +1,133 @@
+# Supabase architecture: a system architect's reference
+
+Supabase is a **service-oriented stack assembled around a single Postgres instance per project**, with a Kong API gateway fronting six independently-deployed backends — Auth (Go), PostgREST (Haskell), Realtime (Elixir), Storage (Node/Fastify), Edge Functions (Rust+Deno), and postgres-meta (Node) — plus an Elixir connection pooler (Supavisor), a Logflare/Vector observability pipeline, and a Next.js dashboard. On Supabase Cloud it runs **exclusively on AWS Graviton (ARM) EC2 across 17 regions**, with a Pulumi-driven control plane, AWS Secrets Manager for secrets, and Cloudflare at the edge. The auxiliary services (Auth, Storage, Realtime, Supavisor) became **multi-tenant fleets in March 2024**; Postgres remains **single-tenant per project on a dedicated EC2 VM** built from Packer/Ansible/Nix AMIs. There is no managed self-serve Multi-AZ HA below Enterprise tier, and a single project is single-region active-passive with optional cross-region async read replicas.
+
+This report walks the architecture top-down: every component and its requirements, how requests flow end-to-end, the cloud's geographic posture, and the lifecycle when a customer clicks "New Project."
+
+## 1. The component inventory
+
+The canonical reference is the self-hosted `docker/docker-compose.yml` in `github.com/supabase/supabase`, which ships **12 services** plus an optional MinIO overlay. Cloud uses the same components but multi-tenants the auxiliaries.
+
+**Core data plane.** PostgreSQL (`supabase/postgres`) is a heavily-extended Postgres 15.8 / 17 build (recent pin `supabase/postgres:15.8.1.060`). It bundles ~50 extensions including **pgvector 0.8.0, pg_graphql, pg_jsonschema, pg_cron, pg_net 0.9.2, pgsodium 3.1.x, supabase_vault 0.3.x, pgmq 1.4.4, pg_tle, wrappers 0.5.4, pgaudit, pgroonga/pg_search, postgis, plv8, hypopg, pgtap, wal2json, supautils** (\"soft superuser\"), and the standard contrib set. `shared_preload_libraries` includes `pg_stat_statements, pg_cron, pg_net, pg_tle, pgaudit, supautils, pgsodium, auto_explain, plan_filter`. Ten internal roles are created — `postgres, supabase_admin, authenticator, anon, authenticated, service_role, supabase_auth_admin, supabase_storage_admin, supabase_functions_admin, supabase_replication_admin, dashboard_user, pgbouncer`. Default port **5432**; persistence on `/var/lib/postgresql/data` plus a `db-config` volume that holds the pgsodium key (must survive restarts). A separate `_supabase` database houses internal `_analytics` (Logflare), `_supavisor` (pooler), and `_realtime` schemas to keep operational data out of user space. Self-hosted recommendation is **8+ GB / 4 vCPU / 80 GB SSD** for the whole stack.
+
+**Supavisor** (`supabase/supavisor:2.5.1`) is the Elixir/Phoenix multi-tenant Postgres connection pooler that replaced PgBouncer in March 2024 cluster-wide. Built on OTP/Cowboy/Ecto with **Cloak/cloak_ecto** for at-rest tenant-config encryption (AES-256-GCM via a 32-byte `VAULT_ENC_KEY`) and **Partisan** for clustering. Listens on **5432 (session mode), 6543 (transaction mode), 4000 (HTTP/metrics), and 20100 (Partisan inter-node)**. Tenant identity is parsed from the username (`postgres.`), allowing one IP/cluster to serve thousands of tenants. The "1M connections" benchmark blog reports **~500k clients per 64-core node**. Requires `SECRET_KEY_BASE ≥64 chars`, `VAULT_ENC_KEY` exactly 32 bytes, and `API_JWT_SECRET`/`METRICS_JWT_SECRET`. PgBouncer is no longer in default compose but is offered as a **co-located dedicated pooler** for paid Cloud projects.
+
+**Kong** (`kong:2.8.1` / cloud uses 3.9.1) runs db-less with declarative `/usr/local/kong/kong.yml`, on **8000 (HTTP) and 8443 (HTTPS)**. Plugins enabled: `request-transformer, cors, key-auth, acl, basic-auth, request-termination, ip-restriction, post-function`. Envoy is officially supported as an alternative on self-host. Cloud uses a closed-source edge gateway behind Cloudflare.
+
+**PostgREST** (`postgrest/postgrest:v12.2.11`, Haskell) on port **3000** auto-generates a REST API by introspecting Postgres. Connects as the `authenticator` role and per-request executes `SET LOCAL ROLE ; SET LOCAL request.jwt.claims = ''`. Exposed schemas default to `public, storage, graphql_public`; GraphQL is delegated to `pg_graphql` via `/rpc/graphql`.
+
+**Auth / GoTrue** (`supabase/gotrue:v2.171.0`, Go) on port **9999**. Owns the `auth` schema (`users, identities, sessions, refresh_tokens, audit_log_entries, flow_state, mfa_factors`) under `supabase_auth_admin`. Configured via ~80 `GOTRUE_*` env vars covering JWT signing, SMTP, OAuth providers (Google, GitHub, Apple, Discord, Azure, etc.), SAML, MFA, anonymous users, and webhook hooks (`GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_*`, `SEND_SMS`, `SEND_EMAIL`). Default access-token TTL **3600s**, audience `authenticated`. Newer versions support **asymmetric JWTs (ES256/RS256)** with JWKS at `/auth/v1/.well-known/jwks.json`.
+
+**Realtime** (`supabase/realtime:v2.34.47`, Elixir/Phoenix) on **4000**. Three modes: **Postgres Changes** (reads `wal2json` from publication `supabase_realtime` via a polled replication slot, applies WALRUS RLS check per row per subscriber, fans out via Phoenix.PubSub PG2), **Broadcast** (ephemeral pub/sub, optionally backed by `realtime.messages` partitioned table for "Broadcast from Database"), and **Presence** (in-memory CRDT via `Phoenix.Presence`). Tenant ID is parsed from the request **subdomain's first label**, hence the unusual container naming `realtime-dev.-realtime`. Encrypts tenant secrets with a 16-byte `DB_ENC_KEY`. Cluster-mode uses Erlang distribution; every region has at least two nodes.
+
+**Storage API** (`supabase/storage-api:v1.22.7`, Node 20 / Fastify 5) on **5000**. Stack: `pg`, `knex`, `pg-boss` (Supabase fork, exactly-once branch) for background jobs, `ioredis` (optional rate limit), `jose` for JWT, `@aws-sdk/*`, `pino` + `pino-logflare`. Two backends: `file` (local volume) and `s3` (AWS S3, R2, MinIO, RustFS). Owns the `storage` schema (`buckets, objects, s3_multipart_uploads, prefixes`) under `supabase_storage_admin`. Implements **TUS resumable uploads** (mandatory 6 MB chunks to align with S3 multipart minimum), Postgres advisory locks (`PgLock`) for concurrent-upload safety, and an **S3-compatible wire endpoint** at `/storage/v1/s3/*` (Sig V4 — Kong's request-transformer is intentionally bypassed on this route to preserve the AWS signature). Delegates RLS-checked metadata reads to PostgREST.
+
+**imgproxy** (`darthsim/imgproxy:v3.8.0`, Go + libvips) on **5001**, reads either the local Storage volume or signed URLs from Storage API; provides on-the-fly resize/crop/format conversion with WebP auto-detection.
+
+**Edge Runtime** (`supabase/edge-runtime:v1.67.4+`, Rust host on `deno_core`, Deno 2.x) on **8081/9000**. Two-tier **V8 isolate model**: a privileged **Main Worker** authenticates the JWT and routes; a sandboxed **User Worker** runs the user's `index.ts` with **150 MB memory and 60s wall-clock defaults**. Policies `per_request | per_worker | oneshot` are configured in `supabase/config.toml`. Cold start 0–5 ms because functions are pre-bundled to **ESZip**. `verify_jwt` is global in self-host; per-function in Cloud.
+
+**postgres-meta** (`supabase/postgres-meta:v0.88.9–0.96.3`, Node/Fastify) on **8080**. REST wrapper over the Postgres system catalog, used exclusively by Studio. Encrypts saved connection strings with `PG_META_CRYPTO_KEY` (≥32 chars). README explicitly warns against direct exposure.
+
+**Studio** (`supabase/studio:2026.04.08-sha-205cbe7`, Next.js 14 / React 18) on **3000**. Stateless; talks to postgres-meta on 8080, Kong on 8000, Logflare on 4000. Behind Kong basic-auth (`DASHBOARD_USERNAME`/`PASSWORD`). Optional `OPENAI_API_KEY` enables the SQL AI assistant.
+
+**Logflare** (`supabase/logflare:1.12.0`, Elixir, acquired 2021) on **4000** internal. Multi-backend (Postgres `_analytics` schema or BigQuery on Cloud). Critically, `studio, kong, auth, rest, realtime, storage, meta, functions, supavisor` all `depends_on: analytics → service_healthy` — meaning Logflare must boot first.
+
+**Vector** (`timberio/vector:0.28.1-alpine`, Rust) on **9001**, tails the Docker socket and ships per-container stdout to Logflare.
+
+**Cloud-only / not in OSS compose:** managed PITR backups, Read Replicas, Multi-AZ HA (Enterprise), Branching, the Management API, Vector Buckets / Analytics Buckets, ETL, the global Edge Functions gateway, the Smart CDN, and PrivateLink. Internal cloud-only routing components like `functions-relay`/`postgrest-relay`/`deno-relay` mentioned in older blog posts have no public source.
+
+**Secret-length contracts to remember:** `SECRET_KEY_BASE` ≥64, `VAULT_ENC_KEY` exactly 32 bytes, `JWT_SECRET` ≥32, `PG_META_CRYPTO_KEY` ≥32, `LOGFLARE_*_ACCESS_TOKEN` ≥32.
+
+## 2. End-to-end request flows
+
+The whole stack hinges on **a single shared JWT secret (or JWKS in newer asymmetric mode)** distributed to every verifying service: `GOTRUE_JWT_SECRET` (signs), `PGRST_JWT_SECRET`, `API_JWT_SECRET` (Realtime), `PGRST_JWT_SECRET` (Storage), `JWT_SECRET` (Edge Runtime), `AUTH_JWT_SECRET` (Studio). The `anon` and `service_role` API keys are themselves JWTs with `role` claims set accordingly, signed with the same secret.
+
+**Kong route table** (from `cli/internal/start/templates/kong.yml` and `docker/volumes/api/kong.yml`, all routes `strip_path: true`):
+
+| Path | Upstream | Plugins |
+|---|---|---|
+| `/auth/v1/verify`, `/callback`, `/authorize` | `auth:9999` | cors only (open) |
+| `/auth/v1/*` | `auth:9999` | cors, key-auth, acl(anon\|admin), request-transformer (Authorization rewrite) |
+| `/rest/v1/*` | `rest:3000` | cors, key-auth, acl(anon\|admin), request-transformer |
+| `/rest-admin/v1/*` | `rest:3001` | cors, key-auth, acl(admin) |
+| `/graphql/v1` | `rest:3000/rpc/graphql` | + request-transformer adds `Content-Profile: graphql_public` |
+| `/realtime/v1/*` (ws) | `realtime:4000/socket` | request-transformer copies `?apikey=` query → `x-api-key` header (browsers can't set headers on WS upgrades) |
+| `/realtime/v1/api` | `realtime:4000/api` | cors, key-auth, acl |
+| `/storage/v1/s3/*` | `storage:5000/s3` | cors only (preserves AWS Sig V4) |
+| `/storage/v1/*` | `storage:5000` | cors, key-auth, acl, request-transformer |
+| `/functions/v1/*` | `edge-runtime:8081` | cors, request-transformer, **read_timeout 150000ms** |
+| `/pg/*` | `meta:8080` | cors, key-auth, acl(admin only) |
+| `/analytics/v1/*` | `logflare:4000` | cors |
+| `/` (catch-all) | `studio:3000` | cors, basic-auth (DASHBOARD) |
+
+Two Kong **consumers** are defined: `anon` (group `anon`, holds the `SUPABASE_ANON_KEY` and the new `SUPABASE_PUBLISHABLE_KEY`) and `service_role` (group `admin`, holds `SUPABASE_SERVICE_KEY` and `SUPABASE_SECRET_KEY`). With the asymmetric API-key flow rolled out in 2025, Kong uses a Lua expression in `request-transformer` to **swap an opaque `sb_publishable_*`/`sb_secret_*` API key for a short-lived gateway-signed JWT** before forwarding upstream — this is the substitution mechanism documented in `self-hosted-auth-keys`.
+
+**Auth issuance.** A `POST /auth/v1/token?grant_type=password` arrives at Kong → key-auth → request-transformer → `auth:9999`. GoTrue verifies bcrypt against `auth.users.encrypted_password`, inserts into `auth.sessions` and `auth.refresh_tokens`, optionally invokes a Postgres-function `Custom Access Token Hook` to inject claims, and returns `{access_token, refresh_token, expires_in: 3600, user}`. The access-token claims include `sub` (→ `auth.uid()`), `aud: "authenticated"`, **`role: "authenticated"`** (the pivotal claim — it determines the Postgres role on every downstream request), `email`, `session_id`, `is_anonymous`, `app_metadata`, `user_metadata`, `aal`, `amr`, plus `iss`/`exp`/`iat`.
+
+**PostgREST role mapping.** Per request, PostgREST connects as the **NOINHERIT** `authenticator` role and runs in a transaction: `BEGIN; SET LOCAL ROLE ; SET LOCAL request.jwt.claims = ''; ; COMMIT;`. No JWT → `SET LOCAL ROLE anon` (per `db-anon-role`). Role `service_role` has `BYPASSRLS`. The `authenticator` role must be granted membership of every target role (`GRANT anon, authenticated, service_role TO authenticator`). The `auth` schema exposes helpers `auth.uid()`, `auth.jwt()`, `auth.role()`, `auth.email()` that read these GUCs, enabling RLS like `USING (user_id = (SELECT auth.uid()))`. Best practice from the docs is to scope policies with `TO authenticated` so they're skipped for anonymous traffic. Schema reload after DDL is triggered by `NOTIFY pgrst, 'reload schema'` (Supabase installs an event trigger that emits this automatically on most DDL).
+
+**Realtime Postgres Changes flow.** Postgres WAL → `wal2json` output via the `supabase_realtime` publication → Realtime polls the replication slot → for each WAL row, the **WALRUS function** (Write-Ahead-Log Realtime Unified Security) executes the table's RLS policy in the context of every connected subscriber's role+JWT to decide visibility → matched subscriptions (Erlang process pids) are appended to the row → `Phoenix.PubSub.PG2` routes to those processes → custom `RealtimeChannel.MessageDispatcher` fastlanes serialization. The 8 KB NOTIFY limit is sidestepped because Realtime reads the WAL directly. Risk: WAL accumulates if no consumer is connected to the slot — mitigate with `max_slot_wal_keep_size`.
+
+**Realtime Broadcast from Database** (newer, much cheaper than Postgres Changes for many workloads). A trigger calls `realtime.broadcast_changes(topic, event, op, ...)` which inserts into the partitioned `realtime.messages` table (3-day retention). Realtime tails the WAL of *that single table only*, applying RLS on `realtime.messages` at channel-join time (cached) — so the per-row authorization cost collapses. Subscribers must connect on **private channels** (`{ config: { private: true } }`) and pass a user JWT via `phx_join.access_token`. A "since/limit" replay extension exists in public alpha. The WS protocol is `wss://.supabase.co/realtime/v1/websocket?apikey=&vsn=2.0.0` with 25-second heartbeats and `access_token` events for mid-session JWT refresh.
+
+**Storage upload.** `POST /storage/v1/object/{bucket}/{path}` → Kong → Storage API verifies JWT against `PGRST_JWT_SECRET`, opens a Postgres connection as `authenticator`, sets role and claims, evaluates the RLS INSERT policy on `storage.objects`, uploads bytes to S3 (capturing the ETag as `version`), inserts the metadata row, and queues a pgboss event for webhooks. **Resumable uploads** use TUS (`/storage/v1/upload/resumable`) with each PATCH mapped to an S3 `UploadPart` and Postgres advisory locks preventing concurrent corruption. Reads use `/storage/v1/object/public/...` (public buckets), `/object/authenticated/...` (RLS), or `/object/sign/...` (JWT-signed temporary URL with `?token=`). On Cloud, the **Smart CDN** (Cloudflare) keys cache entries by the S3 ETag, so overwrites invalidate automatically within ~60s without manual purge — this is the central innovation of Storage v3. Image transformations route through imgproxy via signed URLs and stream back through Fastify.
+
+**Edge Function invocation.** Kong forwards to `edge-runtime:8081` with a 150-second read timeout. The Main Worker (one V8 isolate, full env access) authenticates the JWT (when `verify_jwt=true`), then calls `EdgeRuntime.userWorkers.create({servicePath, memoryLimitMb, workerTimeoutMs, envVars})` to spawn or reuse a User Worker — also a V8 isolate but with restricted API surface (no host env vars except allow-listed, no FS write outside `/tmp`). `Deno.env.get()` exposes `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, and `SUPABASE_DB_URL` to user code. The common pattern is to construct a supabase-js client with the user's incoming JWT so RLS continues to apply for DB calls. `EdgeRuntime.waitUntil(promise)` extends worker lifetime past the response, capped by memory/CPU budgets. `--no-verify-jwt` is the deploy flag for Stripe/GitHub-style webhooks where the caller has no Supabase JWT.
+
+**Database webhooks.** A trigger calls `net.http_post(url, headers, body)` from `pg_net`. The pg_net background worker drains `net.http_request_queue` at up to 200 req/s asynchronously and stores results in `net._http_response` (6h retention) — the original DML transaction never blocks on the network call. This is the fundamental difference from a naive `http_post()` from a synchronous trigger and the reason Supabase steers customers to `pg_net` over `http`. Webhooks targeting Edge Functions traverse Kong's `/functions/v1/` route exactly like external clients.
+
+## 3. Cloud geography: 17 AWS regions, single-region projects
+
+Supabase Cloud runs **exclusively on AWS** (re:Invent Dec 1, 2025 announcement) across **17 regions**, all on **Graviton (ARM)** processors — confirmed in 2020 (Paul Copplestone HN: "26% performance increase from Graviton") and re-confirmed at re:Invent 2025. Available regions: us-east-1/2, us-west-1/2, ca-central-1, eu-west-1/2/3, eu-central-1/2, eu-north-1, ap-south-1, ap-southeast-1/2, ap-northeast-1/2, sa-east-1. Africa (af-south-1) is explicitly removed; "general regions" (Americas/Europe/APAC) route to a default specific region but don't yet support Read Replicas or the Management API.
+
+**A single Supabase project is single-region active-passive.** You cannot make a primary multi-region; "changing region" means creating a new project and migrating. **Cross-region failover for the Postgres primary is not a managed feature.** Multi-AZ HA is **Enterprise-only** per the most recent public team statement (GitHub discussion #1504, repeated unanswered in 2025 discussions #36249 and #38227). No blog post announcing self-serve Multi-AZ HA for Pro/Team has shipped as of April 2026.
+
+**Read Replicas** (GA Dec 15, 2023) are the closest thing to multi-region. Up to **2 replicas on small/medium compute, 5 on XL+**, requires Pro+, Postgres 15+, physical backups. Replicas can sit in the **same region or a different AWS region** ("Multi-region deployment: Deploy replicas in regions closer to your users"). Replication is **asynchronous: hybrid streaming + WAL-G/S3 log shipping** — replicas seed from a physical backup, catch up via S3-archived WAL, then switch to streaming directly from primary. Lag is exposed as `physical_replication_lag_physical_replica_lag_seconds`. A **managed Load Balancer** endpoint geo-routes `GET` Data API requests since April 4, 2025 (it was round-robin before); Auth/Realtime/Storage geo-routing is "coming soon."
+
+**Disaster recovery.** With PITR enabled, **WAL files are archived every ~2 minutes via WAL-G to S3, yielding RPO ≤ 2 minutes**. Without PITR, daily physical backups give RPO up to 24h. **No public RTO commitment** outside Enterprise SLAs. Retention by tier: Free = no managed backups (logical pg_dump only); Pro 7 days; Team 14; Enterprise up to 30; PITR window configurable 7–28 days. Cross-region S3 replication of backups is **not documented** — backup S3 region is presumed in-region.
+
+**Control plane vs data plane.** The control plane (`api.supabase.com`, dashboard, billing, project orchestration) is centrally managed; its region(s) and architecture are **not publicly documented**, though dashboard availability has historically been independent of project regional incidents, suggesting separate hosting. The data plane — Postgres (single-tenant) plus the multi-tenant fleets of Auth/Storage/Realtime/Supavisor/Edge — lives in the project's home AWS region.
+
+**Underlying infra.** Postgres on **EC2 Graviton (ARM) AMIs** built with Packer + Ansible + recently Nix for deterministic builds (`amazon-arm64-nix.pkr.hcl`). EBS **gp3 default** (3000 IOPS / 125 MB/s baseline) or **io2** for high-perf workloads, separately scalable from compute. Compute sizes published as Nano → 16XL (up to 64 vCPU / 256 GB RAM); the underlying instance family (likely t4g/m6g/m7g/r7g) is not officially mapped per tier. **No EKS/Kubernetes is publicly mentioned** for Cloud production — the Packer AMI pipeline strongly suggests **bare EC2 instances launched from custom AMIs with custom orchestration**, not Kubernetes or Nomad. The "supabase-on-aws" CDK repo using ECS Fargate is community-only, not production.
+
+**Edge Functions** were originally hosted on **Deno Deploy** (~2022). The runtime was open-sourced as the Rust+deno_core **`supabase/edge-runtime`** in April 2023, and Cloud was **migrated onto Supabase's own infrastructure** during 2023–2024 — mid-2024 NPM-specifier support is a strong signal of completed cutover (Deno Deploy didn't support NPM at the time). Today: a global API gateway (the Relay) does IP-geolocation routing to the nearest Edge Runtime node, where ESZip-bundled functions execute in V8 isolates with 0–5ms cold starts. Region pinning is available via `x-region` header.
+
+**Realtime** runs as a **multi-tenant Elixir/Phoenix cluster, globally distributed** with at least two nodes per region. Realtime nodes connect to your primary Postgres from the closest Realtime region. **Storage** is the same pattern: a multi-tenant Fastify fleet with per-tenant Postgres connections, S3 origin in the project's home region, **Cloudflare Smart CDN** at the edge (the `cf-cache-status` response header is the giveaway).
+
+**Recent timeline.** Read Replicas Dec 2023 → v2 multi-tenant architecture March 2024 (Storage/Realtime/Pooler all multi-tenant, Supavisor replaces PgBouncer cluster-wide) → Read Replica cap raised 2→5 in 2024 → geo-routing for Data API April 2025 → Edge Functions deploy-from-dashboard + Deno 2.1 April 2025 → Edge Functions S3-mounted persistent storage and 97% faster cold starts July 2025 → PrivateLink via VPC Lattice (Beta, same-region only, DB-only) 2025 → AWS re:Invent Dec 2025: 17 regions exclusively on Graviton, Analytics Buckets (Iceberg / S3 Tables), Vector Buckets (S3 Vectors), ETL alpha → Auth becomes OAuth 2.1 / OIDC provider Dec 4, 2025.
+
+## 4. The provisioning lifecycle: T+0 to T+~90s
+
+The single most informative public source on Cloud orchestration is the **Pulumi case study** (`pulumi.com/case-studies/supabase`), corroborated by The New Stack's interview with Supabase's platform engineering team. **Pulumi (TypeScript, via Automation API)** is the IaC engine for both regional platform infrastructure (~1,000–1,500 resources per region, ~80,000 total across 16+ regions) and per-project resources. **AWS Secrets Manager** stores secrets; **GuardDuty + GCP IDS** for security; **AWS EC2 Instance Connect** for ops access. Cloudflare handles edge/WAF/routing; GCP/BigQuery handles only analytics/logs. The often-rumored "Inugami" / Nomad orchestrator name **does not appear in any public Supabase repo, blog, or HN comment** — that hypothesis is unsupported.
+
+**Click "New Project" → Active in roughly 30–90 seconds.** Refine.dev independently reports ~30s; Supabase markets "in seconds" and Pulumi reports >43,000 databases launched daily.
+
+**T+0–2s: Validate and allocate.** Studio's internal API validates org quotas (Free: 2 orgs × 1 active project), then allocates a **`project_ref` of exactly 20 lowercase `[a-z0-9]` characters** (the regex is enforced in Storage's multi-tenant config: `^([a-z]{20}).local.(?:com|dev)$`). This API surface is functionally identical to the public Management API's `POST /v1/projects`, which is what AI builders (Bolt, Lovable, Vercel v0, Figma Make) call to provision instantly.
+
+**T+1–2s: Generate secrets.** Per-project **JWT secret** (HS256 legacy; new asymmetric ECC/RSA signing keys on the modern API-key system), **anon JWT** (`role: "anon"`, signed with the JWT secret, ~5-year exp), **service_role JWT** (`role: "service_role"`), and Storage S3 protocol keys are generated. The DB password comes from the user's New Project form, or is generated. All stored in **AWS Secrets Manager**. Supabase explicitly cannot recover the DB password — it must be reset.
+
+**T+2–30s: Pulumi runs the per-project stack.** EC2 RunInstances, EBS gp3 attach, security groups, IAM, Route53 record (or wildcard match), Secrets Manager entries. The EC2 instance boots from a **pre-baked Supabase Postgres AMI built with Packer + Ansible + Nix**, supporting both x86 and ARM (`amazon-arm64-nix.pkr.hcl`); production runs **Graviton ARM exclusively**. Postgres runs natively under systemd inside the AMI — **not as a Docker container per project**. PostgREST and postgres-meta run as sibling systemd services on the same instance (Ansible tags: `install-pgbouncer`, `install-postgrest`, `install-supabase-internal`). Paid plans get a co-located **Dedicated PgBouncer** alongside Postgres.
+
+**T+30–60s: Schema seeding.** Migrations apply in this order: GoTrue's `auth` schema, Storage's `storage` schema, Realtime's `realtime` schema and the `supabase_realtime` publication (initially empty), `vault`+`pgsodium` extension setup, default roles (`anon, authenticated, service_role, authenticator, supabase_admin, supabase_auth_admin, supabase_storage_admin`, etc.), and default extensions (`pgcrypto, uuid-ossp, pgjwt, pg_stat_statements, pg_graphql, pgsodium, vault, pg_net, pg_cron, pgvector` and others).
+
+**T+45–75s: Register with multi-tenant fleets.** Kong (cloud's NGINX-based gateway, multi-tenant via host header `[.supabase.co`), Auth fleet (per-tenant `GOTRUE_DB_DATABASE_URL`), Storage fleet (`MULTI_TENANT: true`, regex `^([a-z]{20}).local...$` resolves the project ref from host), Realtime (subdomain → tenant), Edge Functions (per-tenant function code), Supavisor (tenant added to shared pool at `aws-0-.pooler.supabase.com`), Logflare/Vector (tenant log routing).
+
+**T+60–90s: DNS and TLS.** Route53 record for `][.supabase.co` is created or matched against the wildcard. Per the March 2021 changelog, Supabase migrated all subdomains to Route53 with custom Let's Encrypt certs. **TLS uses a wildcard `*.supabase.co` Let's Encrypt cert** at the edge; per-project ACM certs are issued on demand only when a Custom Domain or Vanity Subdomain add-on is purchased. Storage uploads use a separate hostname `][.storage.supabase.co` for performance (bypasses Kong path routing).
+
+**T+~90s: Health checks pass.** Studio shows the project as Active; the user receives `{url: "https://][.supabase.co", anon_key, service_role_key}`. Connection endpoints: REST at `/rest/v1`, Auth at `/auth/v1`, Realtime WS at `/realtime/v1/websocket`, Storage at `/storage/v1`, Edge Functions at `/functions/v1`, direct Postgres at `db.][.supabase.co:5432` (IPv6 by default; IPv4 is a paid add-on), pooler at `aws-0-.pooler.supabase.com:5432` (session) or `:6543` (transaction).
+
+**Pause/resume on Free tier.** A project paused after **7 days of inactivity** is restorable for **90 days** post-pause. The mechanism — based on observed multi-minute resume times and Supabase's April 2025 statement about "a new architecture for scale-to-zero databases, zero-downtime upgrades" being upcoming work — is **EC2 stop + EBS retain (or logical backup)**, not a true scale-to-zero per project. Pro plans **cannot be paused**; the EC2 stays on 24×7.
+
+**Branching.** A branch is **a full new project** provisioned via the same code path, billed at Branching Compute Hours starting $0.01344/hr at Micro size. Branches are **schema-only clones** — `./supabase/seed.sql` runs once at branch creation and `./supabase/migrations/*` apply in order. There is no copy-on-write (unlike Neon). The deployment workflow is a DAG: Clone → Pull → Health (≤2 min wait) → Configure → Migrate → Seed → Functions deploy. Preview branches are paused on inactivity and deleted on PR close; persistent branches are long-lived. Branching 2.0 (alpha) decouples branches from GitHub — they can be created from dashboard/CLI/Management API directly.
+
+**The self-hosted equivalence.** A Cloud project is conceptually the OSS `docker-compose.yml` stack, but with **Postgres + PostgREST + postgres-meta (+ optional dedicated PgBouncer)** running as systemd services on a dedicated EC2 VM, while **Auth, Storage, Realtime, Edge Functions, Supavisor, Logflare** are entries in shared multi-tenant fleets keyed by host header / DB connection. Studio is one frontend, not per-project.
+
+## 5. What this means for an architect
+
+Three architectural decisions dominate the rest. **First, the JWT-as-Postgres-role-claim contract**: every service trusts the same JWT signature, PostgREST does `SET LOCAL ROLE` to the JWT's `role` claim, and RLS evaluates on the resulting `auth.uid()`. This means **your Postgres roles and RLS policies are your authorization system end-to-end** — there is no separate IAM. Custom roles work as long as you `GRANT custom_role TO authenticator` and have the JWT's `role` claim match. The new asymmetric API-key scheme (`sb_publishable_*`/`sb_secret_*`) preserves the contract by having Kong mint a short-lived JWT before forwarding upstream.
+
+**Second, single-region single-tenant Postgres is non-negotiable per project.** If you need cross-region writes, multi-region active-active, or sub-Enterprise Multi-AZ HA, Supabase Cloud cannot give it to you in 2026 — you'd self-host, run multiple projects with application-level sharding, or wait. Read Replicas with cross-region geo-routing on the Data API are the only built-in geographic distribution for reads. Edge Functions are the only globally-distributed compute primitive.
+
+**Third, Realtime cost scales with subscriber count when using Postgres Changes** (per-row RLS evaluation for every connected subscriber). For high-fan-out workloads, **Broadcast from Database** is the modern answer: it moves RLS to channel-join time and lets you select exactly which events propagate, sidestepping the WALRUS hotspot. The same logic applies to webhooks: prefer `pg_net` over synchronous `http`, since the former decouples DML transactions from network latency.
+
+The control plane is the area with the **largest publicly-undocumented surface**: there is no published architecture of `api.supabase.com`, no statement on whether Kubernetes/Nomad/custom Go drives EC2 RunInstances, no specific instance-family-to-compute-size mapping, and no published RTO. Pulumi + AWS Secrets Manager + Cloudflare + GCP-only-for-analytics + AMI-based EC2 are the confirmed pieces; the rest is the operational layer Supabase keeps closed.
\ No newline at end of file
diff --git a/architecture/traffic_one_cloud_architecture_research_draft.md b/architecture/traffic_one_cloud_architecture_research_draft.md
new file mode 100644
index 0000000000000..d94fe4edcb3cb
--- /dev/null
+++ b/architecture/traffic_one_cloud_architecture_research_draft.md
@@ -0,0 +1,506 @@
+# Building a Self-Hosted Supabase Cloud Clone on Bare Metal: An Architectural Reference
+
+This report maps every closed-source component of Supabase Cloud (which runs on AWS + Cloudflare + Pulumi) to a concrete open-source replacement suitable for a multi-tenant, multi-DC, no-Kubernetes deployment built on Firecracker microVMs. It is written for a system architect who is going to fork Supabase OSS and operate the platform themselves across ~10 colo locations.
+
+The closest reference architecture in the public cloud world is **Fly.io**, whose engineering blog is the most directly applicable body of prior art: Firecracker on bare metal across many regions, anycast IPs, no Kubernetes, a per-host Go orchestrator (`flyd`), a CRDT-replicated SQLite gossip layer (`Corrosion`), and a Rust proxy (`fly-proxy`). **Koyeb's** "from Kubernetes to Nomad + Firecracker + Kuma" post is the second key reference, because they build on a stack you can actually buy off the shelf. **Neon's** pageserver/safekeeper split is the right reference for branching/PITR. Where this report makes calls, they tend to align with the choices Fly, Koyeb, and Neon have publicly defended.
+
+---
+
+## 1. Recommended stack at a glance
+
+| Supabase Cloud component (closed) | Recommended OSS replacement | Why |
+|---|---|---|
+| Pulumi + custom control plane (provisioning) | **Custom Go control plane (global) + per-host `flyd`-style agent**, with **HashiCorp Nomad + firecracker-task-driver** as the scheduling primitive for stateless multi-tenant services; a custom orchestrator for stateful per-tenant Postgres microVMs | Nomad is proven to 10K+ nodes, federates natively, and Koyeb runs exactly this pattern in production. A per-host Go agent for Postgres microVMs (Fly's `flyd` model) is needed because Nomad's scheduler is not the right place to encode "this volume must follow this VM" semantics. |
+| Pulumi as IaC | **OpenTofu** (or **Pulumi Automation API** with a self-hosted state backend) for *infrastructure*; for *tenant lifecycle*, a Go state machine writing to Postgres, NOT IaC | IaC is a poor fit for thousands of tenants/sec; you want imperative state machines with idempotent steps. Use OpenTofu for racks, networks, hypervisors; use code for projects. |
+| Cloudflare CDN/edge | **BGP anycast** announced via **BIRD2 + GoCast** + **Caddy** (on-demand TLS) or **Pingora**/Envoy at the edge; **Varnish** for HTTP caching; **bunny.net** or **Fastly** if you want hosted edge while keeping origins self-hosted | This is the hardest replacement; expect to keep a hosted CDN in front for at least year one. |
+| Cloudflare DDoS / WAF | **Coraza WAF** + **OWASP CRS**; **FastNetMon** + RTBH for L3/L4; transit DDoS scrubbing from upstreams (Voxility, Path.net) or Hivelocity's included filtering | Pure DIY DDoS at >50 Gbps is hard; lean on transit providers. |
+| AWS S3 | **Garage** for geo-replicated user blobs; **MinIO** or **Ceph RGW** for high-throughput regional pools; **SeaweedFS** for billions-of-small-files cases | Garage is purpose-built for multi-DC over plain Internet; MinIO has the most operational surface but recent license/feature concerns post-2024; Ceph RGW is the most fully-featured S3 but the biggest operational ask. |
+| AWS Secrets Manager | **OpenBao** (Linux Foundation Vault fork, MPL 2.0); use Namespaces for per-tenant trees | OpenBao 2.x has Namespaces (formerly enterprise-only), is API-compatible with Vault, and has no BSL non-compete clauses — important if you are *operating a SaaS* that competes with HashiCorp's hosted offering. |
+| Route53 | **PowerDNS Authoritative** with MariaDB/Galera or LMDB backend, anycasted via BIRD2 at every POP; PowerDNS REST API for programmatic record creation | Battle-tested for hosting providers; first-class API; the typical pattern at hosting companies. |
+| Let's Encrypt automation | **Caddy** on-demand TLS for per-tenant custom domains; **lego** or **acme.sh** for wildcard certs via DNS-01 against PowerDNS | Caddy's on-demand TLS is explicitly the same mechanism used by SaaS like Render, Vercel, and many custom-domain platforms. |
+| WAL-G + S3 (PITR) | **WAL-G unchanged**, pointed at Garage/MinIO; consider **pgBackRest** for tenants >500 GB; cross-region replication via Garage zones or MinIO site replication | Supabase already uses WAL-G; just swap the backend. |
+| Logflare + BigQuery | **Logflare** with **ClickHouse backend** (now supported), fed by **Vector** (already in Supabase OSS) | ClickHouse is the de-facto OSS log warehouse; Logflare itself is open source and self-hostable. |
+| Metrics | **VictoriaMetrics cluster** (multi-tenant native) or **Prometheus + Mimir** | VictoriaMetrics has built-in `accountID` multi-tenancy that matches the per-project model. |
+| Tracing | **Grafana Tempo** | Object-storage backed, pairs cleanly with Vector + ClickHouse. |
+| Cross-DC overlay | **WireGuard** mesh managed by **Headscale** (Tailscale control-plane fork) for the operator/control plane; **Nebula** if you want a more scalable lighthouse-based mesh; **Consul Connect** for service mesh between control-plane services | For the data plane between tenant microVMs, prefer plain L3 over your colo backhaul + IPsec/WG only at edges. |
+| Cross-DC state | Postgres for the **global control plane** (single primary, regional read replicas); a **gossip/CRDT layer** (Corrosion or NATS JetStream KV) for *fast-changing* placement state | Fly.io explicitly warns against putting routing/placement state through global Raft; that lesson cost them outages. Use Postgres for billing/projects, gossip for "where does machine X live right now." |
+
+---
+
+## 2. Provisioning & orchestration: the deepest part of the design
+
+### 2.1 Why not Kubernetes (validating the user's instinct)
+
+Koyeb's post-mortem on moving off Kubernetes to Nomad + Firecracker is the canonical justification: K8s requires a full cluster + control plane per region, doesn't natively span DCs, has a fast release cadence that becomes a full-time upgrade job at scale, and the Firecracker-on-K8s integration story (Kata Containers, KubeVirt) adds layers without solving the per-tenant Postgres-with-persistent-disk problem cleanly. Fly.io explicitly replaced Nomad with their own `flyd` because they wanted *each host* to be the source of truth for its own workloads, not a centralized scheduler. That ownership model is what makes the platform tolerant of partitions across 10 datacenters.
+
+### 2.2 Firecracker vs Cloud Hypervisor for tenant Postgres
+
+Firecracker is optimized for ephemeral serverless functions: ~125 ms boot, exactly five virtio devices (net, block, vsock, serial, keyboard), no GPU/USB passthrough, no live migration, no CPU/memory hotplug. The original Firecracker paper (Agache et al., NSDI 2020) explicitly notes that Firecracker's block I/O implementation is serial (no flush-to-disk in early versions, currently limited to ~13K IOPS per guest at 4 KB) — that is fine for ephemeral compute but is something to test for production Postgres workloads.
+
+For a **Postgres-per-tenant** architecture, **Cloud Hypervisor** is the more honest choice for most tenants:
+- ~200 ms boot vs ~125 ms — irrelevant when the VM lives for weeks/months
+- CPU/memory hotplug (you want this for paid-plan upgrades without restart)
+- vhost-user-blk for higher-IOPS persistent storage
+- vfio passthrough for NVMe if you need it
+- Live migration (still maturing, but exists)
+- Same Rust/KVM-based security story; Kata Containers defaults to Cloud Hypervisor for exactly these reasons
+
+**Concrete recommendation:** use Firecracker for ephemeral workers (Edge Functions / Deno isolates; Realtime channels; pooler shards), and Cloud Hypervisor for the long-lived per-tenant Postgres microVMs. Your orchestrator should abstract the VMM behind a `MicroVM` interface so you can swap. Fly.io itself has been gradually moving away from pure Firecracker for stateful workloads (their LSVD object-storage-backed disks story in the "Sprites" blog post acknowledges that Firecracker's IO path is not adequate for "a hot Postgres node in production").
+
+### 2.3 Nomad as the substrate, custom controller for stateful tenants
+
+Use Nomad for everything that is *not* a per-tenant Postgres VM:
+- Stateless multi-tenant fleets: GoTrue (Auth), PostgREST, Storage API, Realtime, Supavisor, Edge Functions runner, Kong/Envoy
+- Cron-style jobs (backup verifications, log rotations)
+- The control-plane services themselves
+- Optionally, ephemeral Firecracker worker microVMs for untrusted user code (Edge Functions)
+
+Federate Nomad as **one region per datacenter** (region = DC), each with 5 servers, joined into a single federated set via `nomad server join`. Don't try to run one global Raft — Nomad's federation is precisely designed so each region has its own Raft and only routes cross-region requests when needed. This matches HashiCorp's published reference architecture and Koyeb's experience.
+
+For Firecracker integration in Nomad:
+- The `cneira/firecracker-task-driver` exists but has been stuck on Firecracker 0.25.x; you will likely fork or maintain a private build. Plan to spend ~1 engineer-quarter on a Cloud-Hypervisor task driver. Koyeb wrote their own Firecracker driver and considered it a reasonable lift.
+- Use **ZFS zvols** for guest rootfs (the canonical pattern in the firecracker-task-driver README); ZFS snapshots become your branching primitive (see §8).
+- Use `tc-redirect-tap` + a CNI chain (`ptp` + `firewall` + `tc-redirect-tap`) for guest networking so Nomad allocates host TAP devices.
+
+For per-tenant Postgres VMs, do **not** use Nomad as the scheduler. Instead, model after Fly's `flyd`:
+
+> "On thousands of beefy 'worker' servers in our fleet, each `flyd` is solely responsible for its own state — every server is the source of truth for its own workloads, without a global top-down orchestrator. Under the hood, flyd is a specialized database server that durably tracks the steps in a series of fine state machines, like 'create a Fly Machine' or 'cordon off an existing Fly Machine'." — *Fly Machines / Making Machines Move*
+
+Concretely:
+
+**Per-host agent (call it `pgvmd`)**: a single Go binary on each Postgres-host machine, owning a local BoltDB or SQLite of its own VMs. Exposes a small gRPC API: `CreateVM`, `StartVM`, `SuspendVM`, `ResumeVM`, `SnapshotVM`, `CloneVM`, `DestroyVM`, `AttachDisk`. All operations are durable finite state machines persisted to local disk before any side effect. This is exactly Fly's `flyd` design and is the right pattern.
+
+**Regional placement service**: a stateless Go service per region that picks which host gets the new VM (bin-packing on CPU/RAM/disk, anti-affinity by tenant tier, capacity reservations). Talks to local `pgvmd` instances over mTLS. Reads available capacity from a fast gossip layer (Corrosion or NATS JetStream KV).
+
+**Global control plane**: a Go service with Postgres as its source of truth. Holds projects, organizations, billing, IAM, the JWT key catalog, and the tenant→region assignment. Exposes a Management API equivalent to Supabase's `api.supabase.com`.
+
+### 2.4 Replacing Pulumi specifically
+
+Pulumi is used by Supabase Cloud for two distinct things, and these need different replacements:
+
+1. **Static infra (racks, networks, hypervisor pool, DNS root, AWS-equivalent inventory)** → **OpenTofu** with workspaces per region. State backend in PostgreSQL (OpenTofu supports a Postgres state backend) or in Garage/MinIO with state locking via DynamoDB-equivalent (use Postgres advisory locks). Avoid Terraform Cloud / Pulumi Service.
+
+2. **Per-tenant resources (microVMs, DNS records per project, certs, routing rules)** → **NOT IaC**. Terraform/OpenTofu/Pulumi state files become a liability above a few thousand managed resources (state locking, plan times, blast radius). Instead, use a Go control-plane service with idempotent operations against the underlying APIs (PowerDNS API, Caddy admin API, your `pgvmd` API, Vault/OpenBao, OpenBao, Garage admin API, etc.). This is what Render, Fly, Railway, and Supabase itself do.
+
+If you want IaC ergonomics for the imperative path, Pulumi's **Automation API** does work without the Pulumi Service — you can self-host state in S3-compatible storage. But honestly, at this scale you are writing a control plane, not running IaC; embrace it.
+
+**Crossplane is explicitly off the table** because (a) it requires Kubernetes and (b) its reconciliation model has the same scaling pathologies as plain IaC at high tenant counts.
+
+### 2.5 Tenant lifecycle state machine
+
+The project lifecycle, modeled as durable FSMs in the global control plane (Postgres) and the regional `pgvmd`:
+
+```
+Pending → Provisioning → Active ⇄ Paused → Deleted
+ ↓ ↓ ↓
+ Failed Migrating Restoring
+```
+
+Each transition is composed of *local* FSM steps on the host (Fly's pattern):
+- `CreateZFSVolume` → `WriteRootfs` → `RegisterCNINetwork` → `LaunchFirecracker` → `WaitForPgReady` → `RunBootstrapMigrations` → `WriteSecretsToVault` → `RegisterInRouter` → `WriteDNSRecord` → `IssueTLSCert` → `MarkActive`.
+
+Each step is recorded in `pgvmd`'s BoltDB before it runs and after it completes, with a rollback or retry path. The global control plane only sees coarse-grained transitions; the *fine-grained* steps are owned by the host. This is what makes the system tolerant of control-plane downtime.
+
+### 2.6 What to put in which state store
+
+This is where Fly's hard-won lesson matters most: **don't put everything in one store**.
+
+| Data | Store | Reason |
+|---|---|---|
+| Projects, orgs, billing, IAM, JWT JWKS, tenant→region mapping | **Postgres** (the global control plane DB itself, with Patroni or pg_auto_failover for HA, replicated to a warm standby DC) | Strong consistency, transactions, joins, audit trails |
+| "Where is machine X right now?" health, rapidly-changing capacity numbers, current resident set per host | **Corrosion** (CRDT-over-SQLite gossip) or **NATS JetStream KV** | Fly's blog explains why this can't go through global Raft — they saturated their uplinks the first time they tried. Eventually-consistent gossip is the right tool. |
+| Per-host VM state of truth | **Local BoltDB/SQLite on each host (pgvmd)** | Survives control-plane outages; the host is the source of truth for its own workloads. |
+| Secrets | **OpenBao** with Raft storage, replicated as performance secondaries to each region | Per-region read locality, central write |
+| Logs | **ClickHouse cluster** in each region | Bulk ingest, retention tuning |
+| Metrics | **VictoriaMetrics cluster** | Multi-tenant native |
+
+CockroachDB / FoundationDB are tempting but add operational load you do not need if you split the problem like this. Keep Postgres for "things you'd use a SQL database for anyway" and gossip for "things that change every second per host."
+
+---
+
+## 3. Edge / CDN replacement
+
+This is the *hardest* part of leaving AWS+Cloudflare. Be honest with yourself about it.
+
+### 3.1 What you actually need
+
+Supabase Cloud's edge is roughly:
+1. Anycast TCP/HTTPS termination near the user
+2. WAF + bot mitigation
+3. Smart CDN (cache-on-ETag, useful for Storage)
+4. Per-tenant custom domain TLS termination
+5. Routing to the correct project's region
+
+### 3.2 Anycast on Hivelocity-style colo
+
+You need:
+- **Your own ASN** (apply via ARIN / RIPE; 6–10 weeks)
+- **A /24 IPv4 + /48 IPv6** (PI space; ARIN waitlist or buy on the market — IPv4 is now ~$50/IP)
+- **BGP sessions** to upstream transit at every POP
+
+Open-source pieces:
+- **BIRD2** as the BGP daemon on each edge host
+- **GoCast** (mayuresh82/gocast) as a higher-level controller that announces/withdraws VIPs based on health checks; integrates with Consul for autodiscovery
+- **ExaBGP** if you prefer Python-based programmatic control
+- **ECMP** on the upstream router for in-POP load balancing
+
+This pattern is documented by Andree Toonk, Equinix Metal, NetActuate, and the Packetframe APNIC blog post — all of them describe near-identical setups. You should expect to spend 1 senior network engineer for ~2 quarters to get clean anycast working in 10 POPs, and you will probably contract with a consultancy for the BGP peering relationships.
+
+### 3.3 DDoS
+
+Realistic split:
+- **L3/L4 volumetric**: rely on transit upstreams (Hivelocity offers basic DDoS filtering; for serious capacity contract with **Voxility** or **Path.net** for scrubbing). This is the part you cannot do yourself below ~100 Gbps without significant capex.
+- **L3/L4 detection + RTBH triggering**: **FastNetMon Community/Advanced** consuming sFlow/IPFIX from your edge routers, advertising blackhole routes via BIRD.
+- **L7 / application**: **Coraza WAF** (Go, modern Apache 2.0) with **OWASP CRS** rules, embedded into Caddy as a module or run as a sidecar. ModSecurity v3 still works but Coraza is the actively-developed path.
+
+### 3.4 Edge proxy
+
+Three viable choices:
+
+- **Caddy** — strongest fit for the *custom-domain* problem. On-demand TLS with the `ask` endpoint pattern is exactly what you need (Caddy's docs explicitly position this as "the secret sauce of many SaaS products that offer custom domains," and the community thread documents one user serving 250K domains on a single Caddy host). Pair with the [`caddy-l4`](https://github.com/mholt/caddy-l4) module if you need TCP-level routing.
+- **Cloudflare Pingora** (open-sourced 2024) — Rust, designed for the same workload Cloudflare runs internally. Higher ops bar, but if you are at a scale where Caddy's Go runtime overhead matters, Pingora is the upgrade path.
+- **Envoy** — heaviest, but if you need xDS dynamic config with no restarts (which you will at thousands of tenants), Envoy is purpose-built. Use **go-control-plane** to push routes to Envoy from your control plane.
+
+**Recommended split:** Caddy at the very edge handling on-demand TLS and HTTP routing for *.your-platform.com plus customer domains; Envoy (or Caddy itself with the `reverse_proxy` directive and dynamic upstreams) doing intra-region routing to tenant pods.
+
+### 3.5 CDN for Supabase Storage's "Smart CDN" feature
+
+Supabase's Smart CDN works by caching by *S3 ETag* so cache invalidates automatically when the underlying object changes. To replicate:
+
+- Put **Varnish** (or Caddy with the `cache-handler` module backed by a local Souin/SQLite) at each edge POP.
+- Use the `ETag` header from your origin (Garage/MinIO emit this natively) as part of the cache key — Varnish VCL can do this in a few lines.
+- For purges, your control plane writes the new ETag and the proxy revalidates on the next request.
+
+Pragmatically, **bunny.net** is what most "we're not Cloudflare" SaaS use; it's $0.005–0.01/GB and has 100+ POPs, supports custom origin headers, and has a fast purge API. Use it as origin shield in front of your Garage clusters in year one and revisit DIY edge caching in year two when you actually have the traffic to justify it.
+
+---
+
+## 4. S3-compatible object storage
+
+### 4.1 Quick selection guide
+
+| Workload | Recommended |
+|---|---|
+| User-uploaded blobs across 10 DCs, mixed sizes, eventual consistency tolerable | **Garage** |
+| High-throughput regional pool (per-region tenant Storage), strong consistency, billions of objects | **MinIO** (still OSS under AGPLv3 — but watch the post-2024 commercial direction) or **Ceph RGW** |
+| Tenant database backups (WAL-G target) | **Garage** with 3-replica across geos OR **MinIO** with site replication |
+| Logs / metrics object backend (Tempo, Mimir, Loki) | **MinIO** for local-DC, replicate cold to Garage |
+| Billions of small files (image thumbnails, avatars) | **SeaweedFS** |
+
+### 4.2 Garage (the most aligned with your architecture)
+
+Garage by Deuxfleurs is purpose-built for "small-to-medium S3 over the open Internet across multiple datacenters." Key properties:
+- No consensus protocol (no Paxos/Raft); uses Dynamo-style replication + CRDTs
+- Replication mode `3` keeps copies in 3 different zones
+- Benchmarks show Garage massively outperforms MinIO when nodes have high RTT between them (because MinIO's Raft pays the RTT cost on every write)
+- Native multi-DC concept ("zones") that maps perfectly to your 10 colos
+- Provides a website-from-bucket feature (which MinIO and Ceph do not!) — useful if you ever offer Vercel-style static hosting
+- REST admin API, Prometheus metrics, OpenTelemetry tracing
+- Supports S3 multipart (you need this for tus-resumable uploads in Supabase Storage)
+
+Garage's intentional non-goals: extreme single-node throughput and erasure coding (it does plain replication only). For Supabase Storage's typical workload (user images and PDFs in the kilobyte-to-megabyte range) this is fine. If you have customers uploading 100 GB video files, route those to a MinIO regional pool instead.
+
+### 4.3 MinIO
+
+Production patterns:
+- **Erasure coding** EC:4 (4 parity per 16 disks) for the canonical setup
+- **Multi-site replication** via `mc replicate` for cross-DC; this is async and works over the Internet
+- License: AGPLv3 — fine for a SaaS that doesn't redistribute MinIO. Note that since 2024 MinIO has been more aggressive about pushing enterprise features (caching, KES) and removing some OSS features (the Console UI was stripped down). Keep an upgrade lock.
+
+### 4.4 Ceph RGW
+
+- Most fully featured S3 (full ACLs, IAM, lifecycle, object lock, multi-tenancy via tenant-prefixed buckets, true erasure coding with k+m, bucket notifications via SNS/Kafka)
+- Multi-site replication via `radosgw-multisite`
+- The operational ask is *real*: budget 1–2 dedicated SREs. Cephadm (not Rook, since you're skipping K8s) has matured and is now the recommended deploy method.
+- Use it where you'd otherwise be tempted to also run a separate block-storage layer — Ceph gives you RBD (block) + RGW (object) + CephFS in the same cluster.
+
+### 4.5 Required S3 features
+
+Verify against your shortlist:
+- ✅ **S3 multipart**: Garage ✅, MinIO ✅, Ceph ✅, SeaweedFS ✅
+- ✅ **Presigned URLs**: all four
+- ✅ **Bucket policies / IAM-style**: MinIO best, Ceph good, Garage limited (key-based ACL via CLI), SeaweedFS limited
+- ✅ **ETag for Smart CDN**: all four (it's part of S3 spec)
+
+---
+
+## 5. Secrets management
+
+### 5.1 OpenBao vs Vault
+
+Use **OpenBao**. The deciding factors:
+1. You are *operating a SaaS that competes with a managed database* — Vault's BSL 1.1 explicitly forbids "offering Vault as a hosted or embedded service to third parties in competition with HashiCorp's commercial offerings." Even if your read of that restriction is narrow, your future enterprise customers' legal teams will not be.
+2. OpenBao 2.x added **Namespaces** (formerly Vault Enterprise-only) which is exactly what you need for multi-tenant isolation.
+3. OpenBao 2.5+ added horizontal read scalability (also formerly Enterprise-only).
+4. API/CLI compatible with Vault — your existing Terraform providers, helm charts, libraries all work.
+5. IBM-backed Linux Foundation governance.
+
+Caveats: OpenBao still lacks **Disaster Recovery Replication** and **Performance Replication** out of the box (those remain Vault Enterprise). For your topology, the workaround is:
+- One OpenBao cluster per region (5 nodes, integrated Raft)
+- Cross-region replication via continuous Raft snapshot ship + restore on a hot-standby cluster
+- Or, simpler: keep secrets in your global control-plane Postgres encrypted at rest with a master key in OpenBao, and treat OpenBao as the KMS rather than the database.
+
+### 5.2 Distribution pattern — don't tie everything to OpenBao availability
+
+Per-tenant secrets used by the multi-tenant fleets (GoTrue's per-project JWT secret, Storage's per-project S3 keys, etc.) cannot have OpenBao on the runtime hot path or every OpenBao blip will take down auth for everyone. Pattern:
+
+1. Control plane generates secret → stores in OpenBao (source of truth) → also writes encrypted to the global Postgres.
+2. The control plane writes the **public** parts (JWKS for JWT verification) to a flat file/object in Garage that all GoTrue / PostgREST / Realtime instances poll every N seconds (or subscribe via NATS).
+3. Per-tenant **private** parts the multi-tenant fleets need (e.g., the row in `_realtime.tenants` for Realtime) are written by the control plane *into the tenant-config Postgres tables* over a normal Postgres connection — exactly how Supabase Cloud does it today. Realtime's `_realtime` schema and Supavisor's `_supavisor` schema are designed for this.
+4. **Per-tenant DB passwords** the per-tenant Postgres VM needs at boot are injected via cloud-init / Firecracker MMDS at VM creation, and never read again — the VM only knows its own credentials.
+
+This means OpenBao going down means you can't *create new projects* but existing projects keep working. That's the right blast radius.
+
+### 5.3 JWT keys
+
+Use **asymmetric JWTs (Ed25519 or RS256)** and publish the JWKS publicly. Supabase moved to this model in 2024 because it lets PostgREST/Realtime/Storage verify tokens without a shared secret. In your clone:
+- Store private keys in OpenBao, one keypair per project
+- Publish JWKS at `https://.your-platform.com/auth/v1/.well-known/jwks.json`
+- All verifying services (Postgres via pgjwt, PostgREST, Storage, Realtime) verify against JWKS, no secret distribution needed
+
+---
+
+## 6. DNS / TLS
+
+### 6.1 Authoritative DNS
+
+**PowerDNS Authoritative** with the **gmysql** backend on a **Galera** or **MySQL Group Replication** cluster, anycasted at every POP via BIRD2. The "Building a highly available global anycast PowerDNS cluster" walkthrough by quantum5 is the closest published reference; NetActuate publishes an Ansible playbook for the same pattern.
+
+- 10 anycast nodes, each running PowerDNS authoritative + Galera replica
+- Single writer model: one master (e.g., in a primary DC) takes writes; anycast nodes are async replicas. Failover is manual; that's fine because *write* downtime to DNS for ten minutes does not break user-facing resolution.
+- PowerDNS REST API for programmatic record creation by the control plane (X-API-Key header, JSON, OpenAPI 3.1 spec). Your Go control plane just makes HTTP calls.
+- DNSSEC: PowerDNS handles inline signing automatically.
+- Don't use CoreDNS for authoritative — it's designed for service discovery and lacks features like AXFR-out, Lua records, geo-routing.
+
+### 6.2 TLS
+
+Three certificate stories:
+
+1. **Platform wildcard** (`*.your-platform.com`): issued via DNS-01 against PowerDNS using **lego** (single binary, supports PowerDNS provider natively). Run as a cron in the control plane; renew weekly.
+2. **Per-project subdomains** (`.your-platform.com`): covered by the wildcard above; no per-cert work.
+3. **Customer custom domains** (`api.customer.com`): **Caddy on-demand TLS** with an `ask` endpoint pointing at your control plane. The control plane checks "does this customer have this domain registered and verified?" → returns 200 → Caddy issues via Let's Encrypt HTTP-01. Cache certs in Caddy's storage backend (point at S3-compatible Garage so all edge Caddy instances share). Note Let's Encrypt's 50-cert-per-week-per-registered-domain limit applies per customer's domain, which is fine, and the 300-orders-per-3h account limit which means you should run multiple ACME accounts and shard.
+
+For very large numbers of custom domains, lookup the dev.to article "Scalable Multi-Tenant Architecture for Hundreds of Custom Domains" for the CloudFront SaaS-distribution pattern; the equivalent in your stack is "one Caddy fleet per region, each with its own ACME account, all sharing cert storage in Garage with read-through caching."
+
+---
+
+## 7. Backups / PITR
+
+Keep WAL-G (Supabase's choice) but consider pgBackRest for tenants over ~500 GB.
+
+### 7.1 The recommendation
+
+- **WAL-G** for the default tier, pointing at a per-region Garage bucket. Encryption-at-rest via WAL-G's libsodium support. Cross-region replication via Garage's multi-zone placement (configure replication mode = 3 with zones spanning your 10 DCs and you get geo-redundant backups for free).
+- **pgBackRest** for premium / enterprise tier tenants where the database is large enough that block-level incremental backups and parallel restore matter. pgBackRest beats WAL-G at the high end on (a) block-level incremental, (b) parallel restore speed, (c) more robust verification (`pgbackrest verify`). The trade-off is pgBackRest needs a "stanza" concept and a slightly more involved per-tenant setup.
+
+### 7.2 Avoid Barman at this scale
+
+Barman is excellent for "DBA team manages a fleet of corporate databases" — it assumes a centralized backup server pulling from N databases. That's the wrong shape for thousands of tenant VMs each pushing to their own object-storage bucket prefix.
+
+### 7.3 PITR for the per-tenant model
+
+Each per-tenant Postgres VM:
+- `archive_command` set to `wal-g wal-push %p` to its tenant prefix in Garage
+- `wal-g backup-push` runs nightly (via systemd timer inside the VM)
+- Recovery: spin up a new Firecracker VM with same tenant ID, `wal-g backup-fetch` + `wal-g wal-fetch` for PITR target time. The new Postgres comes up and the control plane swings the routing.
+
+This is the same pattern Crunchy Bridge and Render use; it works.
+
+---
+
+## 8. Branching (the Supabase-Cloud-only feature)
+
+Supabase Cloud's branching is implemented per-project and is the closest Supabase comes to Neon-style instant copy-on-write. Without Neon's pageserver, you have three choices in descending order of fidelity:
+
+### 8.1 ZFS snapshots (recommended)
+
+Each tenant Postgres VM's data volume is a ZFS zvol on the host. To branch:
+1. `zfs snapshot tank/tenant-X@branch-Y`
+2. `zfs clone tank/tenant-X@branch-Y tank/tenant-X-branch-Y`
+3. Boot a new Firecracker VM with `tank/tenant-X-branch-Y` as the rootfs disk (or data disk)
+4. Postgres comes up against the cloned data — the file is shared until either side writes, true CoW.
+
+**Caveats:**
+- The branch lives on the *same physical host* as the parent. Cross-host branching means snapshot send/receive (slower, but still cheap relative to pg_dump).
+- Postgres needs to come up against a quiesced state — best done via a Postgres `CHECKPOINT;` then `pg_start_backup()` or just by snapshotting *after* a graceful Postgres shutdown if branching can be slightly slow. For "branching while parent is live" you need a `pg_start_backup` / `pg_stop_backup` dance — Stolon and CloudNativePG have battle-tested versions.
+
+### 8.2 LVM thin pool
+
+LVM2 thin provisioning gives you nearly the same primitive (`lvcreate --snapshot --thinpool`) without ZFS's memory overhead. Used in production by many telco/finance shops. Less convenient for send/receive across hosts.
+
+### 8.3 pg_dump replay
+
+Falls back to "dump the parent, restore into a fresh VM." Works at any size but is O(N) in DB size. Use only for tenants that haven't enabled branching as a paid feature.
+
+### 8.4 The Neon route (for the truly ambitious)
+
+Long-term, the "right" answer is Neon's pageserver/safekeeper architecture: separate storage from compute, store WAL in a Pageserver, have Postgres compute talk to it over the network. Neon is Apache 2.0 (the core; cloud control plane is private). You *could* fork Neon's pageserver and integrate it with your control plane. This is a multi-engineer-year undertaking; revisit at >5K paying tenants.
+
+---
+
+## 9. Observability / logs
+
+### 9.1 Logflare with ClickHouse
+
+Logflare's BigQuery backend is a non-starter (proprietary, leaves your environment). Logflare's Postgres backend is officially "not optimized for production usage" by Supabase's own documentation. Logflare *does* have a ClickHouse backend (recently added and mentioned in their case studies). Use that.
+
+Pipeline (drop-in replacement for Supabase Cloud's pipeline):
+```
+Each service ─stdout─▶ Vector ─HTTP─▶ Logflare ─▶ ClickHouse cluster (per region)
+ │
+ └─▶ S3 cold tier (Garage) for >30d retention
+```
+
+Vector is already in Supabase OSS, so this is a one-config swap. Each region gets its own ClickHouse cluster (3 shards × 2 replicas is a good starting point); the global Logs Explorer in Studio queries via `clusterAllReplicas('region-{1..10}', ...)` for cross-region queries.
+
+### 9.2 Metrics
+
+**VictoriaMetrics cluster mode** is the right answer because it has native multi-tenancy via `accountID` URL prefix — you assign one accountID per Supabase project and your tenant isolation is automatic. Mimir works too but its multi-tenancy is more involved (Cortex-derived headers, per-tenant tenant config). VictoriaMetrics cluster components: vmstorage, vminsert, vmselect — all single Go binaries, run under systemd, scales horizontally on commodity hardware.
+
+### 9.3 Tracing
+
+**Grafana Tempo**, backed by Garage (it's S3-compatible storage natively). Pair with the `grafana-agent` flow for trace ingest. Turn this on only for your control-plane services; tracing user code in tenant VMs is rarely worth the cost at this scale.
+
+### 9.4 Don't fall into the trap
+
+Avoid SigNoz / OpenObserve / standalone Grafana Loki / VictoriaLogs unless you have a specific reason. The Supabase Studio UI is already wired for Logflare; replicating that integration against a different log backend is more work than getting Logflare→ClickHouse working.
+
+---
+
+## 10. Networking across 10 DCs
+
+### 10.1 Layered overlay strategy
+
+- **Operator/admin plane** (SSH into hosts, run kubectl-equivalent, Nomad UI access): **Headscale** (Tailscale control plane fork). Open source, drop-in for the Tailscale daemon. Run two Headscale instances behind Postgres for HA.
+- **Control-plane service mesh** (your Go services talking to each other across DCs): **Consul Connect** with Consul federated across DCs. Provides mTLS, intentions (auth policy), and L7 observability. You already need Consul for Nomad service discovery, so this is free.
+- **Data plane within a DC** (microVM ↔ multi-tenant fleet ↔ pooler): plain L2/L3 in the colo. CNI on the host with `tc-redirect-tap`; per-VLAN tenant isolation if you go that far.
+- **Data plane across DCs** (Postgres replication for read replicas, WAL-G to Garage, metrics ingestion): plain Internet with TLS, or a private IPsec/WireGuard backbone if your colos give you cross-connects. Don't try to mesh microVMs across DCs — replicate at the *application* layer (Postgres logical replication) where you actually need it.
+
+### 10.2 Cross-DC state — the Fly lesson
+
+Fly.io initially put placement state in a global Consul cluster. They saturated their fleet's uplinks once when a Consul outage caused thousands of workers to retry-storm. Their fix was **Corrosion**: a per-host SQLite database, replicated via SWIM gossip + CRDTs over QUIC, with p99 propagation of 1 second across 40+ regions. The key takeaway from their post-mortems is to avoid a single global state domain — they've now broken Corrosion into multiple state domains.
+
+For your platform:
+- Global control-plane DB (Postgres) handles slow-changing, transactional state.
+- Within each region, a small Corrosion cluster (or NATS JetStream KV) handles host-level "where is what" gossip.
+- Cross-region propagation of cosmetic state (load metrics, global view of fleet) goes via a Postgres logical-replication-fed read replica or a CDC pipeline (Debezium → NATS).
+- **Do not** try to make global gossip your single source of truth for billing-critical data. CockroachDB/FoundationDB are tempting but the operational ask doesn't pay back at 10 DCs.
+
+---
+
+## 11. End-to-end "create new project" walkthrough
+
+A user clicks "New Project" in your Studio. What happens:
+
+1. **Studio → Management API** (your Go global control plane): `POST /v1/projects` with name, region, tier.
+2. **Control plane writes to Postgres**: row in `projects` with status `Pending`, generates UUID, allocates subdomain `.your-platform.com`, generates anon/service JWT keypair. Calls OpenBao to store private key under `secret/projects//jwt`.
+3. **Control plane → regional placement service** (the one in the user's chosen DC): `Schedule(projectID, tier)`. The placement service queries the in-region Corrosion gossip for capacity, picks a host, returns hostID. State is now `Provisioning`.
+4. **Placement service → host's `pgvmd`**: `CreateVM(projectID, ...)` over mTLS gRPC. `pgvmd` writes "step 1: allocate ZFS volume" to its local BoltDB and starts the FSM.
+5. **`pgvmd` FSM**:
+ - `zfs create -V 8G tank/proj-` (or `zfs clone tank/empty-supabase-template@v15.5` to skip migrations entirely)
+ - Configure CNI network for the new VM
+ - Launch Cloud-Hypervisor with that volume + a kernel image + cloud-init data containing the `postgres` superuser password (read from OpenBao)
+ - Wait for Postgres `pg_isready`
+ - Run Supabase OSS bootstrap migrations: create `auth`, `realtime`, `storage`, `_realtime`, `_supavisor` schemas, install pg_graphql, pgsodium, pgjwt, etc.
+ - Write the per-project JWT secret + JWKS into the project's own database (used by RLS)
+ - Mark VM `Active` in local BoltDB
+ - Report back to placement service
+6. **Control plane (in parallel)**:
+ - **DNS**: PowerDNS REST `POST /api/v1/servers/localhost/zones/your-platform.com/records` to create `.your-platform.com` A record pointing at your edge anycast IP.
+ - **Routing**: `POST` to your Caddy admin API (or push via Envoy xDS) to map `.your-platform.com` → the host that hosts the VM, with PROXY-protocol header injection so the VM sees the real client IP.
+ - **TLS**: nothing — wildcard cert covers it.
+ - **Multi-tenant fleets registration**: `INSERT` into the shared `_realtime.tenants` table with the new project's connection string and JWT secret; same for the Supavisor pooler's `_supavisor.tenants` table; same for the Storage API's tenant-config table. These are the side-effect tables that Supabase Cloud's control plane writes to in production.
+ - **Backups**: enable cron on the host to run WAL-G against the tenant's Garage bucket prefix.
+ - **Observability**: register the tenant with Logflare (ClickHouse) so logs from the new VM are routed to its source.
+7. **Mark project `Active`** in global Postgres. Send webhook to user's email.
+
+End-to-end target: **10–30 seconds** if you start from a ZFS-cloned template; **2–5 minutes** if you boot from scratch and run all migrations. Supabase Cloud's documented provisioning time is about a minute, so the cloned-template path is essential.
+
+### 11.2 Pause/resume (scale-to-zero)
+
+Two implementations, and you want both:
+
+- **Soft pause**: Cloud-Hypervisor `pause` API stops vCPU execution but keeps memory resident. Resume is sub-second. Free for the user, costs you only the RAM. Use for the "auto-pause after 5 min idle" tier.
+- **Hard pause**: snapshot the VM (Cloud-Hypervisor's snapshot/restore is mature; Firecracker's is too — see Fly's "Machine Suspend and Resume" doc) to a file in Garage, terminate the VM, free RAM and CPU. Resume = pull snapshot, restore. ~1–3 seconds for a small Postgres. Use for "free tier paused after 7 days idle."
+
+Critically, Supabase's own free tier "pause" today is essentially a hard pause: the VM is stopped, the disk retained. You can do better by combining the two: 5 min idle → soft pause; 24 h soft-paused → hard pause + reclaim RAM; 90 d hard-paused → cold-archive disk to Garage and free local NVMe.
+
+---
+
+## 12. Risks and "things that are genuinely hard"
+
+This is the section a system architect needs most. In rough order of pain:
+
+1. **Anycast and DDoS at commodity colo.** Cloudflare absorbs hundreds of Gbps and bills you nothing. Hivelocity's stated DDoS protection is meaningful but not Cloudflare-grade. You **will** get DDoSed by an angry tenant's customer eventually. Plan for it: contract upstream scrubbing (Voxility/Path.net), be willing to nullroute customer IPs, and consider keeping bunny.net or Fastly in front for the first 12–24 months as a "cheat code."
+2. **Cross-region S3 durability.** S3's 11 nines are real and unfakeable on commodity disk. Garage with 3 zones gets you to maybe 6–7 nines if you're rigorous about disk monitoring; Ceph similar; MinIO similar. For PITR specifically, mitigate by replicating WAL-G prefix to *two* independent storage clusters (e.g., Garage + a hosted Wasabi/Backblaze B2 account as belt-and-suspenders). That hosted backup is your "if our entire infra dies" insurance.
+3. **The Firecracker block-IO ceiling for Postgres.** The original Firecracker NSDI paper documents a guest-side ceiling around 13K IOPS at 4 KB and serial IO submission. Your Postgres tenants will hit this if they're write-heavy. Mitigations: use Cloud Hypervisor (vhost-user-blk) for Postgres VMs, not Firecracker; pin tenant VMs to NVMe-direct hosts; offer a "premium" tier on dedicated bare metal without virtualization for the largest tenants.
+4. **The firecracker-task-driver Nomad plugin is unmaintained-ish.** The cneira/firecracker-task-driver is stuck on Firecracker 0.25.x. You will fork it. Budget for that.
+5. **Operating PowerDNS authoritative under DDoS.** A reflected DNS amplification attack against your nameservers is a normal Tuesday at scale. Anycast helps; rate limiting at BIRD2 helps; but you also need RRL (response rate limiting) configured in PowerDNS and ideally run a recursor like dnsdist in front for ACL.
+6. **Operating OpenBao without DR replication.** Until OpenBao gains parity with Vault Enterprise's DR replication, you're rolling your own (Raft snapshot ship + restore). This is not hard, but you need to test failover and keep someone on call who knows how to bootstrap a fresh OpenBao from a cold backup.
+7. **Cross-DC clock skew.** WAL-G, pgBackRest, Corrosion, distributed Postgres replication, JWT validation — all assume well-disciplined NTP. You **must** run **chrony** with multiple internal stratum-2 sources per DC. This bites everyone eventually.
+8. **Supabase upstream churn.** Every minor version of GoTrue, Realtime, Supavisor, Storage may change its tenant-config schema. Your control plane writes to those tables. You will need a migration strategy and a CI bot that diff-checks upstream schema changes.
+9. **Building the control plane is the actual product.** The OSS Supabase docker-compose is a single-tenant educational setup. The Cloud version has *years* of multi-tenancy plumbing (including Studio's awareness of multiple projects, the `api.supabase.com` Management API, billing, branching, etc.). Realistically, this is **2–4 senior engineers for 12–18 months** to reach feature parity, plus an SRE. Plan accordingly.
+
+---
+
+## 13. Reference reading (real engineering blogs)
+
+The most useful published prior art:
+
+- **Fly.io blog** — `Corrosion` (eventual consistency at 40+ regions, why Raft globally is a trap), `Making Machines Move` (dm-clone, NBD, iSCSI, the volume migration story), `The Design & Implementation of Sprites` (object-storage-backed VM disks, why Firecracker isn't right for hot Postgres), `Machine Suspend and Resume`, the Fly architecture page, and the Platform Engineer: Fly Machines job posting (which is the most candid description of `flyd` available in public).
+- **Koyeb blog** — "The Koyeb Serverless Engine: from Kubernetes to Nomad, Firecracker, and Kuma" and "Lightweight Virtualization: the Container Ecosystem and Firecracker MicroVMs."
+- **Neon docs** — pageserver/safekeeper architecture, branching internals.
+- **Crunchy Data Bridge** — public posts on PostgreSQL on Kubernetes (CrunchyData/postgres-operator), pgBackRest patterns at scale.
+- **Render** and **Railway** blogs — both run multi-tenant on Firecracker-adjacent stacks, both have published useful posts on per-tenant TLS and pause-resume.
+- **Ubicloud** blog — "Cloud virtualization: Red Hat, AWS Firecracker, and Ubicloud internals" is the cleanest explainer of Firecracker vs Cloud Hypervisor for non-serverless workloads. Ubicloud's full open-source AWS-clone (their elastic-cloud-control-plane on GitHub) is itself worth reading as the closest existing OSS equivalent of what you're building.
+- **APNIC blog** — "Building an open source anycast CDN" by Nate Sales (Packetframe) — the canonical practical guide to BIRD2 + anycast + Varnish + Caddy on commodity colo.
+- **AWS Firecracker NSDI 2020 paper** by Agache et al. — the empirical I/O performance numbers (sections 6–7) are essential reading before betting Postgres on Firecracker.
+- **Mayuresh Bagalkote's blog** — GoCast architecture and BGP-anycast-as-a-service patterns.
+- **Cloudflare Pingora** open-source release announcement (2024) — relevant if you ever outgrow Caddy at the edge.
+
+---
+
+## 14. TL;DR architecture
+
+```
+ ┌─────────────────────────────────────────────┐
+ │ GLOBAL CONTROL PLANE (1 region) │
+ │ Go services + Postgres (HA, warm standby) │
+ │ - Management API (Supabase api equivalent) │
+ │ - Project lifecycle FSM │
+ │ - Billing / IAM / Org │
+ │ - JWKS publisher │
+ │ - OpenBao primary cluster │
+ │ - PowerDNS primary (Galera writer) │
+ └────────────────────┬────────────────────────┘
+ │
+ │ control-plane API + gossip
+ ▼
+ ┌─────────────────────────────────────────────────────────────────────┐
+ │ PER-REGION DATA PLANE (× 10) │
+ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
+ │ │ Edge/POP │ │ Multi-tenant│ │ Per-tenant compute │ │
+ │ │ │ │ fleets │ │ │ │
+ │ │ Anycast IP │ │ │ │ Postgres VM (CloudHV) │ │
+ │ │ BIRD2+GoCast │──▶ GoTrue │──▶ ZFS zvol per tenant │ │
+ │ │ Caddy │ │ PostgREST │ │ ┌──────────────────────┐ │ │
+ │ │ on-demand TLS│ │ Realtime │ │ │ pgvmd (per host) │ │ │
+ │ │ Coraza WAF │ │ Storage │ │ │ - durable FSM (Bolt) │ │ │
+ │ │ │ │ Edge Funcs │ │ │ - VM lifecycle │ │ │
+ │ │ PowerDNS │ │ Supavisor │ │ │ - snapshot/clone │ │ │
+ │ │ replica │ │ (on Nomad) │ │ │ - WAL-G push │ │ │
+ │ └──────────────┘ └──────────────┘ │ └──────────────────────┘ │ │
+ │ │ Ephemeral Firecracker │ │
+ │ ┌──────────────────────────────────┐ │ workers for Edge Funcs │ │
+ │ │ Garage (S3, multi-zone replica) │ │ (on Nomad) │ │
+ │ │ ClickHouse (logs) │ └──────────────────────────┘ │
+ │ │ VictoriaMetrics (metrics) │ │
+ │ │ OpenBao perf-secondary │ │
+ │ │ Corrosion gossip │ │
+ │ └──────────────────────────────────┘ │
+ └─────────────────────────────────────────────────────────────────────┘
+```
+
+Get this skeleton running with two regions and ten tenants before you scale either dimension. The architecture above is composable — you can adopt Garage before BGP anycast, OpenBao before Cloud Hypervisor, etc. — but the **global Postgres control plane + per-host `pgvmd`-style agent** is the load-bearing decision; everything else swaps in around it.
+
+The single most important insight, from Fly's hard-won experience, is this: **the host is the source of truth for its own workloads.** Every other choice (gossip vs Raft, IaC vs Go control plane, Nomad for stateless vs custom for stateful) follows from that principle. Build for that, and a multi-DC, no-Kubernetes Supabase clone is a multi-year project but a tractable one.
\ No newline at end of file
diff --git a/docker/.env.example b/docker/.env.example
index 4dc565da5d2b4..e5dfde0ef84ba 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -270,6 +270,9 @@ STORAGE_TENANT_ID=stub
# NOTE: VERIFY_JWT applies to all functions
FUNCTIONS_VERIFY_JWT=false
+# Password for the traffic_api Postgres role (used by platform API edge functions)
+TRAFFIC_API_PASSWORD=your-traffic-api-password
+
############
# API - Configuration for PostgREST
diff --git a/docker/dev/data.sql b/docker/dev/data.sql
index 2328004184a72..56ce90e793d0f 100644
--- a/docker/dev/data.sql
+++ b/docker/dev/data.sql
@@ -31,18 +31,27 @@ begin;
commit;
alter publication supabase_realtime add table profiles;
--- Set up Storage
-insert into storage.buckets (id, name)
-values ('avatars', 'avatars');
-
-create policy "Avatar images are publicly accessible."
- on storage.objects for select
- using ( bucket_id = 'avatars' );
-
-create policy "Anyone can upload an avatar."
- on storage.objects for insert
- with check ( bucket_id = 'avatars' );
-
-create policy "Anyone can update an avatar."
- on storage.objects for update
- with check ( bucket_id = 'avatars' );
+-- The previous version of this file inserted an `avatars` row into
+-- `storage.buckets` and declared three policies on `storage.objects`. Both
+-- tables are created by the Storage API container the first time it boots,
+-- NOT by Postgres init scripts. `data.sql` runs from
+-- `/docker-entrypoint-initdb.d` during the `db` container's first start,
+-- which is BEFORE Storage has had a chance to run its own bootstrap SQL — so
+-- the old block failed every time with `relation "storage.buckets" does not
+-- exist`, leaving the rest of the seed file partially applied.
+--
+-- We intentionally drop the storage seed here. The avatars bucket is a
+-- Studio-UI convenience and can be recreated in one click from
+-- `/project/{ref}/storage/buckets`, or by running the following block by
+-- hand against the DB once the Storage container is up:
+--
+-- insert into storage.buckets (id, name) values ('avatars', 'avatars');
+-- create policy "Avatar images are publicly accessible." on storage.objects
+-- for select using (bucket_id = 'avatars');
+-- create policy "Anyone can upload an avatar." on storage.objects
+-- for insert with check (bucket_id = 'avatars');
+-- create policy "Anyone can update an avatar." on storage.objects
+-- for update with check (bucket_id = 'avatars');
+--
+-- Re-adding the block here without first reordering init would just
+-- re-break `docker compose up --build` on a clean volume.
diff --git a/docker/dev/docker-compose.dev.yml b/docker/dev/docker-compose.dev.yml
index e6b2e76cbde48..d1f88750633d7 100644
--- a/docker/dev/docker-compose.dev.yml
+++ b/docker/dev/docker-compose.dev.yml
@@ -29,6 +29,18 @@ services:
environment:
- GOTRUE_SMTP_USER=
- GOTRUE_SMTP_PASS=
+ # Dev-only rate-limit bump. The default GoTrue budget (`EMAIL_SENT=30/hr`,
+ # etc.) is enough for a human sitting in the Studio UI, but it trips 429s
+ # when the `traffic-one` integration suites or the Playwright
+ # auth-users multi-user flow create several users back-to-back. Production
+ # `docker/docker-compose.yml` is untouched, so real deployments keep the
+ # stock GoTrue defaults.
+ - GOTRUE_RATE_LIMIT_EMAIL_SENT=10000
+ - GOTRUE_RATE_LIMIT_SMS_SENT=10000
+ - GOTRUE_RATE_LIMIT_VERIFY=10000
+ - GOTRUE_RATE_LIMIT_TOKEN_REFRESH=10000
+ - GOTRUE_RATE_LIMIT_OTP=10000
+ - GOTRUE_RATE_LIMIT_ANONYMOUS_USERS=10000
meta:
ports:
- 5555:8080
diff --git a/docker/docker-compose.platform.yml b/docker/docker-compose.platform.yml
new file mode 100644
index 0000000000000..3e9e114493b11
--- /dev/null
+++ b/docker/docker-compose.platform.yml
@@ -0,0 +1,52 @@
+# Platform mode: enables IS_PLATFORM in Studio for the hosted platform UI
+#
+# Usage (from docker/ directory):
+# docker compose -f docker-compose.yml -f docker-compose.platform.yml up -d
+#
+# The pre-built image runs Next.js in dev mode, so NEXT_PUBLIC_* environment
+# variables are evaluated at runtime — no rebuild required.
+
+services:
+ studio:
+ # Raise Studio's container memory ceiling (override with STUDIO_MEM_LIMIT, e.g. 12g).
+ mem_limit: ${STUDIO_MEM_LIMIT:-8g}
+ healthcheck:
+ disable: true
+ volumes:
+ - ../apps/studio/lib/api/incident-banner.ts:/app/apps/studio/lib/api/incident-banner.ts:ro
+ - ../apps/studio/proxy.ts:/app/apps/studio/proxy.ts:ro
+ - ../apps/studio/lib/api/self-hosted/util.ts:/app/apps/studio/lib/api/self-hosted/util.ts:ro
+ - ../traffic-one/studio-patches/gotrue.ts:/app/packages/common/gotrue.ts:ro
+ - ../traffic-one/studio-patches/apiHelpers.ts:/app/apps/studio/lib/api/apiHelpers.ts:ro
+ - ../traffic-one/studio-patches/.env.local:/app/apps/studio/.env.local:ro
+ environment:
+ # Bind Next.js listener to both IPv4 and IPv6 interfaces (prebuilt image runs next dev)
+ HOSTNAME: "::"
+ STUDIO_PORT: 3000
+ NEXT_PUBLIC_IS_PLATFORM: "true"
+ NEXT_PUBLIC_SELF_HOSTED_PLATFORM: "true"
+ NEXT_PUBLIC_API_URL: ${SUPABASE_PUBLIC_URL}/api
+ NEXT_PUBLIC_GOTRUE_URL: ${SUPABASE_PUBLIC_URL}/auth/v1
+ NEXT_PUBLIC_SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
+ NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
+ NEXT_PUBLIC_HCAPTCHA_SITE_KEY: "10000000-ffff-ffff-ffff-000000000001"
+ NEXT_PUBLIC_SITE_URL: ${SUPABASE_PUBLIC_URL}
+ PLATFORM_PG_META_URL: http://meta:8080
+ GOTRUE_INTERNAL_URL: http://kong:8000/auth/v1
+
+ functions:
+ environment:
+ # traffic-one edge function: restricted DB role, Logflare usage queries, pooler + postgres metadata
+ TRAFFIC_DB_URL: postgresql://traffic_api:${TRAFFIC_API_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
+ LOGFLARE_URL: http://analytics:4000
+ LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}
+ POOLER_TENANT_ID: ${POOLER_TENANT_ID}
+ POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE}
+ POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN}
+ POOLER_PROXY_PORT_TRANSACTION: ${POOLER_PROXY_PORT_TRANSACTION}
+ POSTGRES_PORT: ${POSTGRES_PORT}
+
+ kong:
+ depends_on:
+ studio:
+ condition: service_started
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 7bcf1a1c47d1f..b0ef84e8dd853 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -20,7 +20,7 @@ services:
test:
[
"CMD-SHELL",
- "node -e \"fetch('http://localhost:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})\""
+ "node -e \"fetch('http://localhost:8082/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})\""
]
timeout: 10s
interval: 5s
@@ -515,7 +515,7 @@ services:
# Comment out everything below this point if you are using an external Postgres database
db:
container_name: supabase-db
- image: supabase/postgres:15.8.1.085
+ image: supabase/postgres:17.6.1.084
restart: unless-stopped
volumes:
- ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z
diff --git a/docker/volumes/api/kong.yml b/docker/volumes/api/kong.yml
index b89e868f55775..615e3655f2ec8 100644
--- a/docker/volumes/api/kong.yml
+++ b/docker/volumes/api/kong.yml
@@ -98,6 +98,58 @@ services:
plugins:
- name: cors
+ ## Open Auth routes used by Studio platform mode (AuthClient without apikey)
+ - name: auth-v1-open-token
+ _comment: 'Auth: /auth/v1/token* -> http://auth:9999/token*'
+ url: http://auth:9999/token
+ routes:
+ - name: auth-v1-open-token
+ strip_path: true
+ paths:
+ - /auth/v1/token
+ plugins:
+ - name: cors
+ - name: auth-v1-open-user
+ _comment: 'Auth: /auth/v1/user* -> http://auth:9999/user*'
+ url: http://auth:9999/user
+ routes:
+ - name: auth-v1-open-user
+ strip_path: true
+ paths:
+ - /auth/v1/user
+ plugins:
+ - name: cors
+ - name: auth-v1-open-logout
+ _comment: 'Auth: /auth/v1/logout* -> http://auth:9999/logout*'
+ url: http://auth:9999/logout
+ routes:
+ - name: auth-v1-open-logout
+ strip_path: true
+ paths:
+ - /auth/v1/logout
+ plugins:
+ - name: cors
+ - name: auth-v1-open-signup
+ _comment: 'Auth: /auth/v1/signup* -> http://auth:9999/signup*'
+ url: http://auth:9999/signup
+ routes:
+ - name: auth-v1-open-signup
+ strip_path: true
+ paths:
+ - /auth/v1/signup
+ plugins:
+ - name: cors
+ - name: auth-v1-open-recover
+ _comment: 'Auth: /auth/v1/recover* -> http://auth:9999/recover*'
+ url: http://auth:9999/recover
+ routes:
+ - name: auth-v1-open-recover
+ strip_path: true
+ paths:
+ - /auth/v1/recover
+ plugins:
+ - name: cors
+
## Secure Auth routes
- name: auth-v1
_comment: 'Auth: /auth/v1/* -> http://auth:9999/*'
@@ -353,7 +405,7 @@ services:
## Block access to /api/mcp
- name: mcp-blocker
_comment: 'Block direct access to /api/mcp'
- url: http://studio:3000/api/mcp
+ url: http://studio:8082/api/mcp
routes:
- name: mcp-blocker-route
strip_path: true
@@ -367,8 +419,8 @@ services:
## MCP endpoint - local access
- name: mcp
- _comment: 'MCP: /mcp -> http://studio:3000/api/mcp (local access)'
- url: http://studio:3000/api/mcp
+ _comment: 'MCP: /mcp -> http://studio:8082/api/mcp (local access)'
+ url: http://studio:8082/api/mcp
routes:
- name: mcp
strip_path: true
@@ -392,10 +444,280 @@ services:
# - ::1
# deny: []
+ ## Platform Profile API -> Edge Function
+ - name: platform-profile
+ _comment: 'traffic-one: /api/platform/profile* -> http://functions:9000/traffic-one/*'
+ url: http://functions:9000/traffic-one
+ routes:
+ - name: platform-profile-all
+ strip_path: true
+ paths:
+ - /api/platform/profile
+ plugins:
+ - name: cors
+
+ ## Platform Signup API -> Edge Function
+ - name: platform-signup
+ _comment: 'traffic-one: /api/platform/signup -> http://functions:9000/traffic-one/signup'
+ url: http://functions:9000/traffic-one/signup
+ routes:
+ - name: platform-signup-route
+ strip_path: true
+ paths:
+ - /api/platform/signup
+ plugins:
+ - name: cors
+
+ ## Platform Reset Password API -> Edge Function
+ - name: platform-reset-password
+ _comment: 'traffic-one: /api/platform/reset-password -> http://functions:9000/traffic-one/reset-password'
+ url: http://functions:9000/traffic-one/reset-password
+ routes:
+ - name: platform-reset-password-route
+ strip_path: true
+ paths:
+ - /api/platform/reset-password
+ plugins:
+ - name: cors
+
+ ## Platform Organizations API -> Edge Function
+ - name: platform-organizations
+ _comment: 'traffic-one: /api/platform/organizations* -> http://functions:9000/traffic-one/organizations*'
+ url: http://functions:9000/traffic-one/organizations
+ routes:
+ - name: platform-organizations-route
+ strip_path: true
+ paths:
+ - /api/platform/organizations
+ plugins:
+ - name: cors
+
+ ## Platform Projects Resource Warnings -> Edge Function
+ - name: platform-projects-resource-warnings
+ _comment: 'traffic-one: /api/platform/projects-resource-warnings -> http://functions:9000/traffic-one/projects-resource-warnings'
+ url: http://functions:9000/traffic-one/projects-resource-warnings
+ routes:
+ - name: platform-projects-resource-warnings-route
+ strip_path: true
+ paths:
+ - /api/platform/projects-resource-warnings
+ plugins:
+ - name: cors
+
+ ## Platform Stripe API -> Edge Function
+ - name: platform-stripe
+ _comment: 'traffic-one: /api/platform/stripe* -> http://functions:9000/traffic-one/stripe*'
+ url: http://functions:9000/traffic-one/stripe
+ routes:
+ - name: platform-stripe-route
+ strip_path: true
+ paths:
+ - /api/platform/stripe
+ plugins:
+ - name: cors
+
+ ## Platform Projects API -> Edge Function
+ - name: platform-projects
+ _comment: 'traffic-one: /api/platform/projects* -> http://functions:9000/traffic-one/projects*'
+ url: http://functions:9000/traffic-one/projects
+ routes:
+ - name: platform-projects-route
+ strip_path: true
+ paths:
+ - /api/platform/projects
+ plugins:
+ - name: cors
+
+ ## Platform Notifications API -> Edge Function
+ - name: platform-notifications
+ _comment: 'traffic-one: /api/platform/notifications* -> http://functions:9000/traffic-one/notifications*'
+ url: http://functions:9000/traffic-one/notifications
+ routes:
+ - name: platform-notifications-route
+ strip_path: true
+ paths:
+ - /api/platform/notifications
+ plugins:
+ - name: cors
+
+ ## Platform Telemetry -> Edge Function
+ - name: platform-telemetry
+ _comment: 'traffic-one: /api/platform/telemetry* -> http://functions:9000/traffic-one/telemetry*'
+ url: http://functions:9000/traffic-one/telemetry
+ routes:
+ - name: platform-telemetry-route
+ strip_path: true
+ paths:
+ - /api/platform/telemetry
+ plugins:
+ - name: cors
+
+ ## V1 Projects Health API -> Edge Function
+ - name: v1-projects-health
+ _comment: 'traffic-one: /api/v1/projects -> http://functions:9000/traffic-one/v1-projects'
+ url: http://functions:9000/traffic-one/v1-projects
+ routes:
+ - name: v1-projects-health-route
+ strip_path: true
+ paths:
+ - /api/v1/projects
+ plugins:
+ - name: cors
+
+ ## V1 Branches API -> Edge Function
+ ## /api/v1/branches/{id}/* is NOT nested under /projects/{ref}, so it needs
+ ## its own Kong service. Covers branch diff, merge, push, reset, and delete
+ ## operations after a branch has been created under /api/v1/projects/{ref}/branches.
+ - name: v1-branches
+ _comment: 'traffic-one: /api/v1/branches -> http://functions:9000/traffic-one/v1-branches'
+ url: http://functions:9000/traffic-one/v1-branches
+ routes:
+ - name: v1-branches-route
+ strip_path: true
+ paths:
+ - /api/v1/branches
+ plugins:
+ - name: cors
+
+ ## V1 Organizations API -> Edge Function
+ ## Only /api/v1/organizations/{slug}/project-claim/{token} is wired today
+ ## (AWS-marketplace project-transfer flow). The handler returns a "not valid" stub
+ ## so Studio doesn't 404 on claim-project links in self-hosted.
+ - name: v1-organizations
+ _comment: 'traffic-one: /api/v1/organizations -> http://functions:9000/traffic-one/v1-organizations'
+ url: http://functions:9000/traffic-one/v1-organizations
+ routes:
+ - name: v1-organizations-route
+ strip_path: true
+ paths:
+ - /api/v1/organizations
+ plugins:
+ - name: cors
+
+ ## Platform Update Email API -> Edge Function
+ - name: platform-update-email
+ _comment: 'traffic-one: /api/platform/update-email -> http://functions:9000/traffic-one/update-email'
+ url: http://functions:9000/traffic-one/update-email
+ routes:
+ - name: platform-update-email-route
+ strip_path: true
+ paths:
+ - /api/platform/update-email
+ plugins:
+ - name: cors
+
+ ## Platform Auth API -> Edge Function
+ ##
+ ## Covers BOTH the config surface (/api/platform/auth/{ref}/config[/hooks])
+ ## AND the GoTrue admin surface (/users*, /invite, /magiclink, /recover,
+ ## /otp, /users/{id}/factors, /validate/spam). traffic-one's index.ts
+ ## routes config paths to handleAuthConfig and everything else to
+ ## handleProjectAuthAdmin, which resolves the per-project backend and
+ ## signs outbound calls with that project's service_role key.
+ ##
+ ## The Studio Next stubs under apps/studio/pages/api/platform/auth/[ref]/*
+ ## are now UNREACHABLE via Kong (this route wins). They remain in the tree
+ ## as an escape hatch for legacy / non-Kong deployments only.
+ ##
+ ## strip_path: false preserves the original path; traffic-one/index.ts
+ ## strips the `/api/platform/auth` prefix before dispatch.
+ - name: platform-auth
+ _comment: 'traffic-one: /api/platform/auth/{ref}/* -> http://functions:9000/traffic-one/api/platform/auth/{ref}/*'
+ url: http://functions:9000/traffic-one
+ routes:
+ - name: platform-auth-route
+ strip_path: false
+ paths:
+ - /api/platform/auth/
+ plugins:
+ - name: cors
+
+ ## Platform pg-meta API -> Edge Function
+ ##
+ ## Covers POST /api/platform/pg-meta/{ref}/query (SQL runner; audited) and
+ ## the read-only GET surfaces (tables, triggers, types, policies, extensions,
+ ## foreign-tables, materialized-views, views, column-privileges, publications).
+ ## traffic-one/index.ts strips the `/api/platform/pg-meta` prefix and
+ ## dispatches to handleProjectPgMeta, which resolves the per-project backend
+ ## and signs outbound calls with that project's service_role key.
+ ##
+ ## The Studio Next stubs under apps/studio/pages/api/platform/pg-meta/[ref]/*
+ ## are now UNREACHABLE via Kong (this route wins). They remain in the tree
+ ## as an escape hatch for legacy / non-Kong deployments only.
+ ##
+ ## strip_path: false preserves the original path; traffic-one/index.ts
+ ## strips the `/api/platform/pg-meta` prefix before dispatch.
+ ##
+ ## We list BOTH `/api/platform/pg-meta/` (trailing slash) AND
+ ## `/api/platform/pg-meta` so clients that emit the bare prefix (e.g.
+ ## `curl http://kong:8000/api/platform/pg-meta/abc/tables` is fine, but
+ ## `http://kong:8000/api/platform/pg-meta` alone used to 404) still match
+ ## the route. Kong prefix-matches each entry separately; the entry
+ ## without the trailing slash is required to catch bare-prefix requests.
+ - name: platform-pg-meta
+ _comment: 'traffic-one: /api/platform/pg-meta/{ref}/* -> http://functions:9000/traffic-one/api/platform/pg-meta/{ref}/*'
+ url: http://functions:9000/traffic-one
+ routes:
+ - name: platform-pg-meta-route
+ strip_path: false
+ paths:
+ - /api/platform/pg-meta/
+ - /api/platform/pg-meta
+ plugins:
+ - name: cors
+
+ ## Platform Database API -> Edge Function
+ - name: platform-database
+ _comment: 'traffic-one: /api/platform/database* -> http://functions:9000/traffic-one/database*'
+ url: http://functions:9000/traffic-one/database
+ routes:
+ - name: platform-database-route
+ strip_path: true
+ paths:
+ - /api/platform/database
+ plugins:
+ - name: cors
+
+ ## Platform Replication API -> Edge Function
+ - name: platform-replication
+ _comment: 'traffic-one: /api/platform/replication* -> http://functions:9000/traffic-one/replication*'
+ url: http://functions:9000/traffic-one/replication
+ routes:
+ - name: platform-replication-route
+ strip_path: true
+ paths:
+ - /api/platform/replication
+ plugins:
+ - name: cors
+
+ ## Platform Feedback API -> Edge Function
+ - name: platform-feedback
+ _comment: 'traffic-one: /api/platform/feedback* -> http://functions:9000/traffic-one/feedback*'
+ url: http://functions:9000/traffic-one/feedback
+ routes:
+ - name: platform-feedback-route
+ strip_path: true
+ paths:
+ - /api/platform/feedback
+ plugins:
+ - name: cors
+
+ ## Platform CLI API -> Edge Function
+ - name: platform-cli
+ _comment: 'traffic-one: /api/platform/cli* -> http://functions:9000/traffic-one/cli*'
+ url: http://functions:9000/traffic-one/cli
+ routes:
+ - name: platform-cli-route
+ strip_path: true
+ paths:
+ - /api/platform/cli
+ plugins:
+ - name: cors
+
## Protected Dashboard - catch all remaining routes
- name: dashboard
- _comment: 'Studio: /* -> http://studio:3000/*'
- url: http://studio:3000/
+ _comment: 'Studio: /* -> http://studio:8082/*'
+ url: http://studio:8082/
routes:
- name: dashboard-all
strip_path: true
@@ -403,6 +725,3 @@ services:
- /
plugins:
- name: cors
- - name: basic-auth
- config:
- hide_credentials: true
diff --git a/e2e/studio/utils/auth-helpers.ts b/e2e/studio/utils/auth-helpers.ts
index 80ace46971f51..fa1cfec0d8ea5 100644
--- a/e2e/studio/utils/auth-helpers.ts
+++ b/e2e/studio/utils/auth-helpers.ts
@@ -1,6 +1,7 @@
import { expect, Page } from '@playwright/test'
-import { waitForApiResponse } from './wait-for-response.js'
+
import { toUrl } from './to-url.js'
+import { waitForApiResponse } from './wait-for-response.js'
export const createUserViaUI = async (page: Page, ref: string, email: string, password: string) => {
// Open the Add user dropdown
@@ -21,11 +22,24 @@ export const createUserViaUI = async (page: Page, ref: string, email: string, pa
// Verify that "Auto Confirm User?" is checked by default
await expect(page.getByRole('checkbox', { name: 'Auto Confirm User?' })).toBeChecked()
- // Set up API waiters BEFORE clicking the button to avoid race conditions
+ // Set up API waiters BEFORE clicking the button to avoid race conditions.
+ //
+ // The pg-meta matcher MUST pin to `users-infinite` rather than the generic
+ // `query?key=`. Studio fires two pg-meta queries after create/delete:
+ // `users-count` (for the header badge) and `users-infinite` (for the table
+ // body). `useUsersInfiniteQuery` keys off `['projects', ref, 'users-infinite', …]`
+ // which execute-sql joins with `-`, so the URL contains `query?key=projects-${ref}-users-infinite`.
+ // The generic matcher grabs whichever lands first (usually `users-count`)
+ // and the test asserts visibility before the table itself has rerendered.
const createUserPromise = waitForApiResponse(page, 'platform/auth', ref, 'users', {
method: 'POST',
})
- const usersListPromise = waitForApiResponse(page, 'platform/pg-meta', ref, 'query?key=')
+ const usersListPromise = waitForApiResponse(
+ page,
+ 'platform/pg-meta',
+ ref,
+ 'query?key=users-infinite'
+ )
// Click "Create user"
await page.getByRole('button', { name: 'Create user' }).click()
@@ -55,11 +69,18 @@ export const deleteUserViaUI = async (page: Page, ref: string, email: string) =>
// Wait for confirmation dialog
await expect(page.getByRole('dialog', { name: 'Confirm to delete 1 user' })).toBeVisible()
- // Set up API waiters BEFORE clicking the delete button
+ // Set up API waiters BEFORE clicking the delete button.
+ // See `createUserViaUI` above for why the pg-meta matcher pins to
+ // `users-infinite` rather than the generic `query?key=`.
const deleteUserPromise = waitForApiResponse(page, 'platform/auth', ref, 'users/', {
method: 'DELETE',
})
- const usersListPromise = waitForApiResponse(page, 'platform/pg-meta', ref, 'query?key=')
+ const usersListPromise = waitForApiResponse(
+ page,
+ 'platform/pg-meta',
+ ref,
+ 'query?key=users-infinite'
+ )
// Confirm deletion
await page.getByRole('button', { name: 'Delete' }).click()
diff --git a/traffic-one/ARCHITECTURE.md b/traffic-one/ARCHITECTURE.md
new file mode 100644
index 0000000000000..72d748c02128a
--- /dev/null
+++ b/traffic-one/ARCHITECTURE.md
@@ -0,0 +1,1072 @@
+# Architecture
+
+## Overview
+
+```mermaid
+flowchart LR
+ Browser["Studio (Next.js dev)"]
+ Kong["Kong 3.9.1]
docker/volumes/api/kong.yml"]
+ Traffic["traffic-one
functions/index.ts"]
+ Resolver["getProjectBackend(ref)
services/project-backend.service.ts"]
+ GoTrue["GoTrue (per-project /auth/v1/admin/*)"]
+ PG[("Postgres
traffic.*")]
+ Vault[("Postgres Vault
vault.decrypted_secrets")]
+ ProjectDB[("Project Postgres
backend.connectionString")]
+ PgMeta["pg-meta (per-project)
backend.pgMetaUrl"]
+ Logflare["Logflare (per-project)
backend.logflareUrl"]
+ FnAdmin["Edge Functions admin
backend.functionsApiUrl"]
+ Functions["edge-runtime (deno)
/home/deno/functions"]
+
+ Browser -->|"/api/platform/profile"| Kong
+ Browser -->|"/api/platform/organizations*"| Kong
+ Browser -->|"/api/platform/notifications*"| Kong
+ Browser -->|"/api/platform/update-email"| Kong
+ Browser -->|"/api/platform/feedback*"| Kong
+ Browser -->|"/api/platform/cli*"| Kong
+ Browser -->|"/api/platform/telemetry*"| Kong
+ Browser -->|"/api/platform/database/*/backups*"| Kong
+ Browser -->|"/api/platform/replication/*"| Kong
+ Browser -->|"/api/platform/projects/{ref}/*"| Kong
+ Browser -->|"/api/v1/projects/{ref}/*"| Kong
+ Browser -->|"/api/v1/branches/*"| Kong
+ Browser -->|"/api/v1/organizations*"| Kong
+ Browser -->|"/api/platform/auth/{ref}/**"| Kong
+ Browser -->|"/api/platform/pg-meta/{ref}/**"| Kong
+
+ Kong -->|"strip_path: true
(platform/* + v1/* majority)
→ functions:9000/traffic-one/{rest}"| Traffic
+ Kong -->|"strip_path: false
(platform/auth + platform/pg-meta)
→ functions:9000/traffic-one/api/platform/{auth,pg-meta}/{rest}"| Traffic
+ Traffic -->|supabase.auth.getUser| GoTrue
+ Traffic -->|traffic_api role + audit| PG
+ Traffic -->|resolve per-project URLs + keys| Resolver
+ Resolver -->|SELECT traffic.projects ⋈ vault.decrypted_secrets| PG
+ Resolver --> Vault
+ Traffic -->|/auth/v1/admin/* (config + users/invite/...)| GoTrue
+ Traffic -->|/query, /tables, /types, /generators/typescript, ...| PgMeta
+ Traffic -->|analytics SQL, usage SQL, log-drain tail| Logflare
+ Traffic -->|api mode: /_deploy, /_meta/*| FnAdmin
+ Traffic -->|shared-stack: {slug}/index.ts + .meta.json| Functions
+ Traffic -->|one-shot Pool: CREATE/DROP ROLE, ALTER ROLE| ProjectDB
+```
+
+## Request Flow
+
+### Authenticated routes (profile, tokens, etc.)
+
+```
+Browser
+ → GET /api/platform/profile (Authorization: Bearer JWT)
+ → Kong (strip_path: /api/platform/profile)
+ → Edge Runtime (http://functions:9000/traffic-one)
+ → traffic-one worker
+ → supabase.auth.getUser(token) → GoTrue → claims (sub, email)
+ → SELECT from traffic.profiles WHERE gotrue_id = claims.sub → Postgres
+ → 200 JSON response
+```
+
+### Organization routes
+
+```
+Browser
+ → GET/POST/PATCH/DELETE /api/platform/organizations* (Authorization: Bearer JWT)
+ → Kong (strip_path: /api/platform/organizations)
+ → Edge Runtime (http://functions:9000/traffic-one/organizations*)
+ → traffic-one worker
+ → supabase.auth.getUser(token) → GoTrue → claims (sub, email)
+ → getOrCreateProfile → profileId
+ → organization.service.ts → traffic.organizations + traffic.organization_members → Postgres
+ → audit log insert → traffic.audit_logs
+ → JSON response
+```
+
+### Unauthenticated routes (signup, reset-password)
+
+```
+Browser
+ → POST /api/platform/signup (no Authorization)
+ → Kong (strip_path: /api/platform/signup)
+ → Edge Runtime (http://functions:9000/traffic-one/signup)
+ → traffic-one worker
+ → supabase.auth.signUp() → GoTrue → creates user
+ → 201 response
+```
+
+### Billing routes
+
+```
+Browser
+ → GET/PUT/POST/DELETE /api/platform/organizations/{slug}/billing/* (Authorization: Bearer JWT)
+ → GET/PUT/POST/DELETE /api/platform/organizations/{slug}/customer
+ → GET/PUT/DELETE /api/platform/organizations/{slug}/tax-ids
+ → GET/POST/DELETE /api/platform/organizations/{slug}/payments*
+ → GET/POST /api/platform/projects/{ref}/billing/addons*
+ → GET/POST /api/platform/stripe/*
+ → Kong → Edge Runtime → traffic-one worker
+ → supabase.auth.getUser(token) → GoTrue → claims
+ → getOrCreateProfile → profileId
+ → routes/organizations.ts delegates to routes/billing.ts → services/billing.service.ts → Postgres
+ → (optional) services/stripe.service.ts → Stripe API (if STRIPE_API_KEY set)
+ → JSON response
+```
+
+### Team / Members routes
+
+```
+Browser
+ → GET /api/platform/organizations/{slug}/members*
+ → GET /api/platform/organizations/{slug}/roles
+ → POST/DELETE /api/platform/organizations/{slug}/members/invitations*
+ → PATCH/DELETE /api/platform/organizations/{slug}/members/{gotrue_id}*
+ → GET/PATCH /api/platform/organizations/{slug}/members/mfa/enforcement
+ → Kong → Edge Runtime → traffic-one worker
+ → supabase.auth.getUser(token) → GoTrue → claims
+ → getOrCreateProfile → profileId
+ → routes/organizations.ts delegates to routes/members.ts → services/member.service.ts
+ → member.service.ts → traffic.organization_members / traffic.organization_member_roles
+ / traffic.invitations / traffic.roles → Postgres
+ → audit log insert (for mutations) → traffic.audit_logs
+ → JSON response
+```
+
+### Organization Settings routes
+
+```
+Browser
+ → GET /api/platform/organizations/{slug}/audit?iso_timestamp_start&iso_timestamp_end
+ → GET/POST/PUT/DELETE /api/platform/organizations/{slug}/sso
+ → Kong → Edge Runtime → traffic-one worker
+ → supabase.auth.getUser(token) → GoTrue → claims
+ → getOrCreateProfile → profileId
+ → routes/organizations.ts delegates to services/org-settings.service.ts
+ → org-settings.service.ts → traffic.organizations / traffic.sso_providers / traffic.audit_logs → Postgres
+ → audit log insert (for mutations) → traffic.audit_logs
+ → JSON response
+```
+
+### Project routes
+
+```
+Browser
+ → GET/POST/PATCH/DELETE /api/platform/projects* (Authorization: Bearer JWT)
+ → GET /api/v1/projects/{ref}/health
+ → Kong → Edge Runtime → traffic-one worker
+ → supabase.auth.getUser(token) → GoTrue → claims
+ → getOrCreateProfile → profileId
+ → routes/projects.ts → services/project.service.ts
+ → project.service.ts → traffic.projects + membership check via traffic.organization_members → Postgres
+ → provisioner (local: env vars / api: external HTTP) → credentials
+ → Vault (vault.create_secret / vault.decrypted_secrets) → encrypted credential storage
+ → audit log insert → traffic.audit_logs
+ → JSON response
+
+Project creation flow:
+ 1. Verify org membership
+ 2. Generate 20-char hex ref
+ 3. Call provisioner.provision() → credentials
+ 4. Store sensitive credentials (service_key, db_pass, conn_string) in Vault
+ 5. INSERT project with Vault UUIDs + non-sensitive fields
+ 6. Write audit log
+ 7. Return CreateProjectResponse
+
+Lifecycle operations:
+ - Pause: status → INACTIVE
+ - Restore: status → ACTIVE_HEALTHY
+ - Restart: no-op (returns 200)
+```
+
+### Usage routes
+
+```
+Browser
+ → GET /api/platform/organizations/{slug}/usage?project_ref&start&end (Authorization: Bearer JWT)
+ → GET /api/platform/organizations/{slug}/usage/daily?start&end&project_ref
+ → Kong → Edge Runtime → traffic-one worker
+ → supabase.auth.getUser(token) → GoTrue → claims
+ → getOrCreateProfile → profileId
+ → routes/organizations.ts delegates to services/usage.service.ts
+ → usage.service.ts:
+ → Postgres: pg_database_size(), storage.objects → DATABASE_SIZE, STORAGE_SIZE
+ → Logflare (http://analytics:4000): SQL queries → FUNCTION_INVOCATIONS, EGRESS, MAU, REALTIME, etc.
+ → pricing.config.ts + traffic.pricing_overrides → cost calculation with discounts
+ → JSON response (OrgUsageResponse or OrgDailyUsageResponse)
+```
+
+### Route groups and handlers
+
+Every route group below is dispatched by [`functions/index.ts`](functions/index.ts) after Kong strips the `/api/platform/*` or `/api/v1/*` prefix. Handlers share the common Authorization → `getOrCreateProfile` → membership check pattern; the table below notes the strip-path convention, the tables touched, and the audit action(s) emitted.
+
+| Route group | Route file | Kong paths | Mutates | Audit actions |
+| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Profile / update-email** | [`routes/profile.ts`](functions/routes/profile.ts), [`routes/update-email.ts`](functions/routes/update-email.ts) | `/api/platform/profile*`, `/api/platform/update-email` | `traffic.profiles`, `auth.users.email` via GoTrue admin | `profile.email_updated` |
+| **Notifications** | [`routes/notifications.ts`](functions/routes/notifications.ts) | `/api/platform/notifications*` | `traffic.notifications` | `notifications.update` |
+| **GoTrue config** | [`routes/auth-config.ts`](functions/routes/auth-config.ts) | `/api/platform/auth/{ref}/config[/hooks]` | `traffic.auth_config_overrides` + (opportunistically) `{backend.endpoint}/auth/v1/admin/config` | `auth_config.update` |
+| **GoTrue admin proxy** | [`routes/project-auth-admin.ts`](functions/routes/project-auth-admin.ts) | `/api/platform/auth/{ref}/(users*\|invite\|magiclink\|recover\|otp\|validate/spam\|users/{id}/factors)` | Forwards to `{backend.endpoint}/auth/v1/admin/*` using `backend.serviceKey` | `auth_admin.user_{create,update,delete}`, `auth_admin.mfa_factors_delete`, `auth_admin.{invite,magiclink,recover,otp}`, `auth_admin.validate_spam` |
+| **pg-meta proxy** | [`routes/project-pg-meta.ts`](functions/routes/project-pg-meta.ts) | `/api/platform/pg-meta/{ref}/(query\|tables\|triggers\|types\|policies\|extensions\|foreign-tables\|materialized-views\|views\|column-privileges\|publications)` | Forwards to `{backend.pgMetaUrl}/*` using `backend.serviceKey` | `project.pg_meta.query` (emitted for every `POST /{ref}/query` regardless of upstream outcome) |
+| **Backups** | [`routes/backups.ts`](functions/routes/backups.ts) | `/api/platform/database/*/backups*` | read-only + 501 for restore/PITR | — |
+| **Replication** | [`routes/replication.ts`](functions/routes/replication.ts) | `/api/platform/replication/*` | read-only stub (empty arrays); 501 for writes | — |
+| **Analytics / log drains / infra-monitoring** | [`routes/project-analytics.ts`](functions/routes/project-analytics.ts) | `/api/platform/projects/{ref}/(analytics\|infra-monitoring\|api/(rest\|graphql))*` | `traffic.log_drains` | `project.log_drain_{created,updated,deleted}` |
+| **Database migrations** | [`routes/database-migrations.ts`](functions/routes/database-migrations.ts) | `/api/v1/projects/{ref}/database/migrations` (GET + POST; via `v1-projects-health` Kong service, NOT the pg-meta proxy) | `traffic.schema_migrations` | `schema_migrations.insert` |
+| **Feedback** | [`routes/feedback.ts`](functions/routes/feedback.ts) | `/api/platform/feedback/*` | `traffic.feedback` | `profile.feedback_submitted`, `profile.feedback_updated` |
+| **CLI** | [`routes/cli.ts`](functions/routes/cli.ts) | `/api/platform/cli/*` | `traffic.scoped_access_tokens` | `scoped_access_tokens.insert` |
+| **Project config + lint exceptions + DB password rotation** | [`routes/project-config.ts`](functions/routes/project-config.ts) | `/api/platform/projects/{ref}/config/(postgrest\|storage\|realtime\|pgbouncer\|secrets)`, `/settings/sensitivity`, `/db-password`, `/notifications/advisor/exceptions` | `traffic.project_config`, `traffic.lint_exceptions`, `traffic.projects.sensitivity` | `project.config_updated`, `project.db_password_rotated` |
+| **Disk / resize / regions / restore-versions** | [`routes/project-disk.ts`](functions/routes/project-disk.ts) | `/api/platform/projects/{ref}/(disk\|resize\|restore/versions)`, `/api/platform/projects/available-regions` | read-only; 501 for `/resize` and `POST /disk*` | — |
+| **Project network + read-replicas + privatelink** | [`routes/project-network.ts`](functions/routes/project-network.ts) | `/api/v1/projects/{ref}/(network-restrictions\|network-bans\|read-replicas)`, `/api/platform/projects/{ref}/privatelink/*` | stubs; 501 for mutations | — |
+| **Project lifecycle (upgrade, types, readonly, actions)** | [`routes/project-lifecycle.ts`](functions/routes/project-lifecycle.ts) | `/api/v1/projects/{ref}/(upgrade*\|types/typescript\|readonly/temporary-disable\|actions*)` | read-only or 501 | — |
+| **Project auth (third-party-auth, SSL enforcement, secrets)** | [`routes/project-auth.ts`](functions/routes/project-auth.ts) | `/api/v1/projects/{ref}/(config/auth/third-party-auth*\|ssl-enforcement\|secrets)` | `traffic.project_third_party_auth`, `traffic.project_secrets` (Vault-encrypted), `project_config.ssl_enforcement` column | `project.third_party_auth_{added,removed}`, `project.ssl_enforcement_updated`, `project.secret_set`, `project.secret_deleted` |
+| **Project API keys + signing keys** | [`routes/project-api-keys.ts`](functions/routes/project-api-keys.ts) | `/api/v1/projects/{ref}/(api-keys*\|config/auth/signing-keys*)` | `traffic.project_api_keys`, `traffic.project_jwt_signing_keys` | `project.api_key_{created,updated,revoked}`, `project.signing_key_{rotated,revoked}` |
+| **Content (snippets + folders)** | [`routes/content.ts`](functions/routes/content.ts) | `/api/platform/projects/{ref}/content*` | `traffic.content_items`, `traffic.content_folders` | `project.content_{created,updated,deleted}`, `project.content_folder_{created,updated,deleted}` |
+| **Branches + custom hostnames** | [`routes/branches.ts`](functions/routes/branches.ts), [`routes/custom-hostname.ts`](functions/routes/custom-hostname.ts) | `/api/v1/(projects/{ref}/branches*\|branches/*)`, `/api/v1/projects/{ref}/custom-hostname*` | `traffic.branches`, `traffic.custom_hostnames` | `project.branch_{created,updated,pushed,merged,reset,restored,deleted}`, `project.custom_hostname_initialized` |
+| **Edge function mutations** | [`routes/edge-function-mutations.ts`](functions/routes/edge-function-mutations.ts) | `/api/v1/projects/{ref}/functions/(deploy\|{slug})` (POST/PATCH/DELETE) | **shared-stack** (`isSharedStack(backend)`): filesystem writes into `/home/deno/functions/{slug}/` (writable bind-mount) + `.meta.json`. **api mode**: `POST {functionsApiUrl}/_deploy`, `PATCH/DELETE {functionsApiUrl}/_meta/{slug}` (see [§ Edge function deploy HTTP contract](#edge-function-deploy-http-contract-api-mode)) | `project.edge_function_{deployed,updated,deleted}` |
+| **JIT (just-in-time database access)** | [`routes/jit.ts`](functions/routes/jit.ts) | `/api/v1/projects/{ref}/(jit-access\|database/jit*)` | `traffic.jit_policies`, `traffic.jit_grants` + real Postgres roles via superuser pool | `project.jit_policy_updated`, `project.jit_grant_{issued,revoked}` |
+
+**GoTrue admin proxy semantics.** `GET /config` and `GET /config/hooks` return a three-layer merge: env-derived defaults ← (optional) live `GET {backend.endpoint}/auth/v1/admin/settings` ← `traffic.auth_config_overrides`. `PATCH /config` forwards the patch to `POST {backend.endpoint}/auth/v1/admin/config`; fields GoTrue accepts propagate live and any rejected fields fall through to the overrides table so Studio's view remains consistent even on self-hosted GoTrue builds that don't expose live mutation. All outbound calls are signed with `backend.serviceKey` (resolved via `getProjectBackend(ref)` — no global `GOTRUE_URL` / `JWT_SECRET` read). User / invite / magiclink / recover / otp / factor operations live on a sibling handler ([`routes/project-auth-admin.ts`](functions/routes/project-auth-admin.ts)); see the GoTrue admin proxy row in the route table above and [§ Project-backend dispatch](#project-backend-dispatch).
+
+**Logflare fallback.** When Logflare's SQL endpoint is unreachable, `logflare.client.ts` returns `{ result: [] }` so `GET /projects/{ref}/analytics/endpoints/logs.*` never 5xxs. Studio's chart renders an empty timeseries instead of a Suspense error.
+
+**Edge function deploy filesystem contract.** The `functions` container must mount `/home/deno/functions` as a **writable** bind-mount shared with the `traffic-one` worker. Multipart-body deploys write `{slug}/index.ts` + `.meta.json` atomically; delete is `Deno.remove(dir, { recursive: true })`. **There is no live reload** — newly-written files are picked up on the next cold start of the function slug (see `edge-function-mutations.ts:351-356`).
+
+### Edge function deploy HTTP contract (api mode)
+
+When `!isSharedStack(backend)` the traffic-one dispatcher proxies every mutation to `${backend.functionsApiUrl}`. The external runtime MUST expose this admin surface, signed with the project `service_role` key via `Authorization: Bearer …` + `apikey: …` :
+
+| Traffic-one helper | Outbound request | Expected response |
+| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------- |
+| `listRemoteFunctions` | `GET {functionsApiUrl}/_meta` | `FunctionEntry[]` (empty on non-2xx) |
+| `getRemoteFunction` | `GET {functionsApiUrl}/_meta/{slug}` | `FunctionEntry` or 404 |
+| `getRemoteFunctionBody` | `GET {functionsApiUrl}/_meta/{slug}/body` | `Array<{ name, content }>` or 404 |
+| `deployRemoteFunction` | `POST {functionsApiUrl}/_deploy` with `{ slug, name?, verify_jwt?, entrypoint_path?, import_map_path?, files: [{name, content}] }` | `FunctionEntry` on 2xx, error body on 4xx/5xx |
+| `patchRemoteFunction` | `PATCH {functionsApiUrl}/_meta/{slug}` with `FunctionMeta` JSON | `FunctionEntry` or 404 |
+| `deleteRemoteFunction` | `DELETE {functionsApiUrl}/_meta/{slug}` | 2xx (body ignored) or 404 |
+
+The previous plan draft described this as `PUT/DELETE {base}/{slug}`. That shape is **not** what the code implements — the authoritative set is the one above, centralized in [`services/edge-functions.service.ts`](functions/services/edge-functions.service.ts). An orchestrator that targets the old shape WILL fail the live path and fall through to whatever error handling the calling route applies.
+
+## Project-backend dispatch
+
+Every Studio call in the shape `/api/platform/*/{ref}/*` targets one specific
+tenant. A single self-hosted Studio can speak to many independently provisioned
+project backends (GoTrue, PostgREST, pg-meta, Logflare, Edge Functions,
+Postgres) — one set of URLs + credentials per `ref`. `traffic-one` centralises
+that dispatch so route handlers never touch a global environment variable for
+project-scoped traffic.
+
+### The `ProjectBackend` object
+
+[`services/project-backend.service.ts`](functions/services/project-backend.service.ts)
+exports `getProjectBackend(ref, pool)`, which joins `traffic.projects` with
+`vault.decrypted_secrets` and returns a single `ProjectBackend` object shaped
+like this:
+
+The only columns actually selected by the resolver's `SELECT ... FROM traffic.projects` are **`ref`, `endpoint`, `anon_key`, `db_host`, `service_key_secret_id`, `db_pass_secret_id`, `connection_string_secret_id`** (see [`functions/services/project-backend.service.ts`](functions/services/project-backend.service.ts) row shape). Every other field below is either derived from `endpoint` at resolve time, decrypted out of the Vault secret UUIDs, or read from a platform-global env var. There is **no** `pg_meta_url` / `logflare_url` / `functions_api_url` / `db_port` / `db_user` / `db_name` column on `traffic.projects` today — a Phase 6 schema migration would add some of those (see [§ Env-var fallback](#environment-variables) and the M9 note), but they do not exist now, and the table reflects the code as it ships.
+
+| Field | Type | Source | Used by |
+| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
+| `ref` | `string` | `traffic.projects.ref` (row column). | Echoed back on outbound audit rows. |
+| `endpoint` | `string` | `traffic.projects.endpoint` (row column); in shared-stack mode falls back to `SUPABASE_URL` env when the column is `NULL`. | Base URL for `/auth/v1`, `/rest/v1`, `/graphql/v1` proxies. |
+| `anonKey` | `string` | `traffic.projects.anon_key` (row column); in shared-stack mode falls back to `SUPABASE_ANON_KEY`. **Per-project mode refuses to fall back (C2)** — a missing `anon_key` surfaces as `project_backend_not_provisioned`. | `listLegacyApiKeys`, GraphQL proxy default auth. |
+| `serviceKey` | `string` | Decrypted from `vault.decrypted_secrets` via `service_key_secret_id` (row column). In shared-stack mode falls back to `SUPABASE_SERVICE_ROLE_KEY` / `SUPABASE_SECRET_KEY` / `SUPABASE_SERVICE_KEY`. **Per-project mode refuses to fall back (C2)** — see `anonKey` above. | `Authorization: Bearer …` + `apikey: …` on every outbound call (GoTrue admin, PostgREST, pg-meta, Logflare, Edge Functions admin). |
+| `pgMetaUrl` | `string` | **Derived, not a column.** Per-project: `{endpoint}/pg-meta/v1`. Shared-stack: `PG_META_URL` env (default `http://meta:8080`). | `/types/typescript`, `/api/platform/pg-meta/{ref}/*` dispatcher. |
+| `logflareUrl` | `string` | **Derived, not a column.** Per-project: `{endpoint}/analytics/v1`. Shared-stack: `LOGFLARE_URL` env (default `http://analytics:4000`). | `handleAnalyticsEndpoint`, `getOrgUsage`, `getOrgDailyUsage`. |
+| `logflareToken` | `string` | **Platform-global env only (M9 Phase 6 limitation).** Always `LOGFLARE_PRIVATE_ACCESS_TOKEN` — even in per-project mode. No per-tenant Vault secret today; a `logflare_access_token_secret_id` column is the planned Phase 6 migration. | `x-api-key` on Logflare SQL endpoint calls. |
+| `dbHost` | `string` | `traffic.projects.db_host` (row column); falls back to `POSTGRES_HOST` env or the host parsed out of `SUPABASE_DB_URL`. | In-container superuser pool target for JIT DDL + db-password rotation. |
+| `externalDbHost` | `string` | **Env only, not a column.** `SUPABASE_PUBLIC_DB_HOST` env when set; otherwise mirrors `dbHost`. | Human-readable connection string returned to clients (JIT `connection_string` field, cloud Studio download links). |
+| `dbPort` | `number` | **Env only, not a column.** `POSTGRES_PORT` env or the port parsed out of `SUPABASE_DB_URL` (default `5432`). | Composing `connectionString` fallback + rendered DSN. |
+| `dbUser` | `string` | **Env only, not a column.** `POSTGRES_USER` env or the user parsed out of `SUPABASE_DB_URL` (default `postgres`). | Composing `connectionString` fallback. |
+| `dbName` | `string` | **Env only, not a column.** `POSTGRES_DB` env or the database name parsed out of `SUPABASE_DB_URL` (default `postgres`). | Composing `connectionString` fallback. |
+| `dbPass` | `string` | Decrypted from `vault.decrypted_secrets` via `db_pass_secret_id` (row column); falls back to `POSTGRES_PASSWORD` env or the password parsed out of `SUPABASE_DB_URL`. | Only surfaced when Studio needs the project superuser password (not emitted by any current route); composing `connectionString` fallback. |
+| `connectionString` | `string` | Decrypted from `vault.decrypted_secrets` via `connection_string_secret_id` (row column) **iff** it parses to a DSN with a non-empty password; otherwise composed from `postgresql://{dbUser}:{dbPass}@{dbHost}:{dbPort}/{dbName}`. | One-shot `Pool` target for `updateDbPassword` + JIT `createPostgresRole` / `dropPostgresRole`. Empty string → service falls back to stubs. |
+| `functionsApiUrl` | `string` | **Derived, not a column.** Always `{endpoint}/functions/v1` after trimming a trailing slash. In shared-stack mode this is `http://kong:8000/functions/v1`; per-project it points at the orchestrator's edge-runtime admin surface. | `deployRemoteFunction`, `patchRemoteFunction`, `deleteRemoteFunction`, `listRemoteFunctions`, `getRemoteFunction*`. |
+
+`getProjectBackend` raises `ProjectBackendNotProvisionedError` when either
+`endpoint` or `serviceKey` cannot be resolved (even after env fallback). The
+error carries a `missing: string[]` array so route handlers can bubble a
+structured `501 { code: "project_backend_not_provisioned", missing: [...] }`
+response — Studio renders it as a "project not fully provisioned" banner
+instead of a generic 5xx.
+
+Two transport helpers live on the same module:
+
+- `fetchProjectJson(backend, path, init?, fetch?)` — path-relative variant.
+ `path` must start with `/` and is joined onto `backend.endpoint`. Sets
+ `Authorization: Bearer ${backend.serviceKey}`, `apikey: ${backend.serviceKey}`,
+ and `Content-Type: application/json` when a body is present unless the
+ caller already specified them.
+- `fetchProjectUrl(backend, url, init?, fetch?)` — absolute-URL variant.
+ Same auth injection as `fetchProjectJson`, but targets a fully-qualified
+ URL (used for `pgMetaUrl`, `functionsApiUrl`, `logflareUrl`, which may be
+ on a different host than `endpoint` in api-mode deployments).
+
+### ApiProvisioner response shape contract
+
+When `PROJECT_PROVISIONER=api`, project creation in [`services/project.service.ts`](functions/services/project.service.ts)
+delegates to the external HTTP orchestrator configured via `PROVISIONER_API_URL`.
+The provisioner's `POST /projects` response is consumed by
+[`services/provisioners/api.provisioner.ts`](functions/services/provisioners/api.provisioner.ts),
+which today reads **only the five fields below** — anything else in the
+response body is ignored. These fields feed directly into the
+`traffic.projects` row + three Vault secrets that back `getProjectBackend`:
+
+```json
+{
+ "endpoint": "https://{ref}.supabase.example.com",
+ "anon_key": "eyJhbGciOi...",
+ "service_key": "eyJhbGciOi...",
+ "db_host": "db-{ref}.supabase.example.com",
+ "db_pass": "super-secret"
+}
+```
+
+| Response field | Stored in | Required |
+| -------------- | ----------------------------------------------------- | ------------------------------------- |
+| `endpoint` | `traffic.projects.endpoint` (plain column) | Yes — drives every outbound proxy URL |
+| `anon_key` | `traffic.projects.anon_key` (plain column) | Yes for per-project mode (C2) |
+| `service_key` | `vault.decrypted_secrets` via `service_key_secret_id` | Yes — signs every outbound admin call |
+| `db_host` | `traffic.projects.db_host` (plain column) | Yes — in-container Pool target |
+| `db_pass` | `vault.decrypted_secrets` via `db_pass_secret_id` | Yes — Pool password |
+
+The `connection_string` secret is **not** accepted from the provisioner
+today: `services/project.service.ts` composes
+`postgresql://postgres:{db_pass}@{db_host}:5432/postgres` itself and writes
+that into the `connection_string_secret_id` slot. An orchestrator that ships
+a pre-built DSN will have it silently discarded.
+
+The following fields are part of the planned
+per-project surface but `api.provisioner.ts` does not read them yet.
+Returning them is harmless (they are ignored) but a deployment that depends
+on them will not work until the Phase 6 schema migration lands:
+
+- `pg_meta_url`, `logflare_url`, `functions_api_url` — today these are
+ **derived** from `endpoint` (per-project) or from env (shared-stack);
+ there are no corresponding columns on `traffic.projects` yet.
+- `logflare_token` — today always the platform-global
+ `LOGFLARE_PRIVATE_ACCESS_TOKEN` (see M9 / [§ Env-var fallback](#environment-variables)).
+ A future `logflare_access_token_secret_id` Vault slot is the planned fix.
+- `db_port`, `db_user`, `db_name` — today read from `POSTGRES_*` env /
+ `SUPABASE_DB_URL` only; they do not round-trip through the provisioner
+ or `traffic.projects`.
+
+Sensitive fields written to Vault (`service_key`, `db_pass`, and the
+locally-composed `connection_string`) are referenced by UUID from
+`traffic.projects.*_secret_id`. Non-sensitive fields (`endpoint`, `anon_key`,
+`db_host`) live on `traffic.projects` directly.
+
+In **local mode** (`PROJECT_PROVISIONER=local`, the default for
+single-container docker-compose deployments) the provisioner returns empty
+strings / nulls for many fields and `getProjectBackend` fills them in from
+the shared env vars (`SUPABASE_URL`, `SUPABASE_ANON_KEY`, `PG_META_URL`,
+`LOGFLARE_URL`, `POSTGRES_*`, etc.). The helper `isSharedStack(backend)` on
+the same module returns `true` iff `backend.endpoint` equals `SUPABASE_URL`
+(or `SUPABASE_URL` itself is empty, which we treat defensively as "shared
+stack" — see L1), which the edge-functions route uses to pick the
+filesystem-write path instead of proxying over HTTP.
+
+per-project mode disables `anon_key` + `service_key` env
+fallbacks.** When `isPerProjectBackend(row.endpoint)` is true — i.e. the
+project row's endpoint differs from the platform-global `SUPABASE_URL` —
+`getProjectBackend` refuses to substitute `SUPABASE_ANON_KEY` /
+`SUPABASE_SERVICE_ROLE_KEY` for a missing `traffic.projects.anon_key` /
+missing Vault `service_key_secret_id`. Falling back would silently sign
+outbound admin calls for tenant B with the platform-wide service_role key,
+which is a cross-tenant credential leak. Instead the resolver throws
+`ProjectBackendNotProvisionedError` with the exact missing credentials
+in `missing[]`, and route handlers surface that as a structured `501
+{ code: "project_backend_not_provisioned", missing: [...] }` via the
+shared [`notProvisionedResponse`](functions/utils/not-provisioned.ts)
+helper. The env fallbacks remain in play **only** in shared-stack
+mode, where they target the same single-tenant Docker stack that owns
+both keys. See [`services/project-backend.service.ts`](functions/services/project-backend.service.ts)
+for the code and `tests/services/project-backend-service-test.ts` for
+the regression cases.
+
+### 404-for-both anti-enumeration policy
+
+Every route that takes a `{ref}` path segment resolves it via
+`getProjectByRef(pool, ref, profileId)`, which returns `null` in **two
+observationally indistinguishable** cases:
+
+1. The `traffic.projects` row for `ref` does not exist at all.
+2. The row exists, but the authenticated caller is not a member of the
+ owning organization (the query joins `traffic.organization_members` on
+ `profile_id`).
+
+All handlers translate `null → 404 { message: "Project not found" }`.
+We deliberately do **not** distinguish these two cases with different
+HTTP status codes (e.g. `404` vs `403`) because doing so would leak
+existence of other tenants' project refs to any authenticated user: a
+`403` response would confirm the ref exists in some other organization,
+allowing an attacker with a cheap dictionary of candidate refs to map
+the tenant graph. The same policy applies to malformed refs (L4) —
+those return `400 invalid_project_ref` _before_ any DB lookup so the
+resolver never observes them.
+
+This is a deliberate departure from hosted Supabase, which emits
+distinct `403`s for cross-tenant reads on some admin surfaces. If
+you add a new `/{ref}/*` handler, follow suit: collapse both branches
+into a single `404` response. The regression tests in
+`tests/projects-test.ts` assert that a well-formed-but-unknown ref,
+a cross-tenant-membership ref, and a malformed ref all produce the
+expected 400/404 triple without structurally-different bodies.
+
+### Which routes dispatch via `ProjectBackend`
+
+Every handler below runs `getProjectByRef(pool, ref, profileId)` _first_ and
+only calls `getProjectBackend(ref, pool)` if the caller passed the
+membership check. That second call never races the first — both read from
+the same `pool` — so a cross-tenant ref cannot leak a `ProjectBackend`
+even under concurrent requests.
+
+| Route handler | Surface dispatched to | Backend field(s) consumed | Membership gate |
+| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------- |
+| [`auth-config.ts`](functions/routes/auth-config.ts) | GoTrue admin `/auth/v1/admin/settings` + `/admin/config` | `endpoint`, `serviceKey` | `getProjectByRef` |
+| [`project-auth-admin.ts`](functions/routes/project-auth-admin.ts) | GoTrue admin `/auth/v1/admin/users*`, `/invite`, `/magiclink`, `/otp`, … | `endpoint`, `serviceKey` | `getProjectByRef` |
+| [`project-analytics.ts`](functions/routes/project-analytics.ts) | Logflare SQL endpoint + PostgREST OpenAPI + pg_graphql introspection | `logflareUrl`, `logflareToken`, `endpoint`, `anonKey`, `serviceKey` | `getProjectByRef` |
+| [`project-lifecycle.ts`](functions/routes/project-lifecycle.ts) | pg-meta `/generators/typescript` | `pgMetaUrl`, `serviceKey` | `getProjectByRef` |
+| [`project-pg-meta.ts`](functions/routes/project-pg-meta.ts) | pg-meta `/query` + read-only surfaces | `pgMetaUrl`, `serviceKey` | `getProjectByRef` |
+| [`project-config.ts`](functions/routes/project-config.ts) (`/db-password`) | Project's Postgres (one-shot `Pool`; `ALTER ROLE postgres PASSWORD`) | `connectionString` | `getProjectByRef` |
+| [`projects.ts`](functions/routes/projects.ts) (`/{ref}/config/supavisor`, **H2**) | Composes pooler DSN + metadata from the resolved backend (not env) | `dbHost`, `dbPort`, `dbUser`, `dbName` (or parsed `connectionString`) | `getProjectByRef` |
+| [`jit.ts`](functions/routes/jit.ts) | Project's Postgres (one-shot `Pool`; `CREATE ROLE` / `DROP ROLE`) | `connectionString`, `dbHost`, `dbPort`, `dbName` | `getProjectByRef` |
+| [`edge-function-mutations.ts`](functions/routes/edge-function-mutations.ts) (POST/PATCH/DELETE) + **edge-function GETs in** [`projects.ts`](functions/routes/projects.ts) (**C1**) | `{functionsApiUrl}/_meta[...]` + `/_deploy` (when `!isSharedStack(backend)`) or filesystem writes at `/home/deno/functions/{slug}/` (when shared-stack) | `functionsApiUrl`, `endpoint`, `serviceKey` | `getProjectByRef` (mutations + `resolveFunctionsBackend` GETs alike) |
+| [`project-api-keys.ts`](functions/routes/project-api-keys.ts) (`/api-keys/legacy`) | None; reads anon + service keys straight off the backend | `anonKey`, `serviceKey` | `getProjectByRef` |
+| [`organizations.ts`](functions/routes/organizations.ts) (`/{slug}/usage*`, **H1**) | Logflare SQL endpoint (only when `?project_ref=` is present) | `logflareUrl`, `logflareToken` | Two-step: (1) caller in `{slug}` org, (2) `project_ref` in same org |
+
+**Three explicit callouts from the review:**
+
+- **C1 — edge function GETs.** `resolveFunctionsBackend` + the three `GET /{ref}/functions*` handlers in [`projects.ts`](functions/routes/projects.ts) now gate through `getProjectByRef` before ever calling `getProjectBackend`. Pre-C1 they loaded the backend straight from the row, which was an IDOR: any authenticated user could list / read another tenant's functions by guessing the ref.
+- **H1 — usage endpoints.** `/api/platform/organizations/{slug}/usage[/daily]` accepts an optional `?project_ref=` query parameter. [`organizations.ts`](functions/routes/organizations.ts) now verifies that the ref resolves to a project whose `organization_id` matches the slug's org **before** fetching the backend for Logflare. A ref that belongs to a different org collapses into the same `404` as a nonexistent ref (see the [404-for-both policy](#404-for-both-anti-enumeration-policy-m7) above).
+- **H2 — `/{ref}/config/supavisor`.** The previous implementation composed the pooler DSN from platform-global `POOLER_*` / `POSTGRES_DB` env vars, which is wrong in api-mode (every tenant would have gotten the shared pooler). The handler now runs through `getProjectByRef` for membership and reads the pooler components off the resolved `ProjectBackend` only.
+
+Everything else (profile, organizations, members, billing, notifications,
+access tokens, feedback, cli, content, replication, backups read-model, log
+drains CRUD, etc.) stays on the `traffic.*` schema and therefore reads from
+the shared `pool` only — no `ProjectBackend` resolution required.
+
+**Studio Next stubs are now unreachable via Kong.** The platform-auth and
+platform-pg-meta Kong routes (`strip_path: false`) catch every request under
+`/api/platform/auth/` and `/api/platform/pg-meta/` and forward them to
+`traffic-one`. The Studio files under
+`apps/studio/pages/api/platform/auth/[ref]/*` and
+`apps/studio/pages/api/platform/pg-meta/[ref]/*` still compile — they remain
+in the tree as an escape hatch for legacy / non-Kong deployments — but in
+any stack that mounts this repo's `docker/volumes/api/kong.yml` they will
+never receive traffic from the browser.
+
+## Usage APIs
+
+### Data Sources
+
+All usage metrics are derived from real data via two backends:
+
+| Backend | Metrics | Query Method |
+| -------- | ---------------------------- | ---------------------------------------------------------------- |
+| Postgres | `DATABASE_SIZE` | `pg_database_size(current_database())` |
+| Postgres | `STORAGE_SIZE` | `SUM((metadata->>'size')::bigint) FROM storage.objects` |
+| Logflare | `FUNCTION_INVOCATIONS` | `COUNT(DISTINCT id) FROM function_edge_logs` |
+| Logflare | `EGRESS` | `SUM(content_length) FROM edge_logs` with UNNEST on metadata |
+| Logflare | `MONTHLY_ACTIVE_USERS` | `COUNT(DISTINCT actor_id) FROM auth_logs` |
+| Logflare | `REALTIME_MESSAGE_COUNT` | `COUNT(*) FROM realtime_logs` |
+| Logflare | `REALTIME_PEAK_CONNECTIONS` | Derived from `realtime_logs` connection events |
+| Logflare | `STORAGE_IMAGES_TRANSFORMED` | `COUNT(*) FROM edge_logs WHERE path LIKE '/storage/v1/render/%'` |
+
+Logflare is queried via its SQL endpoint: `GET http://analytics:4000/api/endpoints/query/logs.all?project=default&sql=&iso_timestamp_start=&iso_timestamp_end=` with `x-api-key: LOGFLARE_PRIVATE_ACCESS_TOKEN`.
+
+### Pricing Model
+
+Default pricing is hardcoded in `pricing.config.ts` per plan (free/pro/team/enterprise). Three pricing strategies:
+
+| Strategy | Cost Calculation |
+| --------- | ----------------------------------------------------------------------- |
+| `UNIT` | `overage × per_unit_price` where `overage = max(0, usage - free_units)` |
+| `PACKAGE` | `ceil(overage / package_size) × package_price` |
+| `NONE` | Always $0 (metric tracked but not billed) |
+
+### Discount System
+
+Per-organization pricing overrides via `traffic.pricing_overrides`:
+
+| Column | Purpose |
+| ----------------------- | -------------------------------------------------------- |
+| `metric` | Specific metric (NULL = global discount for all metrics) |
+| `discount_percent` | Percentage off the overage price (e.g. 10.00 = 10%) |
+| `custom_free_units` | Override included quota (NULL = use plan default) |
+| `custom_per_unit_price` | Override per-unit price (NULL = use plan default) |
+
+**Override priority** (highest to lowest):
+
+1. Per-metric override for the org (`metric IS NOT NULL`)
+2. Global override for the org (`metric IS NULL`)
+3. Default plan pricing from `pricing.config.ts`
+
+**Cost formula with discounts:**
+
+```
+effective_free_units = override.custom_free_units ?? default.free_units
+effective_price = override.custom_per_unit_price ?? default.per_unit_price
+if (discount_percent > 0): effective_price *= (1 - discount_percent / 100)
+overage = max(0, usage - effective_free_units)
+cost = overage * effective_price // (or package-based for PACKAGE strategy)
+```
+
+## Design Decisions
+
+### Auth
+
+GoTrue JWT via `supabase.auth.getUser(token)` for all routes except `/signup` and `/reset-password`, which are public proxies to GoTrue's native signup (`POST /signup`) and recovery (`POST /recover`) endpoints. These use the existing supabase-js client (anon key) and forward captcha tokens via the SDK's `options.captchaToken`.
+
+### Database
+
+Direct Postgres via `TRAFFIC_DB_URL` using a restricted `traffic_api` role. This role has granular per-table permissions and is append-only on `traffic.audit_logs` (INSERT + SELECT, no UPDATE/DELETE). The `postgres` superuser is reserved for migrations.
+
+### Routing
+
+Kong `strip_path: true` strips route prefixes (`/api/platform/profile`, `/api/platform/organizations`, etc.). The function receives clean paths like `/`, `/access-tokens`, `/permissions`. For organizations, slug subpaths like `/{slug}` and `/{slug}/projects` are preserved after prefix stripping.
+
+**Studio port asymmetry (8082 vs 3000).** The prebuilt `supabase/studio:2026.04.08-sha-205cbe7` image runs `next dev -p ${STUDIO_PORT:-8082}` (see `apps/studio/package.json`). The base `docker/docker-compose.yml` healthcheck therefore probes `http://localhost:8082/api/platform/profile` rather than the upstream `localhost:3000` URL. Platform mode disables the healthcheck entirely in `docker-compose.platform.yml`, so this matters only for non-platform self-hosted users — if they run a build that listens on 3000 instead of 8082, the healthcheck will fail. This is flagged in [§ Known Gaps / Remaining Work](#known-gaps--remaining-work).
+
+### Kong Open Auth Routes
+
+Five Kong services expose GoTrue endpoints **without** the `key-auth` plugin (unlike the `auth-v1-*` services that wrap GoTrue with the apikey requirement):
+
+| Route | Kong service | Upstream |
+| ----------------------- | ---------------------- | -------------------------- |
+| `POST /auth/v1/token` | `auth-v1-open-token` | `http://auth:9999/token` |
+| `GET/PUT /auth/v1/user` | `auth-v1-open-user` | `http://auth:9999/user` |
+| `POST /auth/v1/logout` | `auth-v1-open-logout` | `http://auth:9999/logout` |
+| `POST /auth/v1/signup` | `auth-v1-open-signup` | `http://auth:9999/signup` |
+| `POST /auth/v1/recover` | `auth-v1-open-recover` | `http://auth:9999/recover` |
+
+All five use `strip_path: true`, a single CORS plugin, and forward any body/headers verbatim to the GoTrue upstream.
+
+**Why they are open.** Studio's platform-mode `AuthClient` (see `traffic-one/studio-patches/gotrue.ts`) is constructed with only `NEXT_PUBLIC_GOTRUE_URL` and does not attach an `apikey` header on login/refresh/logout/signup/recover calls, matching supabase.com's production dashboard behavior. Gating these endpoints behind `key-auth` in Kong would break the sign-in form, the refresh-token loop, sign-up, and password recovery in self-hosted platform mode. The endpoints themselves remain safe because GoTrue performs its own authentication (password, refresh token, JWT Bearer, or recovery nonce) and enforces rate limits / captcha internally.
+
+**Scope of exposure.** The `paths:` entries use prefix matching, so `POST /auth/v1/token?grant_type=refresh_token` and `PATCH /auth/v1/user` both route through. Other GoTrue endpoints (admin APIs, SSO, MFA) continue to flow through `auth-v1-*` which still requires the dashboard apikey.
+
+### Platform services routed to `traffic-one`
+
+Every Kong `platform-*` and `v1-*` service that forwards traffic to `traffic-one` is listed below. All services target the same upstream (`http://functions:9000/traffic-one`) and rely on Kong's `strip_path` behaviour to deliver clean tails to [`functions/index.ts`](functions/index.ts).
+
+| Kong service | Paths | `strip_path` | Notes |
+| ------------------------- | ------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `platform-profile` | `/api/platform/profile` | true | — |
+| `platform-update-email` | `/api/platform/update-email` | true | — |
+| `platform-signup` | `/api/platform/signup` | true | Unauthenticated |
+| `platform-reset-password` | `/api/platform/reset-password` | true | Unauthenticated |
+| `platform-organizations` | `/api/platform/organizations` | true | Dispatches sub-resources (billing, members, audit, sso, usage, documents, tax-ids, etc.) inside the function worker |
+| `platform-notifications` | `/api/platform/notifications` | true | Replaces the previously defined `platform-notifications-stub` (see below) |
+| `platform-auth` | `/api/platform/auth/` | false | Covers both the config surface (`/{ref}/config[/hooks]`) and the GoTrue admin surface (`/{ref}/users*`, `/invite`, `/magiclink`, `/recover`, `/otp`, `/users/{id}/factors`, `/validate/spam`). traffic-one dispatches to `handleAuthConfig` for config paths and `handleProjectAuthAdmin` for everything else; the Studio Next stubs under `apps/studio/pages/api/platform/auth/[ref]/*` are unreachable via Kong |
+| `platform-pg-meta` | `/api/platform/pg-meta/` | false | Dispatches `POST /{ref}/query` (SQL runner; audited as `project.pg_meta.query`) and the read-only GET surfaces (`tables`, `triggers`, `types`, `policies`, `extensions`, `foreign-tables`, `materialized-views`, `views`, `column-privileges`, `publications`) to `backend.pgMetaUrl` signed with the project service_role key; Studio's Next stubs under `apps/studio/pages/api/platform/pg-meta/[ref]/*` are unreachable via Kong |
+| `platform-database` | `/api/platform/database` | true | Dispatches `/backups*`, `/{ref}/backups/*`, etc. |
+| `platform-replication` | `/api/platform/replication` | true | Read-only stubs; mutations are 501 |
+| `platform-feedback` | `/api/platform/feedback` | true | `traffic.feedback` |
+| `platform-cli` | `/api/platform/cli` | true | CLI-login handshake backed by `traffic.scoped_access_tokens` |
+| `platform-telemetry` | `/api/platform/telemetry` | true | Sink for Studio telemetry events |
+| `v1-organizations` | `/api/v1/organizations` | true | V1 organization endpoints separate from the platform API |
+| `v1-branches` | `/api/v1/branches` | true | Global branch endpoints (diff, push, merge, reset, restore, delete) — per-project CRUD is served under `/api/v1/projects/{ref}/branches` via `platform-projects` |
+
+Project-level `/api/v1/projects/{ref}/*` endpoints (api-keys, signing-keys, ssl-enforcement, secrets, network, read-replicas, disk, types/typescript, upgrade, custom-hostname, functions/deploy, jit-access, database/jit, etc.) are all dispatched inside `traffic-one` after the `/api/v1/projects` → functions forwarding handled by the existing `v1-projects` + `platform-projects` services.
+
+#### `platform-notifications-stub` removal
+
+`platform-notifications-stub` was a transitional Kong service that returned a hard-coded empty notifications array while the real handler was being developed. It has been **removed** and replaced by `platform-notifications`, which routes to [`functions/routes/notifications.ts`](functions/routes/notifications.ts) backed by `traffic.notifications`. Operators upgrading from older builds should verify the stub block is gone from their mounted `docker/volumes/api/kong.yml`.
+
+### CORS
+
+Returns `Access-Control-Allow-Origin: *` and `Access-Control-Allow-Headers` on all responses and handles OPTIONS preflight.
+
+### Self-contained
+
+Each edge function contains all its own code. No `_shared/` folder. No cross-function imports. `corsHeaders` is exported from `index.ts` and imported by route handlers to avoid duplication.
+
+## Database Schema
+
+All tables live in the `traffic` schema.
+
+### traffic_api Role Permissions
+
+| Table | SELECT | INSERT | UPDATE | DELETE |
+| ------------------------- | ------ | ------ | ------ | ------ |
+| profiles | ✓ | ✓ | ✓ | ✓ |
+| organizations | ✓ | ✓ | ✓ | ✓ |
+| organization_members | ✓ | ✓ | ✓ | ✓ |
+| projects | ✓ | ✓ | ✓ | ✓ |
+| access_tokens | ✓ | ✓ | ✗ | ✓ |
+| scoped_access_tokens | ✓ | ✓ | ✗ | ✓ |
+| notifications | ✓ | ✓ | ✓ | ✗ |
+| audit_logs | ✓ | ✓ | ✗ | ✗ |
+| products | ✓ | ✓ | ✓ | ✓ |
+| prices | ✓ | ✓ | ✓ | ✓ |
+| subscriptions | ✓ | ✓ | ✓ | ✓ |
+| customers | ✓ | ✓ | ✓ | ✓ |
+| payment_methods | ✓ | ✓ | ✓ | ✓ |
+| invoices | ✓ | ✓ | ✓ | ✓ |
+| tax_ids | ✓ | ✓ | ✓ | ✓ |
+| credits | ✓ | ✓ | ✓ | ✓ |
+| credit_transactions | ✓ | ✓ | ✓ | ✓ |
+| project_addons | ✓ | ✓ | ✓ | ✓ |
+| upgrade_requests | ✓ | ✓ | ✓ | ✓ |
+| pricing_overrides | ✓ | ✓ | ✓ | ✓ |
+| sso_providers | ✓ | ✓ | ✓ | ✓ |
+| roles | ✓ | ✗ | ✗ | ✗ |
+| organization_member_roles | ✓ | ✓ | ✓ | ✓ |
+| invitations | ✓ | ✓ | ✓ | ✓ |
+
+### Other Permissions
+
+| Object | Permission | Purpose |
+| ------------------------------------------ | ---------- | ---------------------------------- |
+| `pg_database_size(name)` | EXECUTE | Usage API: query database size |
+| `storage.objects` | SELECT | Usage API: query storage size |
+| `vault.create_secret(text,text,text)` | EXECUTE | Projects: store credentials |
+| `vault.update_secret(uuid,text,text,text)` | EXECUTE | Projects: update credentials |
+| `vault.decrypted_secrets` | SELECT | Projects: read decrypted secrets |
+| `vault.secrets` | DELETE | Projects: remove secrets on delete |
+
+### Tables
+
+- **traffic.profiles** — `id SERIAL PK`, `gotrue_id UUID UNIQUE`, `username`, `primary_email`, `first_name`, `last_name`, `mobile`, `is_alpha_user`, `is_sso_user`, `free_project_limit`, `disabled_features TEXT[]`, timestamps
+- **traffic.organizations** — `id SERIAL PK`, `name`, `slug UNIQUE`, `billing_email`, `opt_in_tags TEXT[]`, `mfa_enforced BOOLEAN` (default `false`), `additional_billing_emails TEXT[]`, `plan_id` (default `free`), `plan_name` (default `Free`), timestamps
+- **traffic.organization_members** — `id SERIAL PK`, `organization_id FK` (CASCADE), `profile_id FK` (CASCADE), `role` (default `owner`), `created_at`, `UNIQUE(organization_id, profile_id)`
+- **traffic.access_tokens** — `id SERIAL PK`, `profile_id FK`, `name`, `token_hash`, `token_alias`, `scope`, `expires_at`, `last_used_at`, `created_at`
+- **traffic.scoped_access_tokens** — `id UUID PK`, `profile_id FK`, `name`, `token_hash`, `token_alias`, `permissions TEXT[]`, `organization_slugs TEXT[]`, `project_refs TEXT[]`, `expires_at`, `last_used_at`, `created_at`
+- **traffic.notifications** — `id UUID PK`, `profile_id FK`, `name`, `data JSONB`, `meta JSONB`, `priority`, `status`, `inserted_at`
+- **traffic.audit_logs** — `id UUID PK`, `profile_id FK`, `organization_id FK` (nullable, SET NULL on delete), `action_name`, `action_metadata JSONB`, `actor_id`, `actor_type`, `actor_metadata JSONB`, `target_description`, `target_metadata JSONB`, `occurred_at`
+- **traffic.projects** — `id SERIAL PK`, `ref TEXT UNIQUE`, `name`, `organization_id FK` (CASCADE), `region` (default `local`), `cloud_provider` (default `FLY`), `status` (default `COMING_UP`), `endpoint`, `anon_key`, `db_host`, `service_key_secret_id UUID` (Vault), `db_pass_secret_id UUID` (Vault), `connection_string_secret_id UUID` (Vault), timestamps
+
+#### Billing Tables (migration 007)
+
+- **traffic.products** — `id TEXT PK`, `active`, `name`, `description`, `image`, `metadata JSONB`
+- **traffic.prices** — `id TEXT PK`, `product_id FK`, `active`, `unit_amount`, `currency`, `type` (pricing_type enum), `interval` (pricing_plan_interval enum), `interval_count`, `trial_period_days`, `metadata JSONB`
+- **traffic.subscriptions** — `id TEXT PK`, `organization_id FK UNIQUE` (CASCADE), `status` (subscription_status enum), `price_id FK`, `tier`, `plan_id`, `plan_name`, `billing_cycle_anchor`, `usage_billing_enabled`, `nano_enabled`, `stripe_subscription_id`, `stripe_customer_id`, period timestamps
+- **traffic.customers** — `id SERIAL PK`, `organization_id FK UNIQUE` (CASCADE), `stripe_customer_id`, `billing_name`, address fields, timestamps
+- **traffic.payment_methods** — `id TEXT PK`, `organization_id FK` (CASCADE), `type`, `card_brand`, `card_last4`, `card_exp_month`, `card_exp_year`, `is_default`, `stripe_payment_method_id`
+- **traffic.invoices** — `id TEXT PK`, `organization_id FK` (CASCADE), `number`, `status`, `amount_due`, `subtotal`, `period_start`, `period_end`, `invoice_pdf`, `stripe_invoice_id`, `subscription_id`
+- **traffic.tax_ids** — `id SERIAL PK`, `organization_id FK` (CASCADE), `type`, `value`
+- **traffic.credits** — `id SERIAL PK`, `organization_id FK UNIQUE` (CASCADE), `balance`
+- **traffic.credit_transactions** — `id SERIAL PK`, `organization_id FK` (CASCADE), `amount`, `type`, `description`
+- **traffic.project_addons** — `id SERIAL PK`, `project_ref`, `addon_type`, `addon_variant`, `UNIQUE(project_ref, addon_type)`
+- **traffic.upgrade_requests** — `id SERIAL PK`, `organization_id FK` (CASCADE), `requested_plan`, `note`, `status`
+
+#### Usage Tables (migration 008)
+
+- **traffic.pricing_overrides** — `id SERIAL PK`, `organization_id FK` (CASCADE), `metric VARCHAR(64)` (NULL = global), `discount_percent NUMERIC(5,2)`, `custom_free_units NUMERIC`, `custom_per_unit_price NUMERIC`, `notes TEXT`, timestamps, `UNIQUE(organization_id, metric)`
+
+#### Team / Members Tables (migration 010)
+
+- **traffic.roles** — `id INTEGER PK`, `name TEXT UNIQUE`, `description TEXT`, `base_role_id INTEGER`. Seeded with 4 fixed roles: Read only (2), Developer (3), Administrator (4), Owner (5)
+- **traffic.organization_member_roles** — `id SERIAL PK`, `organization_id FK` (CASCADE), `profile_id FK` (CASCADE), `role_id FK` (CASCADE), `project_refs TEXT[]`, `created_at`, `UNIQUE(organization_id, profile_id, role_id)`. Junction table for multi-role assignment
+- **traffic.invitations** — `id SERIAL PK`, `organization_id FK` (CASCADE), `invited_email TEXT`, `role_id FK` (CASCADE), `token UUID UNIQUE`, `role_scoped_projects TEXT[]`, `invited_at`, `expires_at` (default now + 24h). Token-based invitation workflow
+
+#### Organization Settings Tables (migration 009)
+
+- **traffic.sso_providers** — `id UUID PK`, `organization_id FK UNIQUE` (CASCADE), `enabled BOOLEAN`, `metadata_xml_file TEXT`, `metadata_xml_url TEXT`, `domains TEXT[]`, `email_mapping TEXT[]`, `first_name_mapping TEXT[]`, `last_name_mapping TEXT[]`, `user_name_mapping TEXT[]`, `join_org_on_signup_enabled BOOLEAN`, `join_org_on_signup_role TEXT`, timestamps
+
+#### Auth Config Overrides (migration 012)
+
+- **traffic.auth_config_overrides** — `id SERIAL PK`, `project_ref TEXT`, `config_key TEXT`, `config_value JSONB`, `updated_at`, `UNIQUE(project_ref, config_key)`. Layer that sits on top of env-derived GoTrue defaults and any live `/admin/settings` response. See [Route groups and handlers](#route-groups-and-handlers).
+
+#### Schema Migrations (migration 013)
+
+- **traffic.schema_migrations** — `id SERIAL PK`, `project_ref TEXT`, `version TEXT`, `name TEXT`, `statements TEXT[]`, `inserted_at`, `UNIQUE(project_ref, version)`. Append-only log of DDL batches applied through `POST /api/v1/projects/{ref}/database/migrations` (routed via the `v1-projects-health` Kong service to [`routes/database-migrations.ts`](functions/routes/database-migrations.ts)). The handler does **not** live under the `/api/platform/pg-meta/*` proxy — an earlier draft of this doc cited that path, but it was never correct.
+
+#### Feedback (migration 014)
+
+- **traffic.feedback** — `id SERIAL PK`, `profile_id FK` (SET NULL), `category TEXT CHECK IN ('general','upgrade_survey','downgrade_survey','support_ticket')`, `message TEXT`, `project_ref TEXT`, `organization_slug TEXT`, `tags TEXT[]`, `metadata JSONB`, `custom_fields JSONB`, timestamps. Mutations are scoped by `profile_id` to prevent cross-user writes.
+
+#### Project API Keys & JWT Signing Keys (migration 015)
+
+- **traffic.project_api_keys** — `id SERIAL PK`, `project_ref TEXT`, `name TEXT`, `description TEXT`, `key_hash TEXT`, `key_alias TEXT`, `type TEXT CHECK IN ('publishable','secret')`, `tags TEXT[]`, timestamps, `deleted_at`. Plaintext surfaced once on CREATE and never stored.
+- **traffic.project_jwt_signing_keys** — `id SERIAL PK`, `project_ref TEXT`, `algorithm TEXT`, `status TEXT CHECK IN ('in_use','standby','previously_used','revoked')`, `public_jwk JSONB`, `private_jwk_secret_id UUID` (Vault), timestamps. Exactly one `in_use` row per project, enforced transactionally.
+
+#### Log Drains (migration 016)
+
+- **traffic.log_drains** — `id SERIAL PK`, `project_ref TEXT`, `token UUID UNIQUE`, `name TEXT`, `description TEXT`, `type TEXT`, `config JSONB`, `filters JSONB`, `active BOOLEAN`, timestamps, `deleted_at`. Partial unique index `(project_ref, name) WHERE deleted_at IS NULL`.
+
+#### Content (migration 017)
+
+- **traffic.content_folders** — `id UUID PK`, `project_ref TEXT`, `owner_id FK` (CASCADE), `parent_id UUID FK` (CASCADE), `name TEXT`, timestamps. Per-owner folder tree rooted at `parent_id IS NULL`.
+- **traffic.content_items** — `id UUID PK`, `project_ref TEXT`, `owner_id FK` (CASCADE), `folder_id UUID FK` (SET NULL), `name TEXT`, `description TEXT`, `type TEXT CHECK IN ('sql','report','log_sql')`, `visibility TEXT CHECK IN ('user','project')`, `content JSONB`, `favorite BOOLEAN`, timestamps. `visibility='user'` is owner-only; `visibility='project'` is readable by any member of the project's organization.
+
+#### Project Config + Lint Exceptions + `projects.sensitivity` (migration 018)
+
+- **traffic.project_config** — `id SERIAL PK`, `project_ref TEXT UNIQUE`, `postgrest JSONB`, `storage JSONB`, `realtime JSONB`, `pgbouncer JSONB`, `secrets_rotation JSONB`, `updated_at`. Per-surface JSONB override shallow-merged with code-side defaults on read.
+- **traffic.lint_exceptions** — `id SERIAL PK`, `project_ref TEXT`, `lint_name TEXT`, `disabled BOOLEAN`, `metadata JSONB`, timestamps, `UNIQUE(project_ref, lint_name)`.
+- `ALTER TABLE traffic.projects ADD COLUMN sensitivity TEXT CHECK IN ('LOW','MEDIUM','HIGH','CRITICAL') DEFAULT 'MEDIUM'`.
+
+#### Third-Party Auth + Secrets + `project_config.ssl_enforcement` (migration 019)
+
+- **traffic.project_third_party_auth** — `id UUID PK`, `project_ref TEXT`, `type TEXT CHECK IN ('oidc','custom_jwks')`, `oidc_issuer_url TEXT`, `jwks_url TEXT`, `custom_jwks JSONB`, `resolved_jwks JSONB`, timestamps.
+- **traffic.project_secrets** — `id SERIAL PK`, `project_ref TEXT`, `name TEXT`, `secret_id UUID` (Vault), timestamps, `UNIQUE(project_ref, name)`. Plaintext lives only in `vault.decrypted_secrets`.
+- `ALTER TABLE traffic.project_config ADD COLUMN ssl_enforcement JSONB DEFAULT '{}'::jsonb`.
+
+#### Branches + Custom Hostnames (migration 020)
+
+- **traffic.branches** — `id UUID PK`, `project_ref TEXT`, `branch_name TEXT`, `parent_project_ref TEXT`, `is_default BOOLEAN`, `git_branch TEXT`, `status TEXT CHECK IN ('created','pushing','pushed','merged','revoked')`, `pr_number INTEGER`, timestamps, `merged_at`, `deleted_at`. Partial unique index `(project_ref, branch_name) WHERE deleted_at IS NULL` so soft-deleted names can be reused.
+- **traffic.custom_hostnames** — `id SERIAL PK`, `project_ref TEXT UNIQUE`, `custom_hostname TEXT`, `status TEXT CHECK IN ('not_configured','pending','active','failed')`, `verification_errors JSONB`, `ownership_verified BOOLEAN`, `ssl_verified BOOLEAN`, timestamps. Activation/reverification are 501 on self-hosted; table is a mirror of user-entered config.
+- **No `branch_refs` table.** Every branch attribute lives on `traffic.branches` directly.
+
+#### JIT Policies + Grants (migration 021)
+
+- **traffic.jit_policies** — `id SERIAL PK`, `project_ref TEXT UNIQUE`, `policy JSONB`, `updated_at`. Handler returns defaults when no row exists.
+- **traffic.jit_grants** — `id SERIAL PK`, `project_ref TEXT`, `profile_id FK` (SET NULL), `username TEXT`, `password_secret_id UUID` (Vault), `scope TEXT`, `status TEXT CHECK IN ('active','pending','revoked','expired')`, `granted_at`, `expires_at`, `revoked_at`. `pending` status is used when the controlling connection lacks CREATEROLE (tests / restricted envs) — the grant row is persisted for the UI but no real PG role is materialized.
+
+## Audit Logging
+
+Audit log inserts are done in application code (not database triggers) so the function has full access to HTTP context (method, route, client IP, email). Every mutating operation wraps the table change and audit log insert in a single Postgres transaction.
+
+**Action names** follow `.`:
+
+| Action | When |
+| ---------------------------------- | -------------------------------------------------------------------- |
+| `profiles.insert` | Profile created (first login) |
+| `profiles.update` | Profile fields updated |
+| `access_tokens.insert` | Access token created |
+| `access_tokens.delete` | Access token revoked |
+| `scoped_access_tokens.insert` | Scoped token created |
+| `scoped_access_tokens.delete` | Scoped token revoked |
+| `organizations.insert` | Organization created |
+| `organizations.update` | Organization name/billing_email updated |
+| `organizations.delete` | Organization deleted |
+| `projects.insert` | Project created |
+| `projects.update` | Project name updated |
+| `projects.delete` | Project deleted |
+| `projects.pause` | Project paused (status → INACTIVE) |
+| `projects.restore` | Project restored (status → ACTIVE_HEALTHY) |
+| `projects.transfer` | Project transferred to another org |
+| `organizations.mfa_update` | MFA enforcement toggled |
+| `sso_providers.insert` | SSO provider created |
+| `sso_providers.update` | SSO provider updated |
+| `sso_providers.delete` | SSO provider deleted |
+| `organization_members.delete` | Member removed from organization |
+| `organization_member_roles.insert` | Role assigned to member |
+| `organization_member_roles.update` | Member role updated (project scoping) |
+| `organization_member_roles.delete` | Role unassigned from member |
+| `invitations.insert` | Invitation created |
+| `invitations.delete` | Invitation deleted |
+| `invitations.accept` | Invitation accepted (member joined) |
+| `notifications.update` | Notification status changed |
+| `notifications.archive_all` | Every non-archived notification for the profile archived in one call |
+| `account.login` | Login event recorded |
+| `subscriptions.update` | Subscription plan changed |
+| `customers.upsert` | Customer billing profile updated |
+| `tax_ids.insert` | Tax ID added |
+| `tax_ids.delete` | Tax ID removed |
+| `credits.redeem` | Credits redeemed |
+| `credits.top_up` | Credits purchased |
+| `upgrade_requests.insert` | Upgrade request submitted |
+
+### Additional actions
+
+The following additional actions are emitted by feature-specific services beyond the core profile / organization / project flows. Every action lives under one of four namespaces: `profile.*`, `project.*`, `auth_config.*`, or `schema_migrations.*`. (M5: the previous `auth_admin.*` namespace was renamed to `project.app_user_*` so that every tenant-scoped admin action lives under a single namespace alongside `project.pg_meta.query` and the other `project.*` rows. Existing rows in `traffic.audit_logs` that still carry the `auth_admin.*` prefix were written pre-M5 and are preserved as-is; new writes use `project.app_user_*`.)
+
+| Action | When |
+| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `profile.email_updated` | `PUT /update-email` success |
+| `profile.feedback_submitted` | `POST /feedback/send` success |
+| `profile.feedback_updated` | `PATCH /feedback/conversations/{id}/custom-fields` |
+| `auth_config.update` | `PATCH /api/platform/auth/{ref}/config` |
+| `project.app_user_create` | `POST /api/platform/auth/{ref}/users` succeeded at `{backend.endpoint}/auth/v1/admin/users` |
+| `project.app_user_update` | `PATCH /api/platform/auth/{ref}/users/{id}` succeeded at `{backend.endpoint}/auth/v1/admin/users/{id}` |
+| `project.app_user_delete` | `DELETE /api/platform/auth/{ref}/users/{id}` succeeded at `{backend.endpoint}/auth/v1/admin/users/{id}` |
+| `project.app_user_mfa_factors_delete` | `DELETE /api/platform/auth/{ref}/users/{id}/factors` — one audit row summarising every factor listed + deleted (H5: success-only; 502 on upstream `list` failure skips the audit) |
+| `project.app_user_invite` | `POST /api/platform/auth/{ref}/invite` succeeded at `{backend.endpoint}/auth/v1/invite` |
+| `project.app_user_magiclink` | `POST /api/platform/auth/{ref}/magiclink` succeeded at `{backend.endpoint}/auth/v1/magiclink` |
+| `project.app_user_recover` | `POST /api/platform/auth/{ref}/recover` succeeded at `{backend.endpoint}/auth/v1/recover` |
+| `project.app_user_otp` | `POST /api/platform/auth/{ref}/otp` succeeded at `{backend.endpoint}/auth/v1/otp` |
+| `project.app_user_validate_spam` | `POST /api/platform/auth/{ref}/validate/spam` — local heuristic only; GoTrue has no native endpoint (see README "Known gaps" and L6) |
+| `project.pg_meta.query` | `POST /api/platform/pg-meta/{ref}/query` — emitted regardless of upstream outcome; `action_metadata` records byte size + SHA-256 `sql_sha256` fingerprint + `disable_statement_timeout` (M12 replaced the pre-existing 512-char SQL preview to avoid secret-in-audit leakage) |
+| `schema_migrations.insert` | `POST /api/v1/projects/{ref}/database/migrations` applied a migration (via the `v1-projects-health` Kong service, NOT the `/api/platform/pg-meta/*` proxy — an earlier draft cited the wrong path) |
+| `project.api_key_created` | `POST /v1/projects/{ref}/api-keys` (publishable or secret) |
+| `project.api_key_updated` | `PATCH /v1/projects/{ref}/api-keys/{id}` |
+| `project.api_key_revoked` | `DELETE /v1/projects/{ref}/api-keys/{id}` (soft-delete) |
+| `project.signing_key_rotated` | `POST /v1/projects/{ref}/config/auth/signing-keys` and `POST /.../signing-keys/{id}/rotate` — both paths share the rotation code that moves `in_use → previously_used` and promotes `standby → in_use` |
+| `project.signing_key_revoked` | `DELETE /v1/projects/{ref}/config/auth/signing-keys/{id}` |
+| `project.log_drain_created` | `POST /projects/{ref}/analytics/log-drains` |
+| `project.log_drain_updated` | `PUT /projects/{ref}/analytics/log-drains/{token}` |
+| `project.log_drain_deleted` | `DELETE /projects/{ref}/analytics/log-drains/{token}` |
+| `project.content_folder_created` | `POST /projects/{ref}/content/folders` |
+| `project.content_folder_updated` | `PATCH /projects/{ref}/content/folders/{id}` |
+| `project.content_folder_deleted` | `DELETE /projects/{ref}/content/folders/{id}` |
+| `project.content_created` | `POST /projects/{ref}/content` (SQL / report / log-sql item) |
+| `project.content_updated` | `PATCH /projects/{ref}/content/{id}` + bulk `PATCH /projects/{ref}/content` |
+| `project.content_deleted` | `DELETE /projects/{ref}/content/{id}` |
+| `project.config_updated` | `PATCH /config/{postgrest,storage,realtime,pgbouncer,secrets}` + `PATCH /settings/sensitivity` |
+| `project.db_password_rotated` | `POST /projects/{ref}/db-password` |
+| `project.branch_created` | `POST /projects/{ref}/branches` |
+| `project.branch_updated` | `PATCH /v1/branches/{id}` (fields listed in `target_metadata.keys`) |
+| `project.branch_pushed` | `POST /v1/branches/{id}/push` |
+| `project.branch_merged` | `POST /v1/branches/{id}/merge` |
+| `project.branch_reset` | `POST /v1/branches/{id}/reset` |
+| `project.branch_restored` | `POST /v1/branches/{id}/restore` (soft-delete reversal) |
+| `project.branch_deleted` | `DELETE /v1/branches/{id}` |
+| `project.custom_hostname_initialized` | `POST /projects/{ref}/custom-hostname/initialize` |
+| `project.jit_policy_updated` | `PUT /projects/{ref}/jit-access` (policy JSON) |
+| `project.jit_grant_issued` | `PUT /projects/{ref}/database/jit` (real PG role created or `pending` fallback) |
+| `project.jit_grant_revoked` | `DELETE /projects/{ref}/database/jit/{id}` (or `cleanupExpiredGrants` tick) |
+| `project.third_party_auth_added` | `POST /projects/{ref}/config/auth/third-party-auth` |
+| `project.third_party_auth_removed` | `DELETE /projects/{ref}/config/auth/third-party-auth/{id}` |
+| `project.ssl_enforcement_updated` | `PUT /projects/{ref}/ssl-enforcement` |
+| `project.secret_set` | `POST /projects/{ref}/secrets` |
+| `project.secret_deleted` | `DELETE /projects/{ref}/secrets` |
+| `project.edge_function_deployed` | `POST /v1/projects/{ref}/functions/deploy` |
+| `project.edge_function_updated` | `PATCH /v1/projects/{ref}/functions/{slug}` |
+| `project.edge_function_deleted` | `DELETE /v1/projects/{ref}/functions/{slug}` |
+
+Enumerate the shipped action set at any point via:
+
+```
+rg "'(profile|project|auth_config|schema_migrations)\\.[a-z_]+'" traffic-one/functions
+```
+
+(the `auth_admin.*` namespace was retired; if the grep finds any
+remaining `auth_admin.*` strings in `functions/`, that's a regression and
+should be renamed to `project.app_user_*`.)
+
+If the audit insert fails, the entire transaction rolls back.
+
+## Permissions
+
+The permission service (`permission.service.ts`) queries `traffic.organization_members` joined with `traffic.organizations` to return one wildcard permission entry per organization the user belongs to. Each entry grants `actions: ["%"]` and `resources: ["%"]` for the corresponding `organization_slug`. If the user has no organizations, a fallback "default" slug is returned for backwards compatibility.
+
+## Authorization Rules (Members)
+
+| Operation | Required Role |
+| ---------------------------------- | ------------------------------------------------- |
+| List members / invitations / roles | Any org member |
+| Create invitation | Owner or Administrator (role_id ≥ 4) |
+| Delete invitation | Owner or Administrator |
+| Accept invitation | Any authenticated user (token validation) |
+| Delete member | Owner or Administrator (cannot remove last owner) |
+| Assign / update / unassign role | Owner or Administrator (cannot demote last owner) |
+| MFA enforcement toggle | Owner or Administrator |
+
+Authorization is checked via `getMemberHighestRoleId()` which returns the maximum `role_id` from `organization_member_roles` for the acting user.
+
+## Files Changed (Outside traffic-one/)
+
+See [§ Studio Patch Strategy](#studio-patch-strategy) for why some Studio changes are committed to source while others are mounted as volume overlays.
+
+### Studio source (committed)
+
+- `apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx` — guard `CreateHookSheet` against undefined `authConfig` (defensive null-check; upstream-worthy correctness fix).
+- `apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx` — null-safety on `payload` / `payload.content` and `snippet?.name` (defensive null-check).
+- `apps/studio/components/interfaces/Settings/Database/PoolingModesModal.tsx` — `Array.isArray` guard before `.find()` on the pooling modes list (defensive null-check).
+- `apps/studio/lib/api/incident-banner.ts` — return `[]` instead of throwing when `INCIDENT_IO_API_KEY` is unset (self-hosted gate).
+- `apps/studio/lib/api/self-hosted/util.ts` — allow self-hosted operation when `IS_PLATFORM && PLATFORM_PG_META_URL` is set (self-hosted-platform gate).
+- `apps/studio/proxy.ts` — early-return from the cloud proxy when `NEXT_PUBLIC_SELF_HOSTED_PLATFORM === 'true'` (self-hosted-platform gate).
+
+### Docker / Kong / env (committed)
+
+- `docker/docker-compose.yml` — Postgres image bumped to `supabase/postgres:17.6.1.084`; Studio healthcheck URL retargeted to `http://localhost:8082/api/platform/profile` because the prebuilt `supabase/studio:2026.04.08-sha-205cbe7` image runs `next dev` on port 8082 (see `apps/studio/package.json`).
+- `docker/docker-compose.platform.yml` — platform overlay. For `studio`: volume mounts for `traffic-one/studio-patches/*`, volume mounts for the three `apps/studio/*` module files patched above, `mem_limit`, healthcheck disable, `HOSTNAME: "::"`, `STUDIO_PORT: 3000`, and `NEXT_PUBLIC_*` platform env. For `functions`: `TRAFFIC_DB_URL`, `LOGFLARE_URL`, `LOGFLARE_PRIVATE_ACCESS_TOKEN`, `POOLER_TENANT_ID`, `POOLER_DEFAULT_POOL_SIZE`, `POOLER_MAX_CLIENT_CONN`, `POOLER_PROXY_PORT_TRANSACTION`, `POSTGRES_PORT`.
+- `docker/volumes/api/kong.yml` — open auth routes (`/auth/v1/{token,user,logout,signup,recover}`; see [§ Kong Open Auth Routes](#kong-open-auth-routes)) plus the `platform-profile` / `platform-signup` / `platform-reset-password` / `platform-organizations` services + routes inserted before the dashboard catch-all.
+- `docker/.env.example` — `TRAFFIC_API_PASSWORD` variable (harmless in non-platform mode since nothing else references it in the base compose file).
+
+### Auto-generated (git-ignored)
+
+- `docker/volumes/functions/traffic-one/` — regenerated by `traffic-one/deploy.sh` (via `cp -r`) from `traffic-one/functions/`. Not tracked in git; see `.gitignore`.
+
+## Studio Patch Strategy
+
+Studio is delivered to self-hosted platform mode using two complementary mechanisms. The choice for any given patch is dictated by whether the target file is a plain `.ts` module that Next.js can re-bundle at request time or a `.tsx` component / build-time file that must be present in the image when it boots.
+
+### 1. Volume overlays (preferred, ephemeral)
+
+For `.ts` library files whose changes need to travel with the platform layer rather than a fork of Studio, `docker-compose.platform.yml` bind-mounts replacement files into the running container. These overlays are read by the in-container `next dev` server the first time the module is requested and re-read on edit.
+
+| Host path | Container path | Purpose |
+| ------------------------------------------ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `traffic-one/studio-patches/gotrue.ts` | `/app/packages/common/gotrue.ts` | Replace the shared `AuthClient` constructor so Studio talks directly to GoTrue via `NEXT_PUBLIC_GOTRUE_URL` without forwarding the dashboard apikey (pairs with [§ Kong Open Auth Routes](#kong-open-auth-routes)). |
+| `traffic-one/studio-patches/apiHelpers.ts` | `/app/apps/studio/lib/api/apiHelpers.ts` | Strip the `x-connection-encrypted` header in self-hosted platform mode so `pg-meta` falls back to its default `PG_CONNECTION`. |
+| `traffic-one/studio-patches/.env.local` | `/app/apps/studio/.env.local` | Inject platform-mode env values that Next.js reads at dev-server startup. |
+| `apps/studio/lib/api/incident-banner.ts` | `/app/apps/studio/lib/api/incident-banner.ts` | Same file as the committed source edit; mounted read-only so platform-mode containers pick up the committed version of the file instead of whatever version shipped with the image. |
+| `apps/studio/proxy.ts` | `/app/apps/studio/proxy.ts` | Same rationale as `incident-banner.ts`. |
+| `apps/studio/lib/api/self-hosted/util.ts` | `/app/apps/studio/lib/api/self-hosted/util.ts` | Same rationale as above. |
+
+### 2. Source modifications (permanent)
+
+For `.tsx` React components and any file that must be baked into the image at build time, the change is committed to `apps/studio/*` so that a future Studio rebuild preserves the fix. These are the committed Studio edits listed in [§ Files Changed (Outside traffic-one/)](#files-changed-outside-traffic-one). Three of them (`incident-banner.ts`, `proxy.ts`, `self-hosted/util.ts`) are _also_ mounted as read-only overlays by `docker-compose.platform.yml` so that the currently pinned `supabase/studio` image — which was built before these fixes existed — picks them up at runtime without waiting for a rebuild.
+
+### Dev-mode assumption
+
+The whole strategy rests on the prebuilt `supabase/studio:2026.04.08-sha-205cbe7` image running Next.js in **dev mode** (`next dev -p 8082`), where modules are re-bundled on demand from the mounted `.ts` files. If that image (or a replacement) is ever switched to a production build (`next start` against a prebaked `.next/`), the bind mounts in `docker-compose.platform.yml` will silently have no effect — the bundled JavaScript in the image will be served instead. Upgrading the pinned image tag therefore requires re-validating that it still runs `next dev`, or migrating every overlay into the source tree and rebuilding the image from this repo.
+
+## Environment Variables
+
+All variables below are read via `Deno.env.get("…")` inside the `functions/` runtime. The list was enumerated against the shipped code (not earlier planning docs), so nothing phantom — `GOTRUE_ADMIN_URL`, `VAULT_URL`, `VAULT_TOKEN` — is listed. Project-secret encryption uses the **Postgres Vault extension** (`vault.create_secret` / `vault.decrypted_secrets`), not an HTTP Vault.
+
+### Core
+
+| Variable | Required | Description |
+| ---------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------- |
+| `SUPABASE_URL` | Yes | Base URL of the Supabase stack used by the supabase-js client inside `traffic-one`. |
+| `SUPABASE_ANON_KEY` | Yes | Anon key; supabase-js auth calls. |
+| `SUPABASE_SERVICE_KEY` / `SUPABASE_SERVICE_ROLE_KEY` | Yes | Service-role key for privileged supabase-js calls (legacy + canonical name, both accepted). |
+| `SUPABASE_SECRET_KEY` | No | Optional secret key used when signing "secret" project API keys; falls back to the service key. |
+| `TRAFFIC_DB_URL` / `SUPABASE_DB_URL` | Yes | Direct Postgres DSN used by `traffic_api` role connection pool. Two names for backwards compat. |
+| `JWT_SECRET` | Yes | GoTrue shared HS256 secret. Used by `services/gotrue-admin.service.ts` to mint the service-role JWT it sends to the GoTrue admin API. |
+
+### GoTrue admin proxy
+
+Read opportunistically by `gotrue-admin.service.ts` and the `getProjectBackend` resolver. The env values act as **shared-stack-only defaults** — when a project row carries a provisioner-issued `endpoint` that is not `SUPABASE_URL`, `traffic-one` signs outbound GoTrue admin calls with the project's own `serviceKey` and targets `{backend.endpoint}/auth/v1` instead of `GOTRUE_URL`. The variables below are only consulted when the project backend resolves to the shared local stack or when `traffic.auth_config_overrides` has no row and the live `GET {backend.endpoint}/auth/v1/admin/settings` fetch is empty or fails.
+
+| Variable | Purpose |
+| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `GOTRUE_URL` | Shared-stack fallback for the GoTrue admin HTTP API (e.g. `http://auth:9999`); ignored once the project backend resolves to a non-shared endpoint. |
+| `SITE_URL`, `API_EXTERNAL_URL` | URL config defaults. |
+| `MAILER_*`, `SMTP_*` | Mailer/SMTP defaults exposed to Studio's auth config UI. |
+| `EXTERNAL_*` | Per-provider OAuth defaults (e.g. `EXTERNAL_GOOGLE_ENABLED`, `EXTERNAL_GOOGLE_CLIENT_ID`, ...). |
+| `MAILER_TEMPLATES_*` | Template URL overrides (confirmation, recovery, magic-link, invite, email-change). |
+| `RATE_LIMIT_*` | GoTrue rate-limit knobs surfaced as read-only defaults. |
+| Every other `GOTRUE_*` | Any additional GoTrue env var is forwarded transparently to the merge. |
+
+### Analytics / log drains
+
+Per-project Logflare **URL** is resolved from `endpoint` when the row points at a per-project backend — see [§ Project-backend dispatch](#project-backend-dispatch). The **private access token** is **always** the platform-global env value, even in api mode (M9 Phase 6 limitation).
+
+| Variable | Required | Description |
+| ------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `LOGFLARE_URL` | Shared-stack | Logflare analytics endpoint used as the fallback `backend.logflareUrl` when the resolved `backend.endpoint` equals `SUPABASE_URL` (default: `http://analytics:4000`). Per-project mode composes `{endpoint}/analytics/v1` instead. |
+| `LOGFLARE_PRIVATE_ACCESS_TOKEN` | **All modes (platform-wide)** | Token sent as `x-api-key` on every Logflare SQL-endpoint call — in **both** shared-stack and api mode. There is no per-project `logflare_access_token_secret_id` column on `traffic.projects` today (M9). Multi-tenant api-mode deployments must configure every downstream Logflare instance to accept the same platform-wide token, or ship the Phase 6 per-project token migration before per-tenant isolation of analytics credentials is possible. |
+
+### Types / pg-meta
+
+Shared-stack-only fallback. When `getProjectBackend(ref)` returns a per-project `pgMetaUrl`, that value is used instead; see [§ Project-backend dispatch](#project-backend-dispatch).
+
+| Variable | Required | Description |
+| ------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `PG_META_URL` | Shared-stack | Fallback `backend.pgMetaUrl` for the shared local stack — used by `GET /v1/projects/{ref}/types/typescript`, the `/api/platform/pg-meta/{ref}/*` dispatcher, and the pg-meta surfaces (`tables`, `extensions`, `policies`, …) when the project backend points at `SUPABASE_URL`. |
+
+### Disk / versions
+
+| Variable | Required | Description |
+| ---------------------------- | -------- | ------------------------------------------------------------------------- |
+| `LOCAL_DISK_SIZE_GB` | No | Value returned by `GET /projects/{ref}/disk` for `size_gb` (default `8`). |
+| `LOCAL_DISK_TYPE` | No | Value returned for `type` (default `gp3`). |
+| `LOCAL_DISK_IOPS` | No | Value returned for `iops` (default `3000`). |
+| `LOCAL_DISK_THROUGHPUT_MBPS` | No | Value returned for `throughput_mbps` (default `125`). |
+| `POSTGRES_VERSION` | No | Shown under `GET /projects/{ref}/restore/versions` (default `15`). |
+
+### JIT
+
+Shared-stack-only fallbacks. JIT's `createPostgresRole` / `dropPostgresRole` target `backend.connectionString` via a one-shot `Pool` that is opened and closed per request; the env variables below only seed `backend.dbHost` / `backend.dbPort` / `backend.dbName` when the project backend resolves to the shared local stack (no provisioner-provided DSN). See [§ Project-backend dispatch](#project-backend-dispatch).
+
+| Variable | Required | Description |
+| ------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------- |
+| `POSTGRES_HOST` | Shared-stack | Fallback `backend.dbHost` used to compose the human-readable connection string surfaced to Studio on grant issue. |
+| `POSTGRES_PORT` | Shared-stack | Fallback `backend.dbPort`. |
+| `POSTGRES_USER` | Shared-stack | Superuser-capable username (needs `CREATEROLE`); used to seed `backend.connectionString` when the provisioner didn't. |
+| `POSTGRES_PASSWORD` | Shared-stack | Superuser password; used alongside `POSTGRES_USER` above. |
+| `POSTGRES_DB` | Shared-stack | Fallback `backend.dbName` (also reused by the pgbouncer config handler). |
+
+If `backend.connectionString` is empty (provisioner produced no DSN and no env fallback applied) or the controlling role cannot `CREATEROLE`, `jit.service.ts` falls back to `status='pending'` grants (row persists, no real PG role). `updateDbPassword` swallows the `ALTER ROLE` failure in the same way and still returns `{ result: "acknowledged" }` so Studio's rotate-password button never surfaces a 5xx.
+
+**Operator note — Postgres log redaction.** `createPostgresRole()` in [`services/jit.service.ts`](functions/services/jit.service.ts) issues `SET LOCAL log_statement = 'none'` inside its own transaction before running `CREATE ROLE` / `ALTER ROLE … PASSWORD`. That suppresses the DDL body so JIT passwords don't land in `postgresql.log` even when the cluster runs `log_statement = 'ddl'` or `'all'`. Operators running alongside external audit tooling that captures DDL outside the session (e.g. `pgaudit`) must still ensure those sinks are redacted or disabled for this function's session. Passwords fed into `createPostgresRole` are **server-generated only** (see `generatePassword()` in the same file); never interpolate user-supplied input into that code path.
+
+### Pooler (reported by `GET /projects/{ref}/config/pgbouncer`)
+
+| Variable | Required | Description |
+| ------------------------------- | -------- | ------------------------------------- |
+| `POOLER_TENANT_ID` | Yes | Supavisor tenant ID. |
+| `POOLER_DEFAULT_POOL_SIZE` | Yes | Default pool size reported to Studio. |
+| `POOLER_MAX_CLIENT_CONN` | Yes | Max client connections. |
+| `POOLER_PROXY_PORT_TRANSACTION` | Yes | Transaction-mode proxy port. |
+
+### Provisioner
+
+| Variable | Required | Description |
+| ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
+| `PROJECT_PROVISIONER` | No | `local` (default) or `api`. Controls whether project creation runs locally or calls an external HTTP provisioner. |
+| `PROVISIONER_API_URL` | If `api` | Base URL of the external provisioner. |
+| `DEFAULT_PROJECT_NAME` | No | Human-friendly default name surfaced in the CreateProjectResponse. |
+
+**External provisioner response contract.** When `PROJECT_PROVISIONER=api`, the orchestrator's `POST /projects` response MUST return the five fields documented under [§ ApiProvisioner response shape contract](#apiprovisioner-response-shape-contract): `endpoint`, `anon_key`, `service_key`, `db_host`, `db_pass`. Any other fields in the response body (e.g. `pg_meta_url`, `logflare_token`, `functions_api_url`) are **silently ignored** today — `api.provisioner.ts` does not read them and they are not persisted to `traffic.projects` or Vault. The downstream URL fields (`pgMetaUrl`, `logflareUrl`, `functionsApiUrl`) are derived from `endpoint` at dispatch time; `logflareToken` is always `LOGFLARE_PRIVATE_ACCESS_TOKEN` (M9, see [§ Analytics / log drains](#analytics--log-drains) above); and `dbPort`/`dbUser`/`dbName` come from `POSTGRES_*` env or `SUPABASE_DB_URL`. The missing Phase 6 work to round-trip those fields through the provisioner is tracked under [§ ApiProvisioner response shape contract](#apiprovisioner-response-shape-contract).
+
+In api mode, `getProjectBackend` will **not** fall back to the shared `SUPABASE_ANON_KEY` / `SUPABASE_SERVICE_ROLE_KEY` env vars when the project row's `anon_key` / Vault `service_key_secret_id` is missing — see the [C2 caveat](#the-projectbackend-object) in the ProjectBackend section. That missing-credential case surfaces as a `501 { code: "project_backend_not_provisioned", missing: [...] }` response. Shared-stack (`PROJECT_PROVISIONER=local`) mode continues to use the env-var fallbacks because all tenants resolve to the same Docker stack that owns both keys.
+
+### Billing
+
+| Variable | Required | Description |
+| ------------------------------- | -------- | ------------------------------------------------------------------------------------------- |
+| `STRIPE_API_KEY` | No | Stripe secret key. If not set, billing works in local-only mode (DB-backed, no Stripe sync) |
+| `STRIPE_WEBHOOK_SIGNING_SECRET` | No | Stripe webhook endpoint signing secret for verifying webhook events |
+
+## Self-hosted limitations
+
+The following endpoints are intentionally unimplemented in self-hosted mode and return `501 { code: "self_hosted_unsupported", message: "…" }`. Studio surfaces the `code` so the UI can render a helpful "not available in self-hosted" banner instead of a generic failure.
+
+| Endpoint | Reason |
+| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
+| `POST /api/platform/projects/{ref}/resize` | No block-device orchestration in self-hosted. |
+| `POST /api/platform/projects/{ref}/read-replicas/setup` | No replica provisioning. |
+| `POST /api/platform/projects/{ref}/read-replicas/remove` | No replica provisioning. |
+| `POST /api/v1/projects/{ref}/network-restrictions/apply` | No WAF/firewall integration. |
+| `POST /api/platform/projects/{ref}/privatelink/associations/*` | No VPC peering in self-hosted. |
+| `DELETE /api/platform/projects/{ref}/privatelink/associations/*` | No VPC peering in self-hosted. |
+| `POST /api/platform/cloud-marketplace/link` | Self-hosted is not sold through cloud marketplaces. |
+| `POST /api/platform/organizations/{slug}/documents/dpa` | No DPA document generation. |
+| `POST /api/platform/projects/{ref}/claim` | No cross-org project-transfer marketplace in self-hosted (ownership is implied by membership). |
+| `POST /api/v1/projects/{ref}/custom-hostname/activate` | No DNS control in self-hosted. |
+| `POST /api/v1/projects/{ref}/custom-hostname/reverify` | No DNS control in self-hosted. |
+| `POST /api/platform/database/{ref}/backups/restore` | No off-cluster PITR scaffolding. |
+| `POST /api/platform/database/{ref}/backups/pitr` | No off-cluster PITR scaffolding. |
+| `POST /api/platform/replication/{ref}/*` (writes) | Replication read-model only. |
+| `POST /api/v1/projects/{ref}/upgrade` | No in-place Postgres upgrade scaffolding. |
+| `PUT /api/platform/projects/{ref}/api-keys/legacy` | Rotating the legacy `anon` / `service_role` keys requires restarting the stack with new env vars, not a runtime write. |
+| `POST /api/platform/projects/{ref}/jwt-signing-keys/legacy/rotate` | Rotating the legacy HS256 `JWT_SECRET` requires restarting GoTrue with a new env var, not a runtime write. |
+
+Additionally, Stripe provisioning / reconciliation, Vercel integration, and Partners integration remain deferred (see [§ Known Gaps / Remaining Work](#known-gaps--remaining-work)).
+
+`GET /v1/branches/{id}/diff` returns an **empty-stub** shape (`{ diff: '', paths: [] }`). Computing a real diff would require a background `pg_dump` worker comparing branch + parent project schemas; this is intentionally not in-scope for self-hosted and is documented in [`traffic-one/tests/branches-test.ts`](tests/branches-test.ts) via the `empty-stub` assertion on that endpoint.
+
+## Invariants
+
+- Studio source is patched only for (a) defensive null-checks that are upstream-worthy correctness fixes and (b) self-hosted-platform-mode gates; every patched file is enumerated in [§ Files Changed (Outside traffic-one/)](#files-changed-outside-traffic-one). For the split between source edits and volume overlays, see [§ Studio Patch Strategy](#studio-patch-strategy).
+- All response shapes match `packages/api-types/types/platform.d.ts`
+- The dashboard catch-all route in Kong continues to work
+- `VERIFY_JWT` remains `false`; the function handles auth itself
+- Existing edge functions (`hello`, etc.) are unaffected
+
+## Known Gaps / Remaining Work
+
+The remaining gaps below are tracked for future work.
+
+### Intentional behavior notes
+
+- **Downloadable-backups shape.** `GET /api/platform/database/{ref}/backups/downloadable` returns `{ backups: [], status: "ok" }`. This wrapped shape matches `packages/api-types/types/platform.d.ts` and is what Studio's React Query hook destructures.
+- **CLI login token storage.** CLI-login handshake tokens are stored in `traffic.scoped_access_tokens` and surfaced via [`routes/cli.ts`](functions/routes/cli.ts) / [`tests/cli-test.ts`](tests/cli-test.ts).
+- **Branch diff is an empty stub.** `GET /v1/branches/{id}/diff` returns `{ diff: '', paths: [] }`. A real implementation would need an out-of-band `pg_dump` worker comparing the branch against the parent schema, which is out of scope for self-hosted. Covered by an `empty-stub` assertion in [`tests/branches-test.ts`](tests/branches-test.ts).
+- **Studio Next stubs unreachable via Kong.** The files under `apps/studio/pages/api/platform/auth/[ref]/*` and `apps/studio/pages/api/platform/pg-meta/[ref]/*` are shadowed by the `platform-auth` and `platform-pg-meta` Kong routes and therefore never receive traffic in any stack that mounts this repo's `docker/volumes/api/kong.yml`. They remain in the Studio source tree as an escape hatch for legacy / non-Kong deployments only. See [§ Project-backend dispatch](#project-backend-dispatch).
+
+### High severity
+
+- **Vercel / Partners / Stripe provisioning** is deferred. `provisioner.service.ts` ships with a `local` + `api` strategy only; cloud provisioners are **not** wired up and every Stripe-backed flow degrades to the DB-only path when `STRIPE_API_KEY` is unset. Cohort landing: `routes/provisioner.ts` + `services/partners.service.ts` + a Stripe webhook reconciler.
+
+### Medium severity
+
+- **Live GoTrue reconfigure is best-effort only.** `PATCH /api/platform/auth/{ref}/config` forwards to `POST {GOTRUE_URL}/admin/config` but self-hosted GoTrue only accepts a subset of fields at runtime (the rest need an env-variable change + container restart). Fields that GoTrue rejects or silently ignores persist in `traffic.auth_config_overrides` so Studio's read view stays consistent, but the running auth server keeps its boot-time config until the operator restarts it. Surfaced in [`services/gotrue-admin.service.ts`](functions/services/gotrue-admin.service.ts) with the same trade-off.
+- **`REALTIME_PEAK_CONNECTIONS` metric reports `0` in self-hosted daily usage.** Peak-concurrent-connections is derived from connection/disconnection events on hosted Supabase. Self-hosted Logflare does not capture those events, so [`services/usage.service.ts#getOrgDailyUsage`](functions/services/usage.service.ts) intentionally emits `usage: 0` for every day instead of running a misleading proxy query. The metric key is still present in the daily-usage feed so Studio's chart renders — it just always flat-lines.
+- **Sign-in SSR hydration mismatch in `LastSignInWrapper`** — Next.js dev overlay surfaces "Text content does not match server-rendered HTML" on `/sign-in`. The login form still works but the overlay must be dismissed. Fix lives in Studio source (`apps/studio/components/.../LastSignInWrapper.tsx`), not traffic-one.
+
+### Low severity
+
+- **TanStack Query DevTools button visible in platform mode** — the "Open TanStack query devtools" button renders in the bottom-left corner on every page. Fix: gate the devtools mount on `NEXT_PUBLIC_IS_PLATFORM !== 'true'` or a dedicated env flag in Studio source.
+- **Studio healthcheck port asymmetry** (also noted under [§ Routing](#routing)) — base `docker/docker-compose.yml` healthcheck probes `localhost:8082`, which only matches a Studio build running `next dev` / `next start` on port 8082. Non-platform self-hosted users whose Studio binary listens on 3000 will see the healthcheck fail; platform mode disables the healthcheck entirely so is unaffected.
+
+## Verification
+
+Re-run these from the repo root whenever you change shipped behaviour to confirm the docs above still match reality.
+
+```bash
+rg "'(profile|project|auth_config|schema_migrations)\.[a-z_]+'" traffic-one/functions
+```
+
+Enumerate the audit-log action names actually emitted by the function code. The output should match the union of [§ Audit Logging](#audit-logging) and [§ Additional actions](#additional-actions).
+
+```bash
+rg "Deno\.env\.get\(" traffic-one/functions
+```
+
+Enumerate every environment variable read by the function runtime. Cross-check the output against [§ Environment Variables](#environment-variables); any variable that appears here and is missing from the doc needs to be added, and anything documented but not matched is stale.
+
+```bash
+ls traffic-one/migrations/
+```
+
+Diff the migration filename list against [§ Tables](#tables). Each numbered migration must have a corresponding `#### ... (migration NNN)` subsection; absence of one or the other is a doc/schema drift signal.
diff --git a/traffic-one/README.md b/traffic-one/README.md
new file mode 100644
index 0000000000000..2639c0b0365b0
--- /dev/null
+++ b/traffic-one/README.md
@@ -0,0 +1,418 @@
+# traffic-one
+
+Platform Profile & Auth API implemented as a Supabase Edge Function. Provides user signup, password reset, profile management, access token CRUD, notifications, permissions, and audit logging.
+
+## Quick Start
+
+```bash
+# Prerequisites: Docker running with local Supabase stack (docker compose up)
+
+# Deploy the function, run migrations, restart containers
+./deploy.sh
+```
+
+## Architecture
+
+See [ARCHITECTURE.md](ARCHITECTURE.md) for the full request flow, design decisions, and database schema.
+
+**Request flow:** Browser → Kong → Edge Runtime → traffic-one worker → GoTrue (JWT verification) → Postgres
+
+## Project Structure
+
+```
+traffic-one/
+ functions/ # Edge function source (deployed to docker/volumes/functions/traffic-one/)
+ index.ts # Deno.serve entry + URL router + auth
+ db.ts # Postgres pool (TRAFFIC_DB_URL)
+ deno.json # Import map
+ deno.lock # Canonical lockfile for traffic-one Deno commands
+ routes/ # HTTP route handlers
+ auth.ts # POST /signup, POST /reset-password (unauthenticated)
+ profile.ts # GET/PUT /
+ access-tokens.ts # CRUD /access-tokens
+ scoped-access-tokens.ts # CRUD /scoped-access-tokens
+ notifications.ts # GET/PATCH /notifications
+ organizations.ts # CRUD /organizations, /organizations/{slug}
+ members.ts # Members, invitations, roles, MFA
+ billing.ts # Billing, payments, customer, tax, addons
+ permissions.ts # GET /permissions
+ audit.ts # GET /audit, POST /audit-login
+ auth-config.ts # GET/PATCH /auth/{ref}/config + /config/hooks
+ project-auth-admin.ts # GoTrue admin proxy (users/invite/magiclink/recover/otp/factors/validate-spam)
+ project-pg-meta.ts # pg-meta proxy (query + tables/types/policies/extensions/…)
+ services/ # Business logic + DB queries
+ profile.service.ts
+ access-token.service.ts
+ notification.service.ts
+ organization.service.ts
+ project.service.ts # Project CRUD, status, transfer, membership enforcement
+ project-backend.service.ts # getProjectBackend(ref) + fetchProjectJson / fetchProjectUrl
+ member.service.ts # Members, invitations, roles, MFA enforcement
+ billing.service.ts # DB queries for billing operations
+ stripe.service.ts # Stripe API wrapper (graceful degradation)
+ usage.service.ts # Usage metrics from Postgres + Logflare
+ pricing.config.ts # Default pricing per plan for all metrics
+ logflare.client.ts # Logflare SQL endpoint HTTP client (per-project aware)
+ gotrue-admin.service.ts # Backend-scoped GoTrue /admin/settings + /admin/config
+ permission.service.ts
+ org-settings.service.ts # MFA enforcement, SSO provider CRUD, org audit logs
+ provisioners/
+ local.provisioner.ts # Reads Docker env vars (local dev mode)
+ api.provisioner.ts # Calls external orchestration API (production mode)
+ types/
+ api.ts # Response types matching platform.d.ts
+ billing.ts # Billing response types
+ migrations/ # SQL migrations (run as postgres superuser)
+ 001_create_schema_and_role.sql
+ 002_create_profiles.sql
+ 003_create_access_tokens.sql
+ 004_create_notifications.sql
+ 005_create_audit_logs.sql
+ 006_create_organizations.sql
+ 007_create_billing_tables.sql
+ 008_create_pricing_overrides.sql
+ 009_create_org_settings.sql
+ 010_create_roles_and_invitations.sql
+ 011_create_projects.sql
+ kong/
+ platform-routes.yml # Kong config snippet (reference)
+ tests/
+ .env # Test env vars
+ traffic-one-test.ts # Integration tests (full HTTP round-trip)
+ organizations-test.ts # Organization integration tests
+ services/ # Unit tests (direct DB)
+ deploy.sh # Deployment script
+```
+
+## API Endpoints
+
+### Auth Endpoints (unauthenticated)
+
+Served via Kong at `/api/platform/signup` and `/api/platform/reset-password`. No Authorization header required.
+
+| Kong Path | Method | Description |
+| ------------------------------ | ------ | ------------------------- |
+| `/api/platform/signup` | POST | Create new user account |
+| `/api/platform/reset-password` | POST | Send password reset email |
+
+### Profile Endpoints
+
+All paths are relative to `/api/platform/profile` (Kong strips the prefix before forwarding):
+
+| Path | Method | Description |
+| ---------------------------- | ------ | ------------------------------------- |
+| `/` | GET | Get or create profile |
+| `/` or `/update` | PUT | Update profile fields |
+| `/access-tokens` | GET | List access tokens |
+| `/access-tokens` | POST | Create access token |
+| `/access-tokens/{id}` | DELETE | Delete access token |
+| `/scoped-access-tokens` | GET | List scoped tokens |
+| `/scoped-access-tokens` | POST | Create scoped token |
+| `/scoped-access-tokens/{id}` | DELETE | Delete scoped token |
+| `/notifications` | GET | List notifications |
+| `/notifications` | PATCH | Bulk update notification status |
+| `/notifications/{id}` | PATCH | Update single notification |
+| `/permissions` | GET | Get user permissions |
+| `/audit` | GET | Get audit logs (requires date params) |
+| `/audit-login` | POST | Record login event |
+
+### Organization Endpoints
+
+Served via Kong at `/api/platform/organizations` (Kong strips the prefix before forwarding):
+
+| Path | Method | Description |
+| --------------------------------- | ------ | --------------------------------------------------------------------------------- |
+| `/` | GET | List user's organizations |
+| `/` | POST | Create organization |
+| `/{slug}` | GET | Get organization detail by slug |
+| `/{slug}` | PATCH | Update organization (name, billing_email, opt_in_tags, additional_billing_emails) |
+| `/{slug}` | DELETE | Delete organization (owner only) |
+| `/{slug}/projects` | GET | List organization projects |
+| `/{slug}/audit` | GET | Get org audit logs (requires date params) |
+| `/{slug}/members/mfa/enforcement` | GET | Get MFA enforcement status |
+| `/{slug}/members/mfa/enforcement` | PATCH | Toggle MFA enforcement |
+| `/{slug}/sso` | GET | Get SSO provider config |
+| `/{slug}/sso` | POST | Create SSO provider config |
+| `/{slug}/sso` | PUT | Update SSO provider config |
+| `/{slug}/sso` | DELETE | Delete SSO provider config |
+| `/{slug}/usage` | GET | Get aggregate usage with billing metadata |
+| `/{slug}/usage/daily` | GET | Get daily time-series usage |
+
+### Team / Members Endpoints
+
+Served via Kong at `/api/platform/organizations` (sub-paths of `/{slug}`):
+
+| Path | Method | Description |
+| --------------------------------------------- | ------ | ----------------------------------------------- |
+| `/{slug}/members` | GET | List org members with profile data and role_ids |
+| `/{slug}/members/{gotrue_id}` | DELETE | Remove a member (admin/owner only) |
+| `/{slug}/members/{gotrue_id}` | PATCH | Assign role to member (Version 2) |
+| `/{slug}/members/{gotrue_id}/roles/{role_id}` | PUT | Update a member's role (project scoping) |
+| `/{slug}/members/{gotrue_id}/roles/{role_id}` | DELETE | Unassign a role from member |
+| `/{slug}/members/invitations` | GET | List pending invitations |
+| `/{slug}/members/invitations` | POST | Create invitation (email + role_id) |
+| `/{slug}/members/invitations/{id}` | DELETE | Delete a pending invitation |
+| `/{slug}/members/invitations/{token}` | GET | Get invitation details by token |
+| `/{slug}/members/invitations/{token}` | POST | Accept invitation (adds member) |
+| `/{slug}/members/reached-free-project-limit` | GET | Check free project limits |
+| `/{slug}/members/mfa/enforcement` | GET | Get MFA enforcement state |
+| `/{slug}/members/mfa/enforcement` | PATCH | Update MFA enforcement state |
+| `/{slug}/roles` | GET | List available roles (org + project scoped) |
+
+#### Usage Query Parameters
+
+| Parameter | Endpoint | Description |
+| ------------- | -------- | ----------------------------------------------------- |
+| `project_ref` | Both | Filter by project (default: `default`) |
+| `start` | Both | ISO 8601 start date (default: start of current month) |
+| `end` | Both | ISO 8601 end date (default: now) |
+
+### Billing Endpoints
+
+Served via Kong at `/api/platform/organizations` and `/api/platform/projects`:
+
+| Path | Method | Description |
+| -------------------------------------------- | ------ | ----------------------------- |
+| `/{slug}/billing/subscription` | GET | Get org subscription details |
+| `/{slug}/billing/subscription` | PUT | Change plan/tier |
+| `/{slug}/billing/subscription/preview` | POST | Preview plan change cost |
+| `/{slug}/billing/subscription/confirm` | POST | Confirm pending payment |
+| `/{slug}/billing/plans` | GET | List available plans |
+| `/{slug}/billing/invoices` | GET | List invoices (paginated) |
+| `/{slug}/billing/invoices` | HEAD | Invoice count (X-Total-Count) |
+| `/{slug}/billing/invoices/upcoming` | GET | Upcoming invoice preview |
+| `/{slug}/billing/invoices/{id}` | GET | Single invoice |
+| `/{slug}/billing/invoices/{id}/receipt` | GET | Invoice receipt |
+| `/{slug}/billing/invoices/{id}/payment-link` | GET | Payment link |
+| `/{slug}/customer` | GET | Get billing profile |
+| `/{slug}/customer` | PUT | Update billing profile |
+| `/{slug}/tax-ids` | GET | List tax IDs |
+| `/{slug}/tax-ids` | PUT | Add tax ID |
+| `/{slug}/tax-ids` | DELETE | Remove tax ID |
+| `/{slug}/payments` | GET | List payment methods |
+| `/{slug}/payments` | DELETE | Detach payment method |
+| `/{slug}/payments/setup-intent` | POST | Create Stripe SetupIntent |
+| `/{slug}/payments/default` | PUT | Set default payment method |
+| `/{slug}/billing/credits/top-up` | POST | Purchase credits |
+| `/{slug}/billing/credits/redeem` | POST | Redeem credit code |
+| `/{slug}/billing/upgrade-request` | POST | Request plan upgrade |
+
+### Project Endpoints
+
+Served via Kong at `/api/platform/projects` (Kong strips the prefix before forwarding):
+
+| Path | Method | Description |
+| ----------------------------- | ------ | --------------------------------------------------- |
+| `/` | GET | List all user's projects (paginated) |
+| `/` | POST | Create project (name, organization_slug, db_region) |
+| `/{ref}` | GET | Get project detail by ref |
+| `/{ref}` | PATCH | Update project (name) |
+| `/{ref}` | DELETE | Delete project |
+| `/{ref}/status` | GET | Get project status |
+| `/{ref}/pause/status` | GET | Get pause status |
+| `/{ref}/pause` | POST | Pause project (sets INACTIVE) |
+| `/{ref}/restore` | POST | Restore project (sets ACTIVE_HEALTHY) |
+| `/{ref}/restart` | POST | Restart project (no-op) |
+| `/{ref}/restart-services` | POST | Restart services (no-op) |
+| `/{ref}/service-versions` | GET | Get service versions (stub) |
+| `/{ref}/transfer/preview` | POST | Preview project transfer |
+| `/{ref}/transfer` | POST | Transfer project to another org |
+| `/projects-resource-warnings` | GET | Resource warnings (empty array) |
+
+Health endpoint (separate Kong route at `/api/v1/projects`):
+
+| Path | Method | Description |
+| --------------- | ------ | -------------------- |
+| `/{ref}/health` | GET | Project health check |
+
+### Project Billing Endpoints
+
+| Path | Method | Description |
+| ------------------------------------------ | ------ | ------------------- |
+| `/projects/{ref}/billing/addons` | GET | List project addons |
+| `/projects/{ref}/billing/addons` | POST | Apply addon |
+| `/projects/{ref}/billing/addons/{variant}` | DELETE | Remove addon |
+
+### Stripe Endpoints
+
+| Path | Method | Description |
+| ------------------------------------- | ------ | -------------------------- |
+| `/stripe/invoices/overdue` | GET | Count overdue invoices |
+| `/stripe/setup-intent` | POST | Create generic SetupIntent |
+| `/organizations/confirm-subscription` | POST | Confirm org subscription |
+
+### Project-scoped GoTrue admin proxy
+
+Served via Kong at `/api/platform/auth/` with `strip_path: false`. The traffic-one dispatcher resolves the per-project GoTrue backend via `getProjectBackend(ref)` and signs every outbound call with that project's `service_role` key — a single Studio can therefore manage users across many independently provisioned project backends.
+
+Config paths (`/config`, `/config/hooks`) stay on the env-merge + override-table flow (see [`routes/auth-config.ts`](functions/routes/auth-config.ts)); everything else dispatches via [`routes/project-auth-admin.ts`](functions/routes/project-auth-admin.ts) to `{backend.endpoint}/auth/v1/admin/*`.
+
+| Path | Method | Description |
+| --------------------------- | ------ | ------------------------------------------------------------------ |
+| `/{ref}/config` | GET | Get merged GoTrue config (defaults ← live ← overrides) |
+| `/{ref}/config` | PATCH | Update GoTrue config (live + overrides) |
+| `/{ref}/config/hooks` | GET | Same shape as `/config`, scoped to webhook fields |
+| `/{ref}/config/hooks` | PATCH | Same behaviour as `PATCH /config` |
+| `/{ref}/users` | POST | Create a user in the project's GoTrue |
+| `/{ref}/users/{id}` | PATCH | Update a user (email, phone, ban duration, metadata, …) |
+| `/{ref}/users/{id}` | DELETE | Delete a user |
+| `/{ref}/users/{id}/factors` | DELETE | Delete every MFA factor on a user |
+| `/{ref}/invite` | POST | Send an admin-invite email |
+| `/{ref}/magiclink` | POST | Send a magic-link email |
+| `/{ref}/recover` | POST | Send a password-recovery email |
+| `/{ref}/otp` | POST | Trigger an OTP flow |
+| `/{ref}/validate/spam` | POST | Local heuristic stub (GoTrue has no native validate/spam endpoint) |
+
+Studio's fallback Next.js stubs under `apps/studio/pages/api/platform/auth/[ref]/*` are **unreachable** once this repo's `docker/volumes/api/kong.yml` is mounted — the Kong `platform-auth` route (`strip_path: false`) wins.
+
+### Project-scoped pg-meta proxy
+
+Served via Kong at `/api/platform/pg-meta/` with `strip_path: false`. The traffic-one dispatcher ([`routes/project-pg-meta.ts`](functions/routes/project-pg-meta.ts)) resolves the per-project backend via `getProjectBackend(ref)` and forwards every surface to `{backend.pgMetaUrl}/` using the project `service_role` key.
+
+| Path | Method | Description |
+| --------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------- |
+| `/{ref}/query` | POST | Run an arbitrary SQL query (body: `{ query, disable_statement_timeout? }`). Audit-logged as `project.pg_meta.query`. |
+| `/{ref}/tables` | GET | List tables |
+| `/{ref}/triggers` | GET | List triggers |
+| `/{ref}/types` | GET | List user-defined types |
+| `/{ref}/policies` | GET | List row-level-security policies |
+| `/{ref}/extensions` | GET | List extensions |
+| `/{ref}/foreign-tables` | GET | List foreign tables |
+| `/{ref}/materialized-views` | GET | List materialized views |
+| `/{ref}/views` | GET | List views |
+| `/{ref}/column-privileges` | GET | List column privileges |
+| `/{ref}/publications` | GET | List logical-replication publications |
+
+Studio's fallback Next.js stubs under `apps/studio/pages/api/platform/pg-meta/[ref]/*` are **unreachable** once this repo's `docker/volumes/api/kong.yml` is mounted — the Kong `platform-pg-meta` route (`strip_path: false`) wins.
+
+## Authentication
+
+Most routes require an `Authorization: Bearer ` header. The function verifies the JWT via `supabase.auth.getUser()` and extracts the user's GoTrue ID for database lookups.
+
+**Exception:** `/signup` and `/reset-password` are unauthenticated -- they proxy to GoTrue's public signup and recovery endpoints via the supabase-js SDK.
+
+## Database
+
+Uses a dedicated `traffic` schema with a restricted `traffic_api` Postgres role:
+
+- **Full CRUD**: `traffic.profiles`, `traffic.organizations`, `traffic.organization_members`, `traffic.projects`
+- **Create + Read + Delete**: `traffic.access_tokens`, `traffic.scoped_access_tokens`
+- **Create + Read + Update**: `traffic.notifications`
+- **Append-only**: `traffic.audit_logs` (INSERT + SELECT only, no UPDATE/DELETE)
+- **Full CRUD**: `traffic.pricing_overrides`
+- **Full CRUD**: `traffic.sso_providers`
+- **Read-only**: `traffic.roles` (seeded catalog)
+- **Full CRUD**: `traffic.organization_member_roles`
+- **Full CRUD**: `traffic.invitations`
+
+### Pricing / Discount System
+
+The `traffic.pricing_overrides` table enables per-organization and per-metric pricing customization:
+
+| Column | Description |
+| ----------------------- | ------------------------------------------------------------- |
+| `metric` | Specific metric name (NULL = global discount for all metrics) |
+| `discount_percent` | Percentage off overage price (e.g. 10.00 = 10%) |
+| `custom_free_units` | Override included quota (NULL = use plan default) |
+| `custom_per_unit_price` | Override per-unit price (NULL = use plan default) |
+
+**Override priority**: per-metric > global > default plan pricing.
+
+**Default pricing** per plan is in `pricing.config.ts` covering all 44 metrics. Three strategies: `UNIT` (overage × price), `PACKAGE` (ceil(overage/size) × package_price), `NONE` (not billed).
+
+## Known gaps / deliberate stubs
+
+Things traffic-one **does not** do the way hosted Supabase does, but intentionally. Each item links to the route / service that owns it so reviewers know what NOT to file bugs on.
+
+- **`POST /auth/{ref}/validate/spam` is a local heuristic, not a GoTrue call.** GoTrue itself exposes no `validate/spam` admin endpoint. [`routes/project-auth-admin.ts`](functions/routes/project-auth-admin.ts) scores the submitted `{ email, metadata }` pair locally (disposable-email list + structural heuristics) and returns `{ decision: 'allowed' | 'disallowed' }`. It does NOT consult the project's GoTrue, which means toggling anti-spam in the GoTrue config has no effect here and the heuristic is identical across projects. If hosted-parity ever ships an admin-surface endpoint we should switch to proxying it (M4).
+- **`LOGFLARE_PRIVATE_ACCESS_TOKEN` is platform-global even in `api` mode.** The per-project backend resolver returns a Logflare endpoint from env (`LOGFLARE_URL`) but does NOT return a per-project access token — `logflare.client.ts` signs every query with the platform-wide token read from `Deno.env`. Multi-tenant Logflare deployments would need a new secret column (`logflare_access_token_secret_id`) on `traffic.projects` and a resolver change. Tracked as a Phase 6 follow-up in [ARCHITECTURE.md § Env-var fallback](ARCHITECTURE.md#environment-variables) (M9).
+- **Edge-function mutations talk to a very specific HTTP contract.** When the project backend is NOT the shared Docker stack, [`services/edge-functions.service.ts`](functions/services/edge-functions.service.ts) calls `POST {functionsApiUrl}/_deploy`, `PATCH {functionsApiUrl}/_meta/{slug}`, `DELETE {functionsApiUrl}/_meta/{slug}`, and `GET {functionsApiUrl}/_meta[/...]`. The orchestrator that runs remote edge-function runtimes MUST expose that exact surface signed with the project `service_role` key — see [ARCHITECTURE.md § Edge function deploy HTTP contract](ARCHITECTURE.md#edge-function-deploy-http-contract-api-mode) (L2).
+
+## Testing
+
+### Required `tests/.env`
+
+`tests/.env` is committed with placeholder values; before running anything
+locally fill in the matching credentials from your deployed VM's
+`docker/.env`:
+
+| Var | Where to find it |
+| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
+| `SUPABASE_ANON_KEY` | `docker/.env` → `ANON_KEY` (must be signed with the same `JWT_SECRET` GoTrue is running with). |
+| `SUPABASE_SERVICE_ROLE_KEY` | `docker/.env` → `SERVICE_ROLE_KEY`. **Required** by the disposable-user helper (admin API calls bypass `GOTRUE_RATE_LIMIT_*`). |
+| `TRAFFIC_DB_URL` | `docker/.env` → `traffic_api` role DSN (host = `127.0.0.1` when tunnelling, port = Supavisor's 5432). |
+| `SUPERUSER_DB_URL` | `docker/.env` → `postgres` role DSN, same host/port form as above. Used to force-confirm test users + write fixture rows. |
+| `SUPABASE_PUBLIC_DB_HOST` | Externally resolvable DB host (defaults to `127.0.0.1` in `tests/.env`). Production leaves this **unset**. |
+
+The disposable-user helper (`tests/_helpers/test-user.ts`) calls
+`auth.admin.createUser({ email_confirm: true })` instead of the public
+`/signup` endpoint, so a single suite run no longer eats into the GoTrue
+hourly email-sent quota — but it does require `SUPABASE_SERVICE_ROLE_KEY`
+to be present, otherwise the helper throws on import.
+
+### Running suites
+
+```bash
+# Always use the function-local Deno config + lock for reproducible resolution.
+DENO_TEST='deno test --config functions/deno.json --lock functions/deno.lock --frozen --allow-all'
+
+# Unit tests (require DB access with traffic_api role)
+$DENO_TEST tests/services/
+
+# Billing unit tests
+$DENO_TEST tests/services/billing-service-test.ts
+
+# Integration tests (require running Supabase stack + test user)
+$DENO_TEST tests/traffic-one-test.ts
+
+# Billing integration tests
+$DENO_TEST tests/billing-test.ts
+
+# Usage integration tests
+$DENO_TEST tests/usage-test.ts
+
+# Usage unit tests
+$DENO_TEST tests/services/usage-service-test.ts
+
+# Org settings integration tests
+$DENO_TEST tests/org-settings-test.ts
+
+# Org settings unit tests
+$DENO_TEST tests/services/org-settings-service-test.ts
+
+# Projects integration tests
+$DENO_TEST tests/projects-test.ts
+
+# Projects unit tests
+$DENO_TEST tests/services/project-service-test.ts
+
+# Members integration tests
+$DENO_TEST tests/members-test.ts
+
+# Members unit tests
+$DENO_TEST tests/services/member-service-test.ts
+```
+
+## Environment Variables
+
+See [ARCHITECTURE.md § Environment Variables](ARCHITECTURE.md#environment-variables) for the full list. Short version:
+
+| Variable | Description |
+| ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `TRAFFIC_DB_URL` | Postgres connection for traffic_api role |
+| `SUPABASE_URL` | Supabase URL for JWT verification |
+| `SUPABASE_ANON_KEY` | Anon key for supabase-js client |
+| `TRAFFIC_API_PASSWORD` | Password for the traffic_api Postgres role |
+| `SUPABASE_SERVICE_ROLE_KEY` | Service role key (used by local provisioner for project creation) |
+| `PROJECT_PROVISIONER` | `local` (default) or `api` — selects project provisioning backend |
+| `PROVISIONER_API_URL` | (Required when `PROJECT_PROVISIONER=api`) External orchestration API URL |
+| `STRIPE_API_KEY` | (Optional) Stripe secret key; billing works without it in local-only mode |
+| `STRIPE_WEBHOOK_SIGNING_SECRET` | (Optional) Stripe webhook signing secret |
+| `LOGFLARE_URL` | Logflare analytics endpoint (default: `http://analytics:4000`) |
+| `LOGFLARE_PRIVATE_ACCESS_TOKEN` | Private access token for Logflare SQL queries |
+| `GOTRUE_URL` | Shared-stack fallback GoTrue admin URL (ignored when per-project backend resolves) |
+| `PG_META_URL` | Shared-stack fallback pg-meta URL (ignored when per-project backend resolves) |
+| `POSTGRES_HOST` / `POSTGRES_PORT` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Shared-stack fallbacks used by the project-backend resolver when the provisioner didn't return a DSN |
+| `SUPABASE_PUBLIC_DB_HOST` | (Optional) Externally resolvable DB host substituted into JIT `connection_string` results so external clients (psql, future cloud Studio) get a hostname they can reach. Leave unset in production to keep the in-container `db` host. |
+
+Many of the variables above act as **shared-stack-only fallbacks** — when `getProjectBackend(ref)` resolves per-project URLs + credentials from `traffic.projects` / Vault, those values win. See [ARCHITECTURE.md § Project-backend dispatch](ARCHITECTURE.md#project-backend-dispatch) for the full precedence rules.
diff --git a/traffic-one/deploy.sh b/traffic-one/deploy.sh
new file mode 100755
index 0000000000000..dc5f282be541d
--- /dev/null
+++ b/traffic-one/deploy.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+DOCKER_DIR="$REPO_ROOT/docker"
+
+echo "==> Setting up traffic-one edge function"
+
+# 1. Copy edge function files into the Docker volumes directory
+# (Symlinks don't work because the target is outside the Docker mount)
+FUNC_TARGET="$DOCKER_DIR/volumes/functions/traffic-one"
+if [ -L "$FUNC_TARGET" ]; then
+ rm "$FUNC_TARGET"
+fi
+rm -rf "$FUNC_TARGET"
+cp -r "$SCRIPT_DIR/functions" "$FUNC_TARGET"
+echo " Copied function to: $FUNC_TARGET"
+
+# 2. Check that TRAFFIC_DB_URL is in docker-compose.yml
+if grep -q "TRAFFIC_DB_URL" "$DOCKER_DIR/docker-compose.yml"; then
+ echo " TRAFFIC_DB_URL already in docker-compose.yml"
+else
+ echo " WARNING: TRAFFIC_DB_URL not found in docker-compose.yml"
+ echo " Please add it to the functions service environment section:"
+ echo ' TRAFFIC_DB_URL: postgresql://traffic_api:${TRAFFIC_API_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}'
+fi
+
+# 3. Generate TRAFFIC_API_PASSWORD if not set in .env
+ENV_FILE="$DOCKER_DIR/.env"
+if [ -f "$ENV_FILE" ] && grep -q "TRAFFIC_API_PASSWORD" "$ENV_FILE"; then
+ echo " TRAFFIC_API_PASSWORD already in .env"
+else
+ TRAFFIC_API_PASSWORD=$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)
+ echo "" >> "$ENV_FILE"
+ echo "# Traffic API restricted role password" >> "$ENV_FILE"
+ echo "TRAFFIC_API_PASSWORD=$TRAFFIC_API_PASSWORD" >> "$ENV_FILE"
+ echo " Generated TRAFFIC_API_PASSWORD in .env"
+fi
+
+# Source the .env file for variable expansion
+set -a
+source "$ENV_FILE"
+set +a
+
+# 4. Run SQL migrations
+echo "==> Running migrations"
+SUPERUSER_DB_URL="postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-127.0.0.1}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres}"
+
+TRAFFIC_API_PASS="${TRAFFIC_API_PASSWORD:-changeme}"
+
+for migration in "$SCRIPT_DIR"/migrations/*.sql; do
+ echo " Running: $(basename "$migration")"
+ psql "$SUPERUSER_DB_URL" \
+ -v traffic_api_pass="$TRAFFIC_API_PASS" \
+ -f "$migration" 2>&1 | sed 's/^/ /'
+done
+
+# 5. Restart relevant containers
+echo "==> Restarting kong and functions containers"
+cd "$DOCKER_DIR"
+docker compose restart kong functions 2>&1 | sed 's/^/ /'
+
+echo "==> Done! traffic-one edge function deployed."
+echo " Test: curl http://localhost:8000/api/platform/profile"
diff --git a/traffic-one/functions/db.ts b/traffic-one/functions/db.ts
new file mode 100644
index 0000000000000..f0a8702817ae8
--- /dev/null
+++ b/traffic-one/functions/db.ts
@@ -0,0 +1,5 @@
+import { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+const TRAFFIC_DB_URL = Deno.env.get('TRAFFIC_DB_URL') ?? Deno.env.get('SUPABASE_DB_URL')!
+
+export const pool = new Pool(TRAFFIC_DB_URL, 3, true)
diff --git a/traffic-one/functions/deno.json b/traffic-one/functions/deno.json
new file mode 100644
index 0000000000000..33efee518b3dc
--- /dev/null
+++ b/traffic-one/functions/deno.json
@@ -0,0 +1,21 @@
+{
+ "imports": {
+ "@supabase/supabase-js": "npm:@supabase/supabase-js@2",
+ "postgres": "https://deno.land/x/postgres@v0.19.3/mod.ts",
+ "@std/assert": "jsr:@std/assert@1",
+ "@std/dotenv": "jsr:@std/dotenv/load",
+ "@std/crypto": "jsr:@std/crypto"
+ },
+ "fmt": {
+ "singleQuote": true,
+ "semiColons": false,
+ "lineWidth": 100,
+ "indentWidth": 2,
+ "useTabs": false
+ },
+ "lint": {
+ "rules": {
+ "exclude": ["no-import-prefix", "no-unversioned-import"]
+ }
+ }
+}
diff --git a/traffic-one/functions/deno.lock b/traffic-one/functions/deno.lock
new file mode 100644
index 0000000000000..bba4e5d3974b1
--- /dev/null
+++ b/traffic-one/functions/deno.lock
@@ -0,0 +1,387 @@
+{
+ "version": "5",
+ "specifiers": {
+ "jsr:@std/assert@1": "1.0.19",
+ "jsr:@std/dotenv@*": "0.225.6",
+ "jsr:@std/internal@^1.0.12": "1.0.13",
+ "npm:@supabase/supabase-js@2": "2.104.0"
+ },
+ "jsr": {
+ "@std/assert@1.0.19": {
+ "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
+ "dependencies": [
+ "jsr:@std/internal"
+ ]
+ },
+ "@std/dotenv@0.225.6": {
+ "integrity": "1d6f9db72f565bd26790fa034c26e45ecb260b5245417be76c2279e5734c421b"
+ },
+ "@std/internal@1.0.13": {
+ "integrity": "2f9546691d4ac2d32859c82dff284aaeac980ddeca38430d07941e7e288725c0"
+ }
+ },
+ "npm": {
+ "@supabase/auth-js@2.104.0": {
+ "integrity": "sha512-Vs0ndL+s5an7rOmXtS/nbYnGXL8m+KXlCSrPIcw9bR96ma6qyLYILnE6syuM+rpDnf+Tg4PVNxNB2+oDwoy6mA==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
+ "@supabase/functions-js@2.104.0": {
+ "integrity": "sha512-O8EyEz/RT1kfWhyJNpVc/VbLeBsohHGBVif/CI83zoMB+Iul/t/NIekH1/7RsH6kuO+b2D4wJhfiaW8Qr47sRg==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
+ "@supabase/phoenix@0.4.0": {
+ "integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw=="
+ },
+ "@supabase/postgrest-js@2.104.0": {
+ "integrity": "sha512-ynylEq6wduQEycj6pL3P+/yIfDQ+CTnBC5I6p+PzcAO2ybj9coAITVtMfboi+g/dacgMslN5MH73rXsRMB29+Q==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
+ "@supabase/realtime-js@2.104.0": {
+ "integrity": "sha512-9fUVDoTVAhn7a79+AmEx+asUlRtf2yBrji7TQckcKn/WK4hvAA9Lia9er+lnhuz3WNiF1x6kkA4x7bRCJrU+KA==",
+ "dependencies": [
+ "@supabase/phoenix",
+ "@types/ws",
+ "tslib",
+ "ws"
+ ]
+ },
+ "@supabase/storage-js@2.104.0": {
+ "integrity": "sha512-s2NHtuAWb9nldJ/fS62WnJE6edvCWn31rrO+FJKlAohs99qdVgtLegUReTU2H9WnZiQlVqaBtu386wt6/6lrRw==",
+ "dependencies": [
+ "iceberg-js",
+ "tslib"
+ ]
+ },
+ "@supabase/supabase-js@2.104.0": {
+ "integrity": "sha512-hILwhIjCB53G31jlHUe73NDEmrXudcjcYlVRuvNfEhzf0gyFQaFf7j6rd1UGmYZkFMOg//DFE8Iy9ZbNEgosVw==",
+ "dependencies": [
+ "@supabase/auth-js",
+ "@supabase/functions-js",
+ "@supabase/postgrest-js",
+ "@supabase/realtime-js",
+ "@supabase/storage-js"
+ ]
+ },
+ "@types/node@25.6.0": {
+ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
+ "dependencies": [
+ "undici-types"
+ ]
+ },
+ "@types/ws@8.18.1": {
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dependencies": [
+ "@types/node"
+ ]
+ },
+ "iceberg-js@0.8.1": {
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="
+ },
+ "tslib@2.8.1": {
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
+ },
+ "undici-types@7.19.2": {
+ "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="
+ },
+ "ws@8.20.0": {
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="
+ }
+ },
+ "redirects": {
+ "https://esm.sh/@types/get-intrinsic@~1.2.3/index.d.ts": "https://esm.sh/@types/get-intrinsic@1.2.3/index.d.ts",
+ "https://esm.sh/@types/object-inspect@~1.13.0/index.d.ts": "https://esm.sh/@types/object-inspect@1.13.0/index.d.ts",
+ "https://esm.sh/@types/qs@~6.15.0/index.d.ts": "https://esm.sh/@types/qs@6.15.0/index.d.ts",
+ "https://esm.sh/async-function@^1.0.0?target=denonext": "https://esm.sh/async-function@1.0.0?target=denonext",
+ "https://esm.sh/async-generator-function@^1.0.0?target=denonext": "https://esm.sh/async-generator-function@1.0.0?target=denonext",
+ "https://esm.sh/call-bind-apply-helpers@^1.0.1?target=denonext": "https://esm.sh/call-bind-apply-helpers@1.0.2?target=denonext",
+ "https://esm.sh/call-bind-apply-helpers@^1.0.2/functionApply?target=denonext": "https://esm.sh/call-bind-apply-helpers@1.0.2/functionApply?target=denonext",
+ "https://esm.sh/call-bind-apply-helpers@^1.0.2/functionCall?target=denonext": "https://esm.sh/call-bind-apply-helpers@1.0.2/functionCall?target=denonext",
+ "https://esm.sh/call-bind-apply-helpers@^1.0.2?target=denonext": "https://esm.sh/call-bind-apply-helpers@1.0.2?target=denonext",
+ "https://esm.sh/call-bound@^1.0.2?target=denonext": "https://esm.sh/call-bound@1.0.4?target=denonext",
+ "https://esm.sh/dunder-proto@^1.0.1/get?target=denonext": "https://esm.sh/dunder-proto@1.0.1/get?target=denonext",
+ "https://esm.sh/es-object-atoms@^1.0.0?target=denonext": "https://esm.sh/es-object-atoms@1.1.1?target=denonext",
+ "https://esm.sh/es-object-atoms@^1.1.1?target=denonext": "https://esm.sh/es-object-atoms@1.1.1?target=denonext",
+ "https://esm.sh/generator-function@^2.0.0?target=denonext": "https://esm.sh/generator-function@2.0.1?target=denonext",
+ "https://esm.sh/get-intrinsic@^1.2.5?target=denonext": "https://esm.sh/get-intrinsic@1.3.1?target=denonext",
+ "https://esm.sh/get-intrinsic@^1.3.0?target=denonext": "https://esm.sh/get-intrinsic@1.3.1?target=denonext",
+ "https://esm.sh/get-proto@^1.0.1/Object.getPrototypeOf?target=denonext": "https://esm.sh/get-proto@1.0.1/Object.getPrototypeOf?target=denonext",
+ "https://esm.sh/get-proto@^1.0.1/Reflect.getPrototypeOf?target=denonext": "https://esm.sh/get-proto@1.0.1/Reflect.getPrototypeOf?target=denonext",
+ "https://esm.sh/get-proto@^1.0.1?target=denonext": "https://esm.sh/get-proto@1.0.1?target=denonext",
+ "https://esm.sh/math-intrinsics@^1.1.0/abs?target=denonext": "https://esm.sh/math-intrinsics@1.1.0/abs?target=denonext",
+ "https://esm.sh/math-intrinsics@^1.1.0/floor?target=denonext": "https://esm.sh/math-intrinsics@1.1.0/floor?target=denonext",
+ "https://esm.sh/math-intrinsics@^1.1.0/max?target=denonext": "https://esm.sh/math-intrinsics@1.1.0/max?target=denonext",
+ "https://esm.sh/math-intrinsics@^1.1.0/min?target=denonext": "https://esm.sh/math-intrinsics@1.1.0/min?target=denonext",
+ "https://esm.sh/math-intrinsics@^1.1.0/pow?target=denonext": "https://esm.sh/math-intrinsics@1.1.0/pow?target=denonext",
+ "https://esm.sh/math-intrinsics@^1.1.0/round?target=denonext": "https://esm.sh/math-intrinsics@1.1.0/round?target=denonext",
+ "https://esm.sh/math-intrinsics@^1.1.0/sign?target=denonext": "https://esm.sh/math-intrinsics@1.1.0/sign?target=denonext",
+ "https://esm.sh/object-inspect@^1.13.3?target=denonext": "https://esm.sh/object-inspect@1.13.4?target=denonext",
+ "https://esm.sh/object-inspect@^1.13.4?target=denonext": "https://esm.sh/object-inspect@1.13.4?target=denonext",
+ "https://esm.sh/qs@^6.11.0?target=denonext": "https://esm.sh/qs@6.15.1?target=denonext",
+ "https://esm.sh/side-channel-list@^1.0.0?target=denonext": "https://esm.sh/side-channel-list@1.0.1?target=denonext",
+ "https://esm.sh/side-channel-map@^1.0.1?target=denonext": "https://esm.sh/side-channel-map@1.0.1?target=denonext",
+ "https://esm.sh/side-channel-weakmap@^1.0.2?target=denonext": "https://esm.sh/side-channel-weakmap@1.0.2?target=denonext",
+ "https://esm.sh/side-channel@^1.1.0?target=denonext": "https://esm.sh/side-channel@1.1.0?target=denonext",
+ "https://esm.sh/stripe@14?target=denonext": "https://esm.sh/stripe@14.25.0?target=denonext"
+ },
+ "remote": {
+ "https://deno.land/std@0.160.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74",
+ "https://deno.land/std@0.160.0/_util/os.ts": "8a33345f74990e627b9dfe2de9b040004b08ea5146c7c9e8fe9a29070d193934",
+ "https://deno.land/std@0.160.0/async/abortable.ts": "87aa7230be8360c24ad437212311c9e8d4328854baec27b4c7abb26e85515c06",
+ "https://deno.land/std@0.160.0/async/deadline.ts": "48ac998d7564969f3e6ec6b6f9bf0217ebd00239b1b2292feba61272d5dd58d0",
+ "https://deno.land/std@0.160.0/async/debounce.ts": "dc8b92d4a4fe7eac32c924f2b8d3e62112530db70cadce27042689d82970b350",
+ "https://deno.land/std@0.160.0/async/deferred.ts": "d8fb253ffde2a056e4889ef7e90f3928f28be9f9294b6505773d33f136aab4e6",
+ "https://deno.land/std@0.160.0/async/delay.ts": "0419dfc993752849692d1f9647edf13407c7facc3509b099381be99ffbc9d699",
+ "https://deno.land/std@0.160.0/async/mod.ts": "dd0a8ed4f3984ffabe2fcca7c9f466b7932d57b1864ffee148a5d5388316db6b",
+ "https://deno.land/std@0.160.0/async/mux_async_iterator.ts": "3447b28a2a582224a3d4d3596bccbba6e85040da3b97ed64012f7decce98d093",
+ "https://deno.land/std@0.160.0/async/pool.ts": "ef9eb97b388543acbf0ac32647121e4dbe629236899586c4d4311a8770fbb239",
+ "https://deno.land/std@0.160.0/async/tee.ts": "9af3a3e7612af75861308b52249e167f5ebc3dcfc8a1a4d45462d96606ee2b70",
+ "https://deno.land/std@0.160.0/bytes/bytes_list.ts": "aba5e2369e77d426b10af1de0dcc4531acecec27f9b9056f4f7bfbf8ac147ab4",
+ "https://deno.land/std@0.160.0/bytes/equals.ts": "3c3558c3ae85526f84510aa2b48ab2ad7bdd899e2e0f5b7a8ffc85acb3a6043a",
+ "https://deno.land/std@0.160.0/bytes/mod.ts": "b2e342fd3669176a27a4e15061e9d588b89c1aaf5008ab71766e23669565d179",
+ "https://deno.land/std@0.160.0/crypto/_fnv/fnv32.ts": "aa9bddead8c6345087d3abd4ef35fb9655622afc333fc41fff382b36e64280b5",
+ "https://deno.land/std@0.160.0/crypto/_fnv/fnv64.ts": "625d7e7505b6cb2e9801b5fd6ed0a89256bac12b2bbb3e4664b85a88b0ec5bef",
+ "https://deno.land/std@0.160.0/crypto/_fnv/index.ts": "a8f6a361b4c6d54e5e89c16098f99b6962a1dd6ad1307dbc97fa1ecac5d7060a",
+ "https://deno.land/std@0.160.0/crypto/_fnv/util.ts": "4848313bed7f00f55be3cb080aa0583fc007812ba965b03e4009665bde614ce3",
+ "https://deno.land/std@0.160.0/crypto/_wasm_crypto/lib/deno_std_wasm_crypto.generated.mjs": "258b484c2da27578bec61c01d4b62c21f72268d928d03c968c4eb590cb3bd830",
+ "https://deno.land/std@0.160.0/crypto/_wasm_crypto/mod.ts": "6c60d332716147ded0eece0861780678d51b560f533b27db2e15c64a4ef83665",
+ "https://deno.land/std@0.160.0/crypto/keystack.ts": "e481eed28007395e554a435e880fee83a5c73b9259ed8a135a75e4b1e4f381f7",
+ "https://deno.land/std@0.160.0/crypto/mod.ts": "fadedc013b4a86fda6305f1adc6d1c02225834d53cff5d95cc05f62b25127517",
+ "https://deno.land/std@0.160.0/crypto/timing_safe_equal.ts": "82a29b737bc8932d75d7a20c404136089d5d23629e94ba14efa98a8cc066c73e",
+ "https://deno.land/std@0.160.0/datetime/formatter.ts": "7c8e6d16a0950f400aef41b9f1eb9168249869776ec520265dfda785d746589e",
+ "https://deno.land/std@0.160.0/datetime/mod.ts": "ea927ca96dfb28c7b9a5eed5bdc7ac46bb9db38038c4922631895cea342fea87",
+ "https://deno.land/std@0.160.0/datetime/tokenizer.ts": "7381e28f6ab51cb504c7e132be31773d73ef2f3e1e50a812736962b9df1e8c47",
+ "https://deno.land/std@0.160.0/encoding/base64.ts": "c57868ca7fa2fbe919f57f88a623ad34e3d970d675bdc1ff3a9d02bba7409db2",
+ "https://deno.land/std@0.160.0/encoding/base64url.ts": "a5f82a9fa703bd85a5eb8e7c1296bc6529e601ebd9642cc2b5eaa6b38fa9e05a",
+ "https://deno.land/std@0.160.0/encoding/hex.ts": "4cc5324417cbb4ac9b828453d35aed45b9cc29506fad658f1f138d981ae33795",
+ "https://deno.land/std@0.160.0/fmt/colors.ts": "9e36a716611dcd2e4865adea9c4bec916b5c60caad4cdcdc630d4974e6bb8bd4",
+ "https://deno.land/std@0.160.0/io/buffer.ts": "fae02290f52301c4e0188670e730cd902f9307fb732d79c4aa14ebdc82497289",
+ "https://deno.land/std@0.160.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3",
+ "https://deno.land/std@0.160.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09",
+ "https://deno.land/std@0.160.0/path/_util.ts": "d16be2a16e1204b65f9d0dfc54a9bc472cafe5f4a190b3c8471ec2016ccd1677",
+ "https://deno.land/std@0.160.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633",
+ "https://deno.land/std@0.160.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee",
+ "https://deno.land/std@0.160.0/path/mod.ts": "56fec03ad0ebd61b6ab39ddb9b0ddb4c4a5c9f2f4f632e09dd37ec9ebfd722ac",
+ "https://deno.land/std@0.160.0/path/posix.ts": "6b63de7097e68c8663c84ccedc0fd977656eb134432d818ecd3a4e122638ac24",
+ "https://deno.land/std@0.160.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9",
+ "https://deno.land/std@0.160.0/path/win32.ts": "ee8826dce087d31c5c81cd414714e677eb68febc40308de87a2ce4b40e10fb8d",
+ "https://deno.land/std@0.160.0/testing/_diff.ts": "a23e7fc2b4d8daa3e158fa06856bedf5334ce2a2831e8bf9e509717f455adb2c",
+ "https://deno.land/std@0.160.0/testing/_format.ts": "cd11136e1797791045e639e9f0f4640d5b4166148796cad37e6ef75f7d7f3832",
+ "https://deno.land/std@0.160.0/testing/asserts.ts": "1e340c589853e82e0807629ba31a43c84ebdcdeca910c4a9705715dfdb0f5ce8",
+ "https://deno.land/std@0.214.0/assert/assert.ts": "bec068b2fccdd434c138a555b19a2c2393b71dfaada02b7d568a01541e67cdc5",
+ "https://deno.land/std@0.214.0/assert/assertion_error.ts": "9f689a101ee586c4ce92f52fa7ddd362e86434ffdf1f848e45987dc7689976b8",
+ "https://deno.land/std@0.214.0/async/delay.ts": "8e1d18fe8b28ff95885e2bc54eccec1713f57f756053576d8228e6ca110793ad",
+ "https://deno.land/std@0.214.0/bytes/copy.ts": "f29c03168853720dfe82eaa57793d0b9e3543ebfe5306684182f0f1e3bfd422a",
+ "https://deno.land/std@0.214.0/crypto/_fnv/fnv32.ts": "ba2c5ef976b9f047d7ce2d33dfe18671afc75154bcf20ef89d932b2fe8820535",
+ "https://deno.land/std@0.214.0/crypto/_fnv/fnv64.ts": "580cadfe2ff333fe253d15df450f927c8ac7e408b704547be26aab41b5772558",
+ "https://deno.land/std@0.214.0/crypto/_fnv/mod.ts": "8dbb60f062a6e77b82f7a62ac11fabfba52c3cd408c21916b130d8f57a880f96",
+ "https://deno.land/std@0.214.0/crypto/_fnv/util.ts": "27b36ce3440d0a180af6bf1cfc2c326f68823288540a354dc1d636b781b9b75f",
+ "https://deno.land/std@0.214.0/crypto/_wasm/lib/deno_std_wasm_crypto.generated.mjs": "76c727912539737def4549bb62a96897f37eb334b979f49c57b8af7a1617635e",
+ "https://deno.land/std@0.214.0/crypto/_wasm/mod.ts": "c55f91473846827f077dfd7e5fc6e2726dee5003b6a5747610707cdc638a22ba",
+ "https://deno.land/std@0.214.0/crypto/crypto.ts": "4448f8461c797adba8d70a2c60f7795a546d7a0926e96366391bffdd06491c16",
+ "https://deno.land/std@0.214.0/datetime/_common.ts": "a62214c1924766e008e27d3d843ceba4b545dc2aa9880de0ecdef9966d5736b6",
+ "https://deno.land/std@0.214.0/datetime/parse.ts": "bb248bbcb3cd54bcaf504a1ee670fc4695e429d9019c06af954bbe2bcb8f1d02",
+ "https://deno.land/std@0.214.0/encoding/_util.ts": "beacef316c1255da9bc8e95afb1fa56ed69baef919c88dc06ae6cb7a6103d376",
+ "https://deno.land/std@0.214.0/encoding/base64.ts": "96e61a556d933201266fea84ae500453293f2aff130057b579baafda096a96bc",
+ "https://deno.land/std@0.214.0/encoding/hex.ts": "4d47d3b25103cf81a2ed38f54b394d39a77b63338e1eaa04b70c614cb45ec2e6",
+ "https://deno.land/std@0.214.0/fmt/colors.ts": "aeaee795471b56fc62a3cb2e174ed33e91551b535f44677f6320336aabb54fbb",
+ "https://deno.land/std@0.214.0/io/buf_reader.ts": "c73aad99491ee6db3d6b001fa4a780e9245c67b9296f5bad9c0fa7384e35d47a",
+ "https://deno.land/std@0.214.0/io/buf_writer.ts": "f82f640c8b3a820f600a8da429ad0537037c7d6a78426bbca2396fb1f75d3ef4",
+ "https://deno.land/std@0.214.0/io/types.ts": "748bbb3ac96abda03594ef5a0db15ce5450dcc6c0d841c8906f8b10ac8d32c96",
+ "https://deno.land/std@0.214.0/path/_common/assert_path.ts": "2ca275f36ac1788b2acb60fb2b79cb06027198bc2ba6fb7e163efaedde98c297",
+ "https://deno.land/std@0.214.0/path/_common/basename.ts": "569744855bc8445f3a56087fd2aed56bdad39da971a8d92b138c9913aecc5fa2",
+ "https://deno.land/std@0.214.0/path/_common/common.ts": "6157c7ec1f4db2b4a9a187efd6ce76dcaf1e61cfd49f87e40d4ea102818df031",
+ "https://deno.land/std@0.214.0/path/_common/constants.ts": "dc5f8057159f4b48cd304eb3027e42f1148cf4df1fb4240774d3492b5d12ac0c",
+ "https://deno.land/std@0.214.0/path/_common/dirname.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8",
+ "https://deno.land/std@0.214.0/path/_common/format.ts": "92500e91ea5de21c97f5fe91e178bae62af524b72d5fcd246d6d60ae4bcada8b",
+ "https://deno.land/std@0.214.0/path/_common/from_file_url.ts": "d672bdeebc11bf80e99bf266f886c70963107bdd31134c4e249eef51133ceccf",
+ "https://deno.land/std@0.214.0/path/_common/glob_to_reg_exp.ts": "2007aa87bed6eb2c8ae8381adcc3125027543d9ec347713c1ad2c68427330770",
+ "https://deno.land/std@0.214.0/path/_common/normalize.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8",
+ "https://deno.land/std@0.214.0/path/_common/normalize_string.ts": "dfdf657a1b1a7db7999f7c575ee7e6b0551d9c20f19486c6c3f5ff428384c965",
+ "https://deno.land/std@0.214.0/path/_common/relative.ts": "faa2753d9b32320ed4ada0733261e3357c186e5705678d9dd08b97527deae607",
+ "https://deno.land/std@0.214.0/path/_common/strip_trailing_separators.ts": "7024a93447efcdcfeaa9339a98fa63ef9d53de363f1fbe9858970f1bba02655a",
+ "https://deno.land/std@0.214.0/path/_common/to_file_url.ts": "7f76adbc83ece1bba173e6e98a27c647712cab773d3f8cbe0398b74afc817883",
+ "https://deno.land/std@0.214.0/path/_interface.ts": "a1419fcf45c0ceb8acdccc94394e3e94f99e18cfd32d509aab514c8841799600",
+ "https://deno.land/std@0.214.0/path/_os.ts": "8fb9b90fb6b753bd8c77cfd8a33c2ff6c5f5bc185f50de8ca4ac6a05710b2c15",
+ "https://deno.land/std@0.214.0/path/basename.ts": "5d341aadb7ada266e2280561692c165771d071c98746fcb66da928870cd47668",
+ "https://deno.land/std@0.214.0/path/common.ts": "03e52e22882402c986fe97ca3b5bb4263c2aa811c515ce84584b23bac4cc2643",
+ "https://deno.land/std@0.214.0/path/constants.ts": "0c206169ca104938ede9da48ac952de288f23343304a1c3cb6ec7625e7325f36",
+ "https://deno.land/std@0.214.0/path/dirname.ts": "85bd955bf31d62c9aafdd7ff561c4b5fb587d11a9a5a45e2b01aedffa4238a7c",
+ "https://deno.land/std@0.214.0/path/extname.ts": "593303db8ae8c865cbd9ceec6e55d4b9ac5410c1e276bfd3131916591b954441",
+ "https://deno.land/std@0.214.0/path/format.ts": "98fad25f1af7b96a48efb5b67378fcc8ed77be895df8b9c733b86411632162af",
+ "https://deno.land/std@0.214.0/path/from_file_url.ts": "911833ae4fd10a1c84f6271f36151ab785955849117dc48c6e43b929504ee069",
+ "https://deno.land/std@0.214.0/path/glob_to_regexp.ts": "83c5fd36a8c86f5e72df9d0f45317f9546afa2ce39acaafe079d43a865aced08",
+ "https://deno.land/std@0.214.0/path/is_absolute.ts": "4791afc8bfd0c87f0526eaa616b0d16e7b3ab6a65b62942e50eac68de4ef67d7",
+ "https://deno.land/std@0.214.0/path/is_glob.ts": "a65f6195d3058c3050ab905705891b412ff942a292bcbaa1a807a74439a14141",
+ "https://deno.land/std@0.214.0/path/join.ts": "ae2ec5ca44c7e84a235fd532e4a0116bfb1f2368b394db1c4fb75e3c0f26a33a",
+ "https://deno.land/std@0.214.0/path/join_globs.ts": "e9589869a33dc3982101898ee50903db918ca00ad2614dbe3934d597d7b1fbea",
+ "https://deno.land/std@0.214.0/path/mod.ts": "ffeaccb713dbe6c72e015b7c767f753f8ec5fbc3b621ff5eeee486ffc2c0ddda",
+ "https://deno.land/std@0.214.0/path/normalize.ts": "4155743ccceeed319b350c1e62e931600272fad8ad00c417b91df093867a8352",
+ "https://deno.land/std@0.214.0/path/normalize_glob.ts": "98ee8268fad271193603271c203ae973280b5abfbdd2cbca1053fd2af71869ca",
+ "https://deno.land/std@0.214.0/path/parse.ts": "65e8e285f1a63b714e19ef24b68f56e76934c3df0b6e65fd440d3991f4f8aefb",
+ "https://deno.land/std@0.214.0/path/posix/_util.ts": "1e3937da30f080bfc99fe45d7ed23c47dd8585c5e473b2d771380d3a6937cf9d",
+ "https://deno.land/std@0.214.0/path/posix/basename.ts": "39ee27a29f1f35935d3603ccf01d53f3d6e0c5d4d0f84421e65bd1afeff42843",
+ "https://deno.land/std@0.214.0/path/posix/common.ts": "26f60ccc8b2cac3e1613000c23ac5a7d392715d479e5be413473a37903a2b5d4",
+ "https://deno.land/std@0.214.0/path/posix/constants.ts": "93481efb98cdffa4c719c22a0182b994e5a6aed3047e1962f6c2c75b7592bef1",
+ "https://deno.land/std@0.214.0/path/posix/dirname.ts": "6535d2bdd566118963537b9dda8867ba9e2a361015540dc91f5afbb65c0cce8b",
+ "https://deno.land/std@0.214.0/path/posix/extname.ts": "8d36ae0082063c5e1191639699e6f77d3acf501600a3d87b74943f0ae5327427",
+ "https://deno.land/std@0.214.0/path/posix/format.ts": "185e9ee2091a42dd39e2a3b8e4925370ee8407572cee1ae52838aed96310c5c1",
+ "https://deno.land/std@0.214.0/path/posix/from_file_url.ts": "951aee3a2c46fd0ed488899d024c6352b59154c70552e90885ed0c2ab699bc40",
+ "https://deno.land/std@0.214.0/path/posix/glob_to_regexp.ts": "54d3ff40f309e3732ab6e5b19d7111d2d415248bcd35b67a99defcbc1972e697",
+ "https://deno.land/std@0.214.0/path/posix/is_absolute.ts": "cebe561ad0ae294f0ce0365a1879dcfca8abd872821519b4fcc8d8967f888ede",
+ "https://deno.land/std@0.214.0/path/posix/is_glob.ts": "8a8b08c08bf731acf2c1232218f1f45a11131bc01de81e5f803450a5914434b9",
+ "https://deno.land/std@0.214.0/path/posix/join.ts": "aef88d5fa3650f7516730865dbb951594d1a955b785e2450dbee93b8e32694f3",
+ "https://deno.land/std@0.214.0/path/posix/join_globs.ts": "ee2f4676c5b8a0dfa519da58b8ade4d1c4aa8dd3fe35619edec883ae9df1f8c9",
+ "https://deno.land/std@0.214.0/path/posix/mod.ts": "563a18c2b3ddc62f3e4a324ff0f583e819b8602a72ad880cb98c9e2e34f8db5b",
+ "https://deno.land/std@0.214.0/path/posix/normalize.ts": "baeb49816a8299f90a0237d214cef46f00ba3e95c0d2ceb74205a6a584b58a91",
+ "https://deno.land/std@0.214.0/path/posix/normalize_glob.ts": "65f0138fa518ef9ece354f32889783fc38cdf985fb02dcf1c3b14fa47d665640",
+ "https://deno.land/std@0.214.0/path/posix/parse.ts": "d5bac4eb21262ab168eead7e2196cb862940c84cee572eafedd12a0d34adc8fb",
+ "https://deno.land/std@0.214.0/path/posix/relative.ts": "3907d6eda41f0ff723d336125a1ad4349112cd4d48f693859980314d5b9da31c",
+ "https://deno.land/std@0.214.0/path/posix/resolve.ts": "bac20d9921beebbbb2b73706683b518b1d0c1b1da514140cee409e90d6b2913a",
+ "https://deno.land/std@0.214.0/path/posix/separator.ts": "c9ecae5c843170118156ac5d12dc53e9caf6a1a4c96fc8b1a0ab02dff5c847b0",
+ "https://deno.land/std@0.214.0/path/posix/to_file_url.ts": "7aa752ba66a35049e0e4a4be5a0a31ac6b645257d2e031142abb1854de250aaf",
+ "https://deno.land/std@0.214.0/path/posix/to_namespaced_path.ts": "28b216b3c76f892a4dca9734ff1cc0045d135532bfd9c435ae4858bfa5a2ebf0",
+ "https://deno.land/std@0.214.0/path/relative.ts": "ab739d727180ed8727e34ed71d976912461d98e2b76de3d3de834c1066667add",
+ "https://deno.land/std@0.214.0/path/resolve.ts": "a6f977bdb4272e79d8d0ed4333e3d71367cc3926acf15ac271f1d059c8494d8d",
+ "https://deno.land/std@0.214.0/path/separator.ts": "c6c890507f944a1f5cb7d53b8d638d6ce3cf0f34609c8d84a10c1eaa400b77a9",
+ "https://deno.land/std@0.214.0/path/to_file_url.ts": "88f049b769bce411e2d2db5bd9e6fd9a185a5fbd6b9f5ad8f52bef517c4ece1b",
+ "https://deno.land/std@0.214.0/path/to_namespaced_path.ts": "b706a4103b104cfadc09600a5f838c2ba94dbcdb642344557122dda444526e40",
+ "https://deno.land/std@0.214.0/path/windows/_util.ts": "d5f47363e5293fced22c984550d5e70e98e266cc3f31769e1710511803d04808",
+ "https://deno.land/std@0.214.0/path/windows/basename.ts": "e2dbf31d1d6385bfab1ce38c333aa290b6d7ae9e0ecb8234a654e583cf22f8fe",
+ "https://deno.land/std@0.214.0/path/windows/common.ts": "26f60ccc8b2cac3e1613000c23ac5a7d392715d479e5be413473a37903a2b5d4",
+ "https://deno.land/std@0.214.0/path/windows/constants.ts": "5afaac0a1f67b68b0a380a4ef391bf59feb55856aa8c60dfc01bd3b6abb813f5",
+ "https://deno.land/std@0.214.0/path/windows/dirname.ts": "33e421be5a5558a1346a48e74c330b8e560be7424ed7684ea03c12c21b627bc9",
+ "https://deno.land/std@0.214.0/path/windows/extname.ts": "165a61b00d781257fda1e9606a48c78b06815385e7d703232548dbfc95346bef",
+ "https://deno.land/std@0.214.0/path/windows/format.ts": "bbb5ecf379305b472b1082cd2fdc010e44a0020030414974d6029be9ad52aeb6",
+ "https://deno.land/std@0.214.0/path/windows/from_file_url.ts": "ced2d587b6dff18f963f269d745c4a599cf82b0c4007356bd957cb4cb52efc01",
+ "https://deno.land/std@0.214.0/path/windows/glob_to_regexp.ts": "6dcd1242bd8907aa9660cbdd7c93446e6927b201112b0cba37ca5d80f81be51b",
+ "https://deno.land/std@0.214.0/path/windows/is_absolute.ts": "4a8f6853f8598cf91a835f41abed42112cebab09478b072e4beb00ec81f8ca8a",
+ "https://deno.land/std@0.214.0/path/windows/is_glob.ts": "8a8b08c08bf731acf2c1232218f1f45a11131bc01de81e5f803450a5914434b9",
+ "https://deno.land/std@0.214.0/path/windows/join.ts": "e0b3356615c1a75c56ebb6a7311157911659e11fd533d80d724800126b761ac3",
+ "https://deno.land/std@0.214.0/path/windows/join_globs.ts": "ee2f4676c5b8a0dfa519da58b8ade4d1c4aa8dd3fe35619edec883ae9df1f8c9",
+ "https://deno.land/std@0.214.0/path/windows/mod.ts": "7d6062927bda47c47847ffb55d8f1a37b0383840aee5c7dfc93984005819689c",
+ "https://deno.land/std@0.214.0/path/windows/normalize.ts": "78126170ab917f0ca355a9af9e65ad6bfa5be14d574c5fb09bb1920f52577780",
+ "https://deno.land/std@0.214.0/path/windows/normalize_glob.ts": "179c86ba89f4d3fe283d2addbe0607341f79ee9b1ae663abcfb3439db2e97810",
+ "https://deno.land/std@0.214.0/path/windows/parse.ts": "b9239edd892a06a06625c1b58425e199f018ce5649ace024d144495c984da734",
+ "https://deno.land/std@0.214.0/path/windows/relative.ts": "3e1abc7977ee6cc0db2730d1f9cb38be87b0ce4806759d271a70e4997fc638d7",
+ "https://deno.land/std@0.214.0/path/windows/resolve.ts": "75b2e3e1238d840782cee3d8864d82bfaa593c7af8b22f19c6422cf82f330ab3",
+ "https://deno.land/std@0.214.0/path/windows/separator.ts": "e51c5522140eff4f8402617c5c68a201fdfa3a1a8b28dc23587cff931b665e43",
+ "https://deno.land/std@0.214.0/path/windows/to_file_url.ts": "1cd63fd35ec8d1370feaa4752eccc4cc05ea5362a878be8dc7db733650995484",
+ "https://deno.land/std@0.214.0/path/windows/to_namespaced_path.ts": "4ffa4fb6fae321448d5fe810b3ca741d84df4d7897e61ee29be961a6aac89a4c",
+ "https://deno.land/x/postgres@v0.17.0/client.ts": "348779c9f6a1c75ef1336db662faf08dce7d2101ff72f0d1e341ba1505c8431d",
+ "https://deno.land/x/postgres@v0.17.0/client/error.ts": "0817583b666fd546664ed52c1d37beccc5a9eebcc6e3c2ead20ada99b681e5f7",
+ "https://deno.land/x/postgres@v0.17.0/connection/auth.ts": "1070125e2ac4ca4ade36d69a4222d37001903092826d313217987583edd61ce9",
+ "https://deno.land/x/postgres@v0.17.0/connection/connection.ts": "428ed3efa055870db505092b5d3545ef743497b7b4b72cf8f0593e7dd4788acd",
+ "https://deno.land/x/postgres@v0.17.0/connection/connection_params.ts": "52bfe90e8860f584b95b1b08c254dde97c3aa763c4b6bee0c80c5930e35459e0",
+ "https://deno.land/x/postgres@v0.17.0/connection/message.ts": "f9257948b7f87d58bfbfe3fc6e2e08f0de3ef885655904d56a5f73655cc22c5a",
+ "https://deno.land/x/postgres@v0.17.0/connection/message_code.ts": "466719008b298770c366c5c63f6cf8285b7f76514dadb4b11e7d9756a8a1ddbf",
+ "https://deno.land/x/postgres@v0.17.0/connection/packet.ts": "050aeff1fc13c9349e89451a155ffcd0b1343dc313a51f84439e3e45f64b56c8",
+ "https://deno.land/x/postgres@v0.17.0/connection/scram.ts": "0c7a2551fe7b1a1c62dd856b7714731a7e7534ccca10093336782d1bfc5b2bd2",
+ "https://deno.land/x/postgres@v0.17.0/deps.ts": "f47ccb41f7f97eaad455d94f407ef97146ae99443dbe782894422c869fbba69e",
+ "https://deno.land/x/postgres@v0.17.0/mod.ts": "a1e18fd9e6fedc8bc24e5aeec3ae6de45e2274be1411fb66e9081420c5e81d7d",
+ "https://deno.land/x/postgres@v0.17.0/pool.ts": "892db7b5e1787988babecc994a151ebbd7d017f080905cbe9c3d7b44a73032a9",
+ "https://deno.land/x/postgres@v0.17.0/query/array_parser.ts": "f8a229d82c3801de8266fa2cc4afe12e94fef8d0c479e73655c86ed3667ef33f",
+ "https://deno.land/x/postgres@v0.17.0/query/decode.ts": "44a4a6cbcf494ed91a4fecae38a57dce63a7b519166f02c702791d9717371419",
+ "https://deno.land/x/postgres@v0.17.0/query/decoders.ts": "16cb0e60227d86692931e315421b15768c78526e3aeb84e25fcc4111096de9fd",
+ "https://deno.land/x/postgres@v0.17.0/query/encode.ts": "5f1418a2932b7c2231556e4a5f5f56efef48728014070cfafe7656963f342933",
+ "https://deno.land/x/postgres@v0.17.0/query/oid.ts": "8c33e1325f34e4ca9f11a48b8066c8cfcace5f64bc1eb17ad7247af4936999e1",
+ "https://deno.land/x/postgres@v0.17.0/query/query.ts": "edb473cbcfeff2ee1c631272afb25d079d06b66b5853f42492725b03ffa742b6",
+ "https://deno.land/x/postgres@v0.17.0/query/transaction.ts": "8e75c3ce0aca97da7fe126e68f8e6c08d640e5c8d2016e62cee5c254bebe7fe8",
+ "https://deno.land/x/postgres@v0.17.0/query/types.ts": "a6dc8024867fe7ccb0ba4b4fa403ee5d474c7742174128c8e689c3b5e5eaa933",
+ "https://deno.land/x/postgres@v0.17.0/utils/deferred.ts": "dd94f2a57355355c47812b061a51b55263f72d24e9cb3fdb474c7519f4d61083",
+ "https://deno.land/x/postgres@v0.17.0/utils/utils.ts": "19c3527ddd5c6c4c49ae36397120274c7f41f9d3cbf479cb36065d23329e9f90",
+ "https://deno.land/x/postgres@v0.19.3/client.ts": "d141c65c20484c545a1119c9af7a52dcc24f75c1a5633de2b9617b0f4b2ed5c1",
+ "https://deno.land/x/postgres@v0.19.3/client/error.ts": "05b0e35d65caf0ba21f7f6fab28c0811da83cd8b4897995a2f411c2c83391036",
+ "https://deno.land/x/postgres@v0.19.3/connection/auth.ts": "db15c1659742ef4d2791b32834950278dc7a40cb931f8e434e6569298e58df51",
+ "https://deno.land/x/postgres@v0.19.3/connection/connection.ts": "198a0ecf92a0d2aa72db3bb88b8f412d3b1f6b87d464d5f7bff9aa3b6aff8370",
+ "https://deno.land/x/postgres@v0.19.3/connection/connection_params.ts": "463d7a9ed559f537a55d6928cab62e1c31b808d08cd0411b6ae461d0c0183c93",
+ "https://deno.land/x/postgres@v0.19.3/connection/message.ts": "20da5d80fc4d7ddb7b850083e0b3fa8734eb26642221dad89c62e27d78e57a4d",
+ "https://deno.land/x/postgres@v0.19.3/connection/message_code.ts": "12bcb110df6945152f9f6c63128786558d7ad1e61006920daaa16ef85b3bab7d",
+ "https://deno.land/x/postgres@v0.19.3/connection/packet.ts": "050aeff1fc13c9349e89451a155ffcd0b1343dc313a51f84439e3e45f64b56c8",
+ "https://deno.land/x/postgres@v0.19.3/connection/scram.ts": "532d4d58b565a2ab48fb5e1e14dc9bfb3bb283d535011e371e698eb4a89dd994",
+ "https://deno.land/x/postgres@v0.19.3/debug.ts": "8add17699191f11e6830b8c95d9de25857d221bb2cf6c4ae22254d395895c1f9",
+ "https://deno.land/x/postgres@v0.19.3/deps.ts": "c312038fe64b8368f8a294119f11d8f235fe67de84d7c3b0ef67b3a56628171a",
+ "https://deno.land/x/postgres@v0.19.3/mod.ts": "4930c7b44f8d16ea71026f7e3ef22a2322d84655edceacd55f7461a9218d8560",
+ "https://deno.land/x/postgres@v0.19.3/pool.ts": "2289f029e7a3bd3d460d4faa71399a920b7406c92a97c0715d6e31dbf1380ec3",
+ "https://deno.land/x/postgres@v0.19.3/query/array_parser.ts": "ff72d3e026e3022a1a223a6530be5663f8ebbd911ed978291314e7fe6c2f2464",
+ "https://deno.land/x/postgres@v0.19.3/query/decode.ts": "3e89ad2a662eab66a4f4e195ff0924d71d199af3c2f5637d1ae650301a03fa9b",
+ "https://deno.land/x/postgres@v0.19.3/query/decoders.ts": "6a73da1024086ab91e233648c850dccbde59248b90d87054bbbd7f0bf4a50681",
+ "https://deno.land/x/postgres@v0.19.3/query/encode.ts": "5b1c305bc7352a6f9fe37f235dddfc23e26419c77a133b4eaea42cf136481aa6",
+ "https://deno.land/x/postgres@v0.19.3/query/oid.ts": "21fc714ac212350ba7df496f88ea9e01a4ee0458911d0f2b6a81498e12e7af4c",
+ "https://deno.land/x/postgres@v0.19.3/query/query.ts": "510f9a27da87ed7b31b5cbcd14bf3028b441ac2ddc368483679d0b86a9d9f213",
+ "https://deno.land/x/postgres@v0.19.3/query/transaction.ts": "8f4eef68f8e9b4be216199404315e6e08fe1fe98afb2e640bffd077662f79678",
+ "https://deno.land/x/postgres@v0.19.3/query/types.ts": "540f6f973d493d63f2c0059a09f3368071f57931bba68bea408a635a3e0565d6",
+ "https://deno.land/x/postgres@v0.19.3/utils/deferred.ts": "5420531adb6c3ea29ca8aac57b9b59bd3e4b9a938a4996bbd0947a858f611080",
+ "https://deno.land/x/postgres@v0.19.3/utils/utils.ts": "ca47193ea03ff5b585e487a06f106d367e509263a960b787197ce0c03113a738",
+ "https://esm.sh/async-function@1.0.0/denonext/async-function.mjs": "9a68a79494e7318827f19367915e5e8c902d81d395a2a429114371d8f506afd8",
+ "https://esm.sh/async-function@1.0.0?target=denonext": "c5e89ac5310741350e80c75eb1ff0c24bdaa16db5bf0f9181fceea7e3bca763e",
+ "https://esm.sh/async-generator-function@1.0.0/denonext/async-generator-function.mjs": "c7d6e6484a0083577a002c3114e08d84762b6f74327c89cd0129226cefd7436a",
+ "https://esm.sh/async-generator-function@1.0.0?target=denonext": "7b92d0f159fcd05e4cb37faf06fe4d83333517287a9fd3fa1c3dfa34bfa99891",
+ "https://esm.sh/call-bind-apply-helpers@1.0.2/denonext/actualApply.mjs": "e40dd22950f5eb996a325283de44db908753de3396f81ca4b4b186809ec7404b",
+ "https://esm.sh/call-bind-apply-helpers@1.0.2/denonext/call-bind-apply-helpers.mjs": "1c096a11476850297224ad825a8e505c23fcc555a8474e929897f8d799fef30b",
+ "https://esm.sh/call-bind-apply-helpers@1.0.2/denonext/functionApply.mjs": "20d90adbc9be9d9b51fe4fe1019f8bd1d0823f27a2557eed275b9e44c07260c5",
+ "https://esm.sh/call-bind-apply-helpers@1.0.2/denonext/functionCall.mjs": "b36700f863bccd6667f66bfdc7cd9a252129cb203bf5eef59bf29046b9da1467",
+ "https://esm.sh/call-bind-apply-helpers@1.0.2/denonext/reflectApply.mjs": "ad4d25d2a301d5d1701b908c50aa229ff4b5e62f05136d3828f1a26d5dc901f6",
+ "https://esm.sh/call-bind-apply-helpers@1.0.2/functionApply?target=denonext": "62c4f7ef478c97ef7b1ba0e303d61df3cb4a1df4317b606e43b655f0e4219c43",
+ "https://esm.sh/call-bind-apply-helpers@1.0.2/functionCall?target=denonext": "4366685652c948d1c2ca5264d496bb739f52ee5860950a1496e5214759135cc8",
+ "https://esm.sh/call-bind-apply-helpers@1.0.2?target=denonext": "905e972ffcd24bdbceda3bc3208a2102b1ba8ebc2e74e55e42433ad17e1e455e",
+ "https://esm.sh/call-bound@1.0.4/denonext/call-bound.mjs": "08fb5feeb1c0e871cfd19912759ea62b7023bac1d443ffb498f3968082bb3711",
+ "https://esm.sh/call-bound@1.0.4?target=denonext": "8861d775f1c2f685b8985662bfc0eb9037cef7c41c7ee39ae49306662933cc67",
+ "https://esm.sh/dunder-proto@1.0.1/denonext/get.mjs": "8249c9d4dfb0c1f5ee60df6588c77153a4da927b2759e7059b4124c69a8e9223",
+ "https://esm.sh/dunder-proto@1.0.1/get?target=denonext": "13d001daa54e39c69fe8034e0f54ecf326c1b44fcdf005b47a16087c535ee15e",
+ "https://esm.sh/es-object-atoms@1.1.1/denonext/es-object-atoms.mjs": "002f305a1112ee598445ab88204560f9e3e1595d4086d4b044d845364df196d1",
+ "https://esm.sh/es-object-atoms@1.1.1?target=denonext": "42f0f1f77d6dc7e20b9510cd914b97e8f20c57c218bccd433292a9d86a7f2123",
+ "https://esm.sh/generator-function@2.0.1/denonext/generator-function.mjs": "c0830dd65531f61823dd69e7d9f8fedace61f5810aa3ef4a47c876638ccce5a2",
+ "https://esm.sh/generator-function@2.0.1?target=denonext": "d69b75b5af8feab1b72504ae1c088a112345997bc2fb03e78bc4b5cc2c717b30",
+ "https://esm.sh/get-intrinsic@1.3.1/denonext/get-intrinsic.mjs": "af07ca16f714e1faaca241d5f136cae8e6d8300dc1cfcee3d2b670008fcfd579",
+ "https://esm.sh/get-intrinsic@1.3.1?target=denonext": "e293e28f884cf6eb97da776907dd175a9b20a072d4b90898b590d7a156449f72",
+ "https://esm.sh/get-proto@1.0.1/Object.getPrototypeOf?target=denonext": "07ea2fdda9026eb3b7e18eff95a1314a82db3b37efd0e4a1b7bd91c454bfd492",
+ "https://esm.sh/get-proto@1.0.1/Reflect.getPrototypeOf?target=denonext": "08346568b8d1b2532dfff0affbb99b0400960313cb1b350969f9ca1f889ac700",
+ "https://esm.sh/get-proto@1.0.1/denonext/Object.getPrototypeOf.mjs": "d62989d14e99b23a7604030f5b2c176b55067bd790d9056fd7b8a7f324c13c62",
+ "https://esm.sh/get-proto@1.0.1/denonext/Reflect.getPrototypeOf.mjs": "4b884fb35dbdc6b2a67708f195cf46435514a7eb3930578453176aafe59d49fe",
+ "https://esm.sh/get-proto@1.0.1/denonext/get-proto.mjs": "0e4ddb145c883b3f941aeba555feb48b9f177838d070449782265daf59b77377",
+ "https://esm.sh/get-proto@1.0.1?target=denonext": "fa3e52250f16f485da729565f1f41dcbb23edeb64420db0146e374cc835c9b04",
+ "https://esm.sh/math-intrinsics@1.1.0/abs?target=denonext": "b43b9b3996b29cda49a1ad6d71b876095144b3252c761b4338e8870e5073542e",
+ "https://esm.sh/math-intrinsics@1.1.0/denonext/abs.mjs": "08304368394a36ee89a52def8a533da1f7c602891647a3e10543a8bbdb746c8b",
+ "https://esm.sh/math-intrinsics@1.1.0/denonext/floor.mjs": "c5e41bb95fa47641ca012faa0a093eef6401d3ace4479a85e39cf726eb184785",
+ "https://esm.sh/math-intrinsics@1.1.0/denonext/isNaN.mjs": "4c0aa9576873f1a60fc724bf6a7959ae3eb30e6b002aa3a94a00f6d071ae4fb2",
+ "https://esm.sh/math-intrinsics@1.1.0/denonext/max.mjs": "d7b63113695c5fef18e6c505fb0db439cefefe5d6578283207bbed54287c53e9",
+ "https://esm.sh/math-intrinsics@1.1.0/denonext/min.mjs": "445c0cbc6acecab1076657ce2b3ce8783b6bd7ec638b76b128dae98a92a9876a",
+ "https://esm.sh/math-intrinsics@1.1.0/denonext/pow.mjs": "b15d61336938ae7d84cd9e223509cb576cc2b89a34ec678889c6cdc82bfdd45c",
+ "https://esm.sh/math-intrinsics@1.1.0/denonext/round.mjs": "a96681000e62bc8c0ff3582a77981fc88fa3034ed5bb85b3e1a15047eeb954b6",
+ "https://esm.sh/math-intrinsics@1.1.0/denonext/sign.mjs": "323a0314efc3a9892beebf5cdd3b6a1d71986821b58548b3a593f8103e4c49b0",
+ "https://esm.sh/math-intrinsics@1.1.0/floor?target=denonext": "58bd34b24e7c69b79e09243ed99bf0aa35e0423524c5d6f3986d46f72b19cdab",
+ "https://esm.sh/math-intrinsics@1.1.0/max?target=denonext": "67e6a93d9f2dd0eb70967013abd67be262b7651e05c4384c9899621ed29db5bb",
+ "https://esm.sh/math-intrinsics@1.1.0/min?target=denonext": "869cb45f08e5642671cb4e0078b73fae5e767656e7ab86a208ca721d67b42fb1",
+ "https://esm.sh/math-intrinsics@1.1.0/pow?target=denonext": "4f42df7a6c0593efdb1edb840affe3a464884ac3287fa18b03810021ee55a5fb",
+ "https://esm.sh/math-intrinsics@1.1.0/round?target=denonext": "635d454d1f3fe901ab84dc7508ca8ba90825085b051278f083986ab8d763e675",
+ "https://esm.sh/math-intrinsics@1.1.0/sign?target=denonext": "67eede9463cdb90393f9e449e8d6d59283db42ff1793fc381292a5612e535cfe",
+ "https://esm.sh/object-inspect@1.13.4/denonext/object-inspect.mjs": "45c312125d1f5469db2840085ce40fa3fbaab81bebcb4b2f79153f9eeaa05230",
+ "https://esm.sh/object-inspect@1.13.4?target=denonext": "426a13b7cd2fb610060e1d943f1ae802ef3873c2055b80dd75b39fddcb5b91f9",
+ "https://esm.sh/qs@6.15.1/denonext/qs.mjs": "6f667d2181b01ae15dc7e9dcfd9f806bdf934382b8f8e2cacb9b8a3ee1f76d38",
+ "https://esm.sh/qs@6.15.1?target=denonext": "fa3c3e4402315574bb0a6df73e011a130995a474b2d0ebc5fc234f1a5cae9874",
+ "https://esm.sh/side-channel-list@1.0.1/denonext/side-channel-list.mjs": "c5645369dabde028779a14220cbf07fd306949004ce0b96ceca37b9c0a67477d",
+ "https://esm.sh/side-channel-list@1.0.1?target=denonext": "048acf95bc6b0fbc223931d6c49be2d929d9a6d3ebde2ab9897fae38df154cc0",
+ "https://esm.sh/side-channel-map@1.0.1/denonext/side-channel-map.mjs": "5c6c38348826aa2b41eb5654fff235ae06a06c6f0b02ad736b6f226704d7043a",
+ "https://esm.sh/side-channel-map@1.0.1?target=denonext": "8b59be4ffd58b5654971b600ca894755e9277c9be88dbfcc5673b2e85d8d30ec",
+ "https://esm.sh/side-channel-weakmap@1.0.2/denonext/side-channel-weakmap.mjs": "5bee9551eadb611a71937950a614bd9d46ca5139afbb20e28321c1704953b367",
+ "https://esm.sh/side-channel-weakmap@1.0.2?target=denonext": "f6ca783896c64a8ca09f483a7809e053e4e31b1569b5c5251ed5813561330dfe",
+ "https://esm.sh/side-channel@1.1.0/denonext/side-channel.mjs": "2b14f5c6f2fc136405c1bda1897e81a87993ee525b4eff74232b8e6cacf9b759",
+ "https://esm.sh/side-channel@1.1.0?target=denonext": "af0b34fab98933edb9b50119e3383d0f2df5451b179ded5e92007d6f773d12e2",
+ "https://esm.sh/stripe@14.25.0/denonext/stripe.mjs": "4001e85651cdcef056f1c6132f4fa1245fc5aa0950fae2a91b480e2269ffd4c4",
+ "https://esm.sh/stripe@14.25.0?target=denonext": "5c144d95e39f91ac9633debba114dc7260c0e312d70d7d89a8f90bf95ae65595"
+ },
+ "workspace": {
+ "dependencies": [
+ "jsr:@std/assert@1",
+ "jsr:@std/crypto@*",
+ "jsr:@std/dotenv@*",
+ "npm:@supabase/supabase-js@2"
+ ]
+ }
+}
diff --git a/traffic-one/functions/index.ts b/traffic-one/functions/index.ts
new file mode 100644
index 0000000000000..1a5f4a01a8997
--- /dev/null
+++ b/traffic-one/functions/index.ts
@@ -0,0 +1,236 @@
+import { createClient } from 'npm:@supabase/supabase-js@2'
+
+import { pool } from './db.ts'
+import { handleAccessTokens } from './routes/access-tokens.ts'
+import { handleAudit } from './routes/audit.ts'
+import { handleAuthConfig } from './routes/auth-config.ts'
+import { handleResetPassword, handleSignup } from './routes/auth.ts'
+import { handleBackups } from './routes/backups.ts'
+import { handleConfirmSubscription, handleStripe } from './routes/billing.ts'
+import { handleBranchById } from './routes/branches.ts'
+import { handleCli } from './routes/cli.ts'
+import { handleDatabaseMigrations } from './routes/database-migrations.ts'
+import { handleFeedback } from './routes/feedback.ts'
+import { handleNotifications } from './routes/notifications.ts'
+import { handleOrganizations, handleV1Organizations } from './routes/organizations.ts'
+import { handlePermissions } from './routes/permissions.ts'
+import { handleProfile } from './routes/profile.ts'
+import { handleProjectAuthAdmin } from './routes/project-auth-admin.ts'
+import { handleProjectPgMeta } from './routes/project-pg-meta.ts'
+import { handleProjectHealth, handleProjects } from './routes/projects.ts'
+import { handleReplication } from './routes/replication.ts'
+import { handleScopedAccessTokens } from './routes/scoped-access-tokens.ts'
+import { handleUpdateEmail } from './routes/update-email.ts'
+import { getOrCreateProfile } from './services/profile.service.ts'
+
+export const corsHeaders = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+}
+
+const supabaseUrl = Deno.env.get('SUPABASE_URL')!
+const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY')!
+
+const supabase = createClient(supabaseUrl, supabaseAnonKey, {
+ auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false },
+})
+
+Deno.serve(async (req: Request) => {
+ if (req.method === 'OPTIONS') {
+ return new Response('ok', { headers: corsHeaders })
+ }
+
+ const url = new URL(req.url)
+ const path = url.pathname.replace(/^\/traffic-one/, '') || '/'
+ const method = req.method
+
+ // Unauthenticated routes (public, like GoTrue itself)
+ if (path === '/signup' && method === 'POST') {
+ return handleSignup(req, supabase)
+ }
+ if (path === '/reset-password' && method === 'POST') {
+ return handleResetPassword(req, supabase)
+ }
+
+ // Telemetry endpoints are anon-friendly in Studio (signed-out users also fire PostHog events).
+ // Keep them PUBLIC and return shape-correct no-op responses; we don't forward to PostHog from here.
+ if (path.startsWith('/telemetry')) {
+ // /telemetry/feature-flags -> Studio reads this as a flag map; must stay {}.
+ // /telemetry/event | /telemetry/identify | /telemetry/reset -> { success: true }.
+ if (path.startsWith('/telemetry/feature-flags')) {
+ return Response.json({}, { headers: corsHeaders })
+ }
+ return Response.json({ success: true }, { headers: corsHeaders })
+ }
+
+ const authHeader = req.headers.get('Authorization')
+ if (!authHeader) {
+ return Response.json(
+ { msg: 'Missing authorization' },
+ {
+ status: 401,
+ headers: corsHeaders,
+ },
+ )
+ }
+
+ const token = authHeader.replace('Bearer ', '')
+
+ let gotrueId: string
+ let email: string
+
+ try {
+ const {
+ data: { user },
+ error,
+ } = await supabase.auth.getUser(token)
+ if (error || !user) {
+ return Response.json({ msg: 'Invalid JWT' }, { status: 401, headers: corsHeaders })
+ }
+ gotrueId = user.id
+ email = user.email ?? ''
+ } catch {
+ return Response.json({ msg: 'Invalid JWT' }, { status: 401, headers: corsHeaders })
+ }
+
+ try {
+ const profile = await getOrCreateProfile(pool, gotrueId, email)
+ const profileId = profile.id
+
+ if (path === '/' || path === '/update') {
+ return handleProfile(req, path, method, pool, gotrueId, email)
+ }
+
+ if (path === '/update-email' && method === 'PUT') {
+ return handleUpdateEmail(req, method, pool, gotrueId, email, profileId)
+ }
+
+ if (path.startsWith('/access-tokens')) {
+ return handleAccessTokens(req, path, method, pool, gotrueId, email, profileId)
+ }
+
+ if (path.startsWith('/scoped-access-tokens')) {
+ return handleScopedAccessTokens(req, path, method, pool, gotrueId, email, profileId)
+ }
+
+ if (path.startsWith('/notifications')) {
+ return handleNotifications(req, path, method, pool, gotrueId, email, profileId)
+ }
+
+ if (path === '/permissions') {
+ return handlePermissions(req, path, method, pool, profileId)
+ }
+
+ if (path === '/organizations/confirm-subscription' && method === 'POST') {
+ return handleConfirmSubscription(req, method)
+ }
+
+ if (path.startsWith('/organizations')) {
+ const orgPath = path.replace(/^\/organizations/, '') || '/'
+ return handleOrganizations(req, orgPath, method, pool, profileId, gotrueId, email)
+ }
+
+ if (path === '/api/platform/auth' || path.startsWith('/api/platform/auth/')) {
+ const authPath = path.replace(/^\/api\/platform\/auth/, '') || '/'
+ // Config paths stay on the env-merge + override-table flow (Wave 1);
+ // everything else (users / invite / magiclink / recover / otp /
+ // users/{id}/factors / validate/spam) dispatches to the per-project
+ // GoTrue backend via the project-backend resolver.
+ const isConfigPath = /^\/[^/]+\/config(\/hooks)?$/.test(authPath)
+ if (isConfigPath) {
+ return handleAuthConfig(req, authPath, method, pool, profileId, gotrueId, email)
+ }
+ return handleProjectAuthAdmin(req, authPath, method, pool, profileId, gotrueId, email)
+ }
+
+ // Phase 4 — /api/platform/pg-meta/{ref}/* proxies to the per-project pg-meta
+ // (backend.pgMetaUrl) signed with the project service_role key. Studio's
+ // Next stubs under apps/studio/pages/api/platform/pg-meta/[ref]/* sign
+ // with a single shared PG_META_URL / SUPABASE_SERVICE_KEY, which breaks
+ // the moment the tenant's pg-meta lives elsewhere. Kong's platform-pg-meta
+ // route uses strip_path: false so the full path lands here intact; we
+ // trim the `/api/platform/pg-meta` prefix before dispatch.
+ if (path === '/api/platform/pg-meta' || path.startsWith('/api/platform/pg-meta/')) {
+ const pgMetaPath = path.replace(/^\/api\/platform\/pg-meta/, '') || '/'
+ return handleProjectPgMeta(req, pgMetaPath, method, pool, profileId, gotrueId, email)
+ }
+
+ if (path.startsWith('/stripe')) {
+ const stripePath = path.replace(/^\/stripe/, '') || '/'
+ return handleStripe(req, stripePath, method)
+ }
+
+ if (path === '/projects-resource-warnings') {
+ return Response.json([], { headers: corsHeaders })
+ }
+
+ if (path === '/database' || path.startsWith('/database/')) {
+ const dbPath = path.replace(/^\/database/, '') || '/'
+ return handleBackups(req, dbPath, method, pool, profileId, gotrueId, email)
+ }
+
+ if (path === '/replication' || path.startsWith('/replication/')) {
+ const replPath = path.replace(/^\/replication/, '') || '/'
+ return handleReplication(req, replPath, method, pool, profileId, gotrueId, email)
+ }
+
+ if (path.startsWith('/projects')) {
+ const projectPath = path.replace(/^\/projects/, '') || '/'
+ return handleProjects(req, projectPath, method, pool, profileId, gotrueId, email)
+ }
+
+ if (path.startsWith('/v1-projects')) {
+ const v1Path = path.replace(/^\/v1-projects/, '') || '/'
+ // Intercept /{ref}/database/migrations before handleProjectHealth (Wave 1: projects.ts untouched).
+ const dbMigrationsMatch = v1Path.match(/^\/([^/]+)\/database\/migrations\/?$/)
+ if (dbMigrationsMatch) {
+ return handleDatabaseMigrations(req, v1Path, method, pool, profileId, gotrueId, email)
+ }
+ return handleProjectHealth(req, v1Path, method, pool, profileId, gotrueId, email)
+ }
+
+ if (path.startsWith('/v1-organizations')) {
+ const v1OrgPath = path.replace(/^\/v1-organizations/, '') || '/'
+ return handleV1Organizations(req, v1OrgPath, method, pool, profileId)
+ }
+
+ // Wave 3 Bundle O: /api/v1/branches/{id}/* is not project-scoped, so it arrives
+ // under its own Kong route → stripped to /v1-branches/{id}/*.
+ if (path.startsWith('/v1-branches')) {
+ const branchPath = path.replace(/^\/v1-branches/, '') || '/'
+ return handleBranchById(req, branchPath, method, pool, profileId, gotrueId, email)
+ }
+
+ if (path === '/profile/audit-log') {
+ return handleAudit(req, '/audit', method, pool, gotrueId, email, profileId)
+ }
+
+ if (path === '/audit' || path === '/audit-login') {
+ return handleAudit(req, path, method, pool, gotrueId, email, profileId)
+ }
+
+ if (path.startsWith('/feedback')) {
+ const fbPath = path.replace(/^\/feedback/, '') || '/'
+ return handleFeedback(req, fbPath, method, pool, profileId, gotrueId, email)
+ }
+
+ if (path.startsWith('/cli')) {
+ const cliPath = path.replace(/^\/cli/, '') || '/'
+ return handleCli(req, cliPath, method, pool, profileId, gotrueId, email)
+ }
+
+ return Response.json(
+ { message: 'Not Found' },
+ {
+ status: 404,
+ headers: corsHeaders,
+ },
+ )
+ } catch (err) {
+ console.error('traffic-one error:', err)
+ return Response.json(
+ { message: 'Internal Server Error' },
+ { status: 500, headers: corsHeaders },
+ )
+ }
+})
diff --git a/traffic-one/functions/routes/access-tokens.ts b/traffic-one/functions/routes/access-tokens.ts
new file mode 100644
index 0000000000000..ab8829cf84e3d
--- /dev/null
+++ b/traffic-one/functions/routes/access-tokens.ts
@@ -0,0 +1,54 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import {
+ createAccessToken,
+ deleteAccessToken,
+ listAccessTokens,
+} from '../services/access-token.service.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+
+export async function handleAccessTokens(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ gotrueId: string,
+ email: string,
+ profileId: number,
+): Promise {
+ const ip = getClientIp(req)
+ const auditContext = { email, ip, method, route: '/profile' + path }
+
+ if (method === 'GET' && path === '/access-tokens') {
+ const tokens = await listAccessTokens(pool, profileId)
+ return Response.json(tokens, { headers: corsHeaders })
+ }
+
+ if (method === 'POST' && path === '/access-tokens') {
+ const body = await req.json().catch(() => ({}))
+ if (!body.name) {
+ return Response.json({ message: 'name is required' }, { status: 400, headers: corsHeaders })
+ }
+ const token = await createAccessToken(pool, profileId, body.name, gotrueId, auditContext)
+ return Response.json(token, { status: 201, headers: corsHeaders })
+ }
+
+ const deleteMatch = path.match(/^\/access-tokens\/(\d+)$/)
+ if (method === 'DELETE' && deleteMatch) {
+ const tokenId = parseInt(deleteMatch[1], 10)
+ const deleted = await deleteAccessToken(pool, profileId, tokenId, gotrueId, auditContext)
+ if (!deleted) {
+ return Response.json({ message: 'Token not found' }, { status: 404, headers: corsHeaders })
+ }
+ return Response.json({ message: 'Token deleted' }, { headers: corsHeaders })
+ }
+
+ return Response.json(
+ { message: 'Method not allowed' },
+ {
+ status: 405,
+ headers: corsHeaders,
+ },
+ )
+}
diff --git a/traffic-one/functions/routes/audit.ts b/traffic-one/functions/routes/audit.ts
new file mode 100644
index 0000000000000..33ef54d47fe1d
--- /dev/null
+++ b/traffic-one/functions/routes/audit.ts
@@ -0,0 +1,115 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import type { AuditLog, AuditLogsResponse } from '../types/api.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+
+interface AuditLogRow {
+ id: string
+ profile_id: number
+ action_name: string
+ action_metadata: Array<{ method?: string; route?: string; status?: number }>
+ actor_id: string
+ actor_type: string
+ actor_metadata: Array<{ email?: string; ip?: string; tokenType?: string }>
+ target_description: string
+ target_metadata: Record
+ occurred_at: string
+}
+
+function rowToAuditLog(row: AuditLogRow): AuditLog {
+ return {
+ action: {
+ name: row.action_name,
+ metadata: row.action_metadata ?? [],
+ },
+ actor: {
+ id: row.actor_id,
+ type: row.actor_type,
+ metadata: row.actor_metadata ?? [],
+ },
+ target: {
+ description: row.target_description ?? '',
+ metadata: row.target_metadata ?? {},
+ },
+ occurred_at: row.occurred_at,
+ }
+}
+
+const DEFAULT_RETENTION_PERIOD = 7
+
+export async function handleAudit(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ gotrueId: string,
+ email: string,
+ profileId: number,
+): Promise {
+ if (method === 'GET' && path === '/audit') {
+ const url = new URL(req.url)
+ const startTs = url.searchParams.get('iso_timestamp_start')
+ const endTs = url.searchParams.get('iso_timestamp_end')
+
+ if (!startTs || !endTs) {
+ return Response.json(
+ { message: 'iso_timestamp_start and iso_timestamp_end are required' },
+ { status: 400, headers: corsHeaders },
+ )
+ }
+
+ const connection = await pool.connect()
+ try {
+ const result = await connection.queryObject`
+ SELECT * FROM traffic.audit_logs
+ WHERE profile_id = ${profileId}
+ AND occurred_at >= ${startTs}::timestamptz
+ AND occurred_at <= ${endTs}::timestamptz
+ ORDER BY occurred_at DESC
+ `
+ const response: AuditLogsResponse = {
+ result: result.rows.map(rowToAuditLog),
+ retention_period: DEFAULT_RETENTION_PERIOD,
+ }
+ return Response.json(response, { headers: corsHeaders })
+ } finally {
+ connection.release()
+ }
+ }
+
+ if (method === 'POST' && path === '/audit-login') {
+ const ip = getClientIp(req)
+
+ const connection = await pool.connect()
+ try {
+ await connection.queryObject`
+ INSERT INTO traffic.audit_logs (
+ id, profile_id, action_name, action_metadata,
+ actor_id, actor_type, actor_metadata,
+ target_description, target_metadata, occurred_at
+ ) VALUES (
+ gen_random_uuid(), ${profileId}, 'account.login',
+ ${JSON.stringify([{ method: 'POST', route: '/audit-login', status: 200 }])}::jsonb,
+ ${gotrueId}, 'user',
+ ${JSON.stringify([{ email, ip }])}::jsonb,
+ 'account login', '{}'::jsonb, now()
+ )
+ `
+ return Response.json(
+ { message: 'Login event recorded' },
+ { status: 201, headers: corsHeaders },
+ )
+ } finally {
+ connection.release()
+ }
+ }
+
+ return Response.json(
+ { message: 'Method not allowed' },
+ {
+ status: 405,
+ headers: corsHeaders,
+ },
+ )
+}
diff --git a/traffic-one/functions/routes/auth-config.ts b/traffic-one/functions/routes/auth-config.ts
new file mode 100644
index 0000000000000..000c830df278c
--- /dev/null
+++ b/traffic-one/functions/routes/auth-config.ts
@@ -0,0 +1,115 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import { applyConfigPatch, getMergedConfig } from '../services/gotrue-admin.service.ts'
+import {
+ getProjectBackend,
+ ProjectBackendNotProvisionedError,
+} from '../services/project-backend.service.ts'
+import { getProjectByRef } from '../services/project.service.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+import { notProvisionedResponse } from '../utils/project-backend-response.ts'
+import { assertValidRef } from '../utils/ref-validation.ts'
+
+// Handles the three endpoints Studio's /auth/* pages call:
+//
+// GET /auth/{ref}/config -> providers / URL config / hooks pages
+// PATCH /auth/{ref}/config -> save from any of those pages
+// PATCH /auth/{ref}/config/hooks -> save from /auth/hooks only
+//
+// `path` here is the project-scoped tail AFTER the /auth prefix has been
+// stripped by index.ts (e.g. `/abcd1234.../config`). `ref` is validated
+// against traffic.projects via getProjectByRef to keep cross-project
+// access scoped to the caller's org membership.
+
+export async function handleAuthConfig(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+): Promise {
+ const ip = getClientIp(req)
+
+ const configMatch = path.match(/^\/([^/]+)\/config$/)
+ const hooksMatch = path.match(/^\/([^/]+)\/config\/hooks$/)
+ const match = configMatch ?? hooksMatch
+
+ if (!match) {
+ return Response.json(
+ { message: 'Not Found' },
+ {
+ status: 404,
+ headers: corsHeaders,
+ },
+ )
+ }
+
+ const ref = match[1]
+
+ // L4: malformed ref → 400 before the DB lookup.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) {
+ return Response.json(
+ { message: 'Project not found' },
+ {
+ status: 404,
+ headers: corsHeaders,
+ },
+ )
+ }
+
+ const auditContext = { email, ip, method, route: '/auth' + path }
+
+ let backend
+ try {
+ backend = await getProjectBackend(ref, pool)
+ } catch (err) {
+ if (err instanceof ProjectBackendNotProvisionedError) {
+ return notProvisionedResponse(err)
+ }
+ throw err
+ }
+
+ if (method === 'GET' && configMatch) {
+ const merged = await getMergedConfig(pool, backend)
+ return Response.json(merged, { headers: corsHeaders })
+ }
+
+ if (method === 'PATCH' && (configMatch || hooksMatch)) {
+ // M13: `applyConfigPatch` now fetches `/admin/settings` once and
+ // composes the post-push merged view internally. We used to call
+ // `getMergedConfig` again right after, which triggered a duplicate
+ // GoTrue round-trip (push + settings-fetch + settings-fetch). When
+ // the body isn't a usable JSON object we still need a full merge for
+ // the response, so we fall through to `getMergedConfig` in that
+ // narrow branch — single fetch either way.
+ const body = await req.json().catch(() => ({}))
+ if (body && typeof body === 'object' && !Array.isArray(body)) {
+ const result = await applyConfigPatch(
+ pool,
+ backend,
+ body as Record,
+ gotrueId,
+ profileId,
+ auditContext,
+ )
+ return Response.json(result.merged, { headers: corsHeaders })
+ }
+ const merged = await getMergedConfig(pool, backend)
+ return Response.json(merged, { headers: corsHeaders })
+ }
+
+ return Response.json(
+ { message: 'Method not allowed' },
+ {
+ status: 405,
+ headers: corsHeaders,
+ },
+ )
+}
diff --git a/traffic-one/functions/routes/auth.ts b/traffic-one/functions/routes/auth.ts
new file mode 100644
index 0000000000000..33340e6caace4
--- /dev/null
+++ b/traffic-one/functions/routes/auth.ts
@@ -0,0 +1,48 @@
+import type { SupabaseClient } from 'npm:@supabase/supabase-js@2'
+import { corsHeaders } from '../index.ts'
+
+export async function handleSignup(
+ req: Request,
+ supabase: SupabaseClient,
+): Promise {
+ const { email, password, hcaptchaToken, redirectTo } = await req.json()
+
+ const { error } = await supabase.auth.signUp({
+ email,
+ password,
+ options: {
+ captchaToken: hcaptchaToken ?? undefined,
+ emailRedirectTo: redirectTo ?? undefined,
+ },
+ })
+
+ if (error) {
+ return Response.json({ message: error.message }, {
+ status: error.status ?? 400,
+ headers: corsHeaders,
+ })
+ }
+
+ return new Response(null, { status: 201, headers: corsHeaders })
+}
+
+export async function handleResetPassword(
+ req: Request,
+ supabase: SupabaseClient,
+): Promise {
+ const { email, hcaptchaToken, redirectTo } = await req.json()
+
+ const { error } = await supabase.auth.resetPasswordForEmail(email, {
+ captchaToken: hcaptchaToken ?? undefined,
+ redirectTo: redirectTo ?? undefined,
+ })
+
+ if (error) {
+ return Response.json({ message: error.message }, {
+ status: error.status ?? 400,
+ headers: corsHeaders,
+ })
+ }
+
+ return Response.json({}, { headers: corsHeaders })
+}
diff --git a/traffic-one/functions/routes/backups.ts b/traffic-one/functions/routes/backups.ts
new file mode 100644
index 0000000000000..0234e27ce1a2c
--- /dev/null
+++ b/traffic-one/functions/routes/backups.ts
@@ -0,0 +1,144 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import { getProjectByRef } from '../services/project.service.ts'
+import { assertValidRef } from '../utils/ref-validation.ts'
+
+const BACKUPS_UNSUPPORTED_MESSAGE = 'Database backups are not available in self-hosted deployments'
+
+function notSupportedResponse(message = BACKUPS_UNSUPPORTED_MESSAGE): Response {
+ return Response.json(
+ { code: 'self_hosted_unsupported', message },
+ { status: 501, headers: corsHeaders },
+ )
+}
+
+function notFoundResponse(message = 'Not Found'): Response {
+ return Response.json({ message }, { status: 404, headers: corsHeaders })
+}
+
+function methodNotAllowedResponse(): Response {
+ return Response.json({ message: 'Method not allowed' }, { status: 405, headers: corsHeaders })
+}
+
+// ── Handler ────────────────────────────────────────────────
+
+export async function handleBackups(
+ _req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ _gotrueId: string,
+ _email: string,
+): Promise {
+ // Extract ref from path: /{ref} or /{ref}/sub-path
+ const refMatch = path.match(/^\/([^/]+)(\/.*)?$/)
+ if (!refMatch) {
+ return notFoundResponse()
+ }
+
+ const ref = refMatch[1]
+ const subPath = refMatch[2] || ''
+
+ // L4: malformed ref → 400 before we touch the DB.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) {
+ return notFoundResponse('Project not found')
+ }
+
+ const region = project.region || 'local'
+
+ // ── Backups ─────────────────────────────────────────────
+ if (subPath === '/backups' || subPath === '') {
+ if (method === 'GET') {
+ return Response.json(
+ {
+ backups: [],
+ physicalBackupData: {},
+ pitr_enabled: false,
+ region,
+ walg_enabled: false,
+ tierKey: 'FREE',
+ },
+ { headers: corsHeaders },
+ )
+ }
+ return methodNotAllowedResponse()
+ }
+
+ if (subPath === '/backups/downloadable-backups') {
+ if (method === 'GET') {
+ return Response.json({ backups: [], status: 'ok' }, { headers: corsHeaders })
+ }
+ return methodNotAllowedResponse()
+ }
+
+ if (subPath === '/backups/download') {
+ if (method === 'POST') return notSupportedResponse()
+ return methodNotAllowedResponse()
+ }
+
+ if (subPath === '/backups/restore') {
+ if (method === 'POST') return notSupportedResponse()
+ return methodNotAllowedResponse()
+ }
+
+ if (subPath === '/backups/restore-physical') {
+ if (method === 'POST') return notSupportedResponse()
+ return methodNotAllowedResponse()
+ }
+
+ if (subPath === '/backups/enable-physical-backups') {
+ if (method === 'POST') return notSupportedResponse()
+ return methodNotAllowedResponse()
+ }
+
+ if (subPath === '/backups/pitr') {
+ if (method === 'POST') return notSupportedResponse()
+ return methodNotAllowedResponse()
+ }
+
+ // ── Clone ───────────────────────────────────────────────
+ if (subPath === '/clone') {
+ if (method === 'GET') {
+ return Response.json(
+ {
+ backups: [],
+ physicalBackupData: {},
+ pitr_enabled: false,
+ region,
+ target_compute_size: 'nano',
+ target_volume_size_gb: 8,
+ walg_enabled: false,
+ },
+ { headers: corsHeaders },
+ )
+ }
+ if (method === 'POST') return notSupportedResponse()
+ return methodNotAllowedResponse()
+ }
+
+ if (subPath === '/clone/status') {
+ if (method === 'GET') {
+ return Response.json(
+ { id: project.id, ref: project.ref, clones: [] },
+ { headers: corsHeaders },
+ )
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── Hooks (Database Webhooks) ───────────────────────────
+ if (subPath === '/hook-enable') {
+ if (method === 'POST') {
+ return Response.json({ enabled: true }, { headers: corsHeaders })
+ }
+ return methodNotAllowedResponse()
+ }
+
+ return notFoundResponse()
+}
diff --git a/traffic-one/functions/routes/billing.ts b/traffic-one/functions/routes/billing.ts
new file mode 100644
index 0000000000000..23523fffb7386
--- /dev/null
+++ b/traffic-one/functions/routes/billing.ts
@@ -0,0 +1,328 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import {
+ applyProjectAddon,
+ countInvoices,
+ createUpgradeRequest,
+ deletePaymentMethod,
+ deleteTaxId,
+ getCustomer,
+ getInvoice,
+ getPlans,
+ getProjectAddons,
+ getSubscription,
+ listInvoices,
+ listPaymentMethods,
+ listTaxIds,
+ previewSubscriptionChange,
+ redeemCredits,
+ removeProjectAddon,
+ setDefaultPaymentMethod,
+ topUpCredits,
+ updateSubscription,
+ upsertCustomer,
+ upsertTaxId,
+} from '../services/billing.service.ts'
+import { getProjectByRef } from '../services/project.service.ts'
+import { createSetupIntent, isStripeEnabled } from '../services/stripe.service.ts'
+
+export async function handleBilling(
+ req: Request,
+ subPath: string,
+ method: string,
+ pool: Pool,
+ orgId: number,
+ _profileId: number,
+ _gotrueId: string,
+ _email: string,
+): Promise {
+ // ── Subscription ─────────────────────────────────────
+
+ if (subPath === '/billing/subscription' && method === 'GET') {
+ const sub = await getSubscription(pool, orgId)
+ return Response.json(sub, { headers: corsHeaders })
+ }
+
+ if (subPath === '/billing/subscription' && method === 'PUT') {
+ const body = await req.json()
+ const planId = body.plan_id ?? body.tier?.replace('tier_', '') ?? 'free'
+ const planName = body.plan_name ?? planId.charAt(0).toUpperCase() + planId.slice(1)
+ const tier = body.tier ?? `tier_${planId}`
+ const sub = await updateSubscription(pool, orgId, planId, planName, tier)
+ return Response.json(sub, { headers: corsHeaders })
+ }
+
+ if (subPath === '/billing/subscription/preview' && method === 'POST') {
+ const body = await req.json()
+ const preview = await previewSubscriptionChange(pool, orgId, body.target_plan ?? 'free')
+ return Response.json(preview, { headers: corsHeaders })
+ }
+
+ if (subPath === '/billing/subscription/confirm' && method === 'POST') {
+ const sub = await getSubscription(pool, orgId)
+ return Response.json(sub, { headers: corsHeaders })
+ }
+
+ // ── Plans ────────────────────────────────────────────
+
+ if (subPath === '/billing/plans' && method === 'GET') {
+ const plans = getPlans()
+ return Response.json({ plans }, { headers: corsHeaders })
+ }
+
+ // ── Invoices ─────────────────────────────────────────
+
+ if (subPath === '/billing/invoices' && method === 'HEAD') {
+ const count = await countInvoices(pool, orgId)
+ return new Response(null, {
+ headers: { ...corsHeaders, 'X-Total-Count': String(count) },
+ })
+ }
+
+ if (subPath === '/billing/invoices' && method === 'GET') {
+ const url = new URL(req.url)
+ const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
+ const limit = parseInt(url.searchParams.get('limit') ?? '10', 10)
+ const invoices = await listInvoices(pool, orgId, offset, limit)
+ return Response.json(invoices, { headers: corsHeaders })
+ }
+
+ if (subPath === '/billing/invoices/upcoming' && method === 'GET') {
+ return Response.json(
+ {
+ amount_due: 0,
+ subtotal: 0,
+ lines: [],
+ },
+ { headers: corsHeaders },
+ )
+ }
+
+ const invoiceMatch = subPath.match(/^\/billing\/invoices\/([^/]+)(\/.*)?$/)
+ if (invoiceMatch && method === 'GET') {
+ const invoiceId = invoiceMatch[1]
+ const invoiceSub = invoiceMatch[2] || ''
+
+ if (invoiceSub === '/receipt') {
+ const invoice = await getInvoice(pool, orgId, invoiceId)
+ if (!invoice) {
+ return Response.json(
+ { message: 'Invoice not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json({ url: invoice.invoice_pdf ?? '' }, { headers: corsHeaders })
+ }
+
+ if (invoiceSub === '/payment-link') {
+ return Response.json({ url: '' }, { headers: corsHeaders })
+ }
+
+ const invoice = await getInvoice(pool, orgId, invoiceId)
+ if (!invoice) {
+ return Response.json({ message: 'Invoice not found' }, { status: 404, headers: corsHeaders })
+ }
+ return Response.json(invoice, { headers: corsHeaders })
+ }
+
+ // ── Customer ─────────────────────────────────────────
+
+ if (subPath === '/customer' && method === 'GET') {
+ const customer = await getCustomer(pool, orgId)
+ return Response.json(customer, { headers: corsHeaders })
+ }
+
+ if (subPath === '/customer' && method === 'PUT') {
+ const body = await req.json()
+ const customer = await upsertCustomer(pool, orgId, body)
+ return Response.json(customer, { headers: corsHeaders })
+ }
+
+ // ── Payment Methods ──────────────────────────────────
+
+ if (subPath === '/payments' && method === 'GET') {
+ const methods = await listPaymentMethods(pool, orgId)
+ return Response.json(methods, { headers: corsHeaders })
+ }
+
+ if (subPath === '/payments/setup-intent' && method === 'POST') {
+ if (!isStripeEnabled()) {
+ return Response.json(
+ { id: 'seti_local', client_secret: 'local_mode' },
+ { headers: corsHeaders },
+ )
+ }
+ const body = await req.json()
+ const intent = await createSetupIntent(body.customer_id ?? '')
+ return Response.json(intent ?? { id: '', client_secret: '' }, { headers: corsHeaders })
+ }
+
+ if (subPath === '/payments' && method === 'DELETE') {
+ const body = await req.json()
+ const deleted = await deletePaymentMethod(pool, orgId, body.id ?? body.payment_method_id)
+ return Response.json({ success: deleted }, { headers: corsHeaders })
+ }
+
+ if (subPath === '/payments/default' && method === 'PUT') {
+ const body = await req.json()
+ const success = await setDefaultPaymentMethod(pool, orgId, body.id ?? body.payment_method_id)
+ return Response.json({ success }, { headers: corsHeaders })
+ }
+
+ // ── Tax IDs ──────────────────────────────────────────
+ //
+ // GET / PUT both return the OpenAPI `TaxIdResponse` envelope:
+ // `{ tax_id: { country, type, value } | null }` so Studio's
+ // `useOrganizationTaxIdQuery` can read `.tax_id` and fall back to null.
+
+ if (subPath === '/tax-ids' && method === 'GET') {
+ const taxId = await listTaxIds(pool, orgId)
+ return Response.json(taxId, { headers: corsHeaders })
+ }
+
+ if (subPath === '/tax-ids' && method === 'PUT') {
+ const body = await req.json()
+ const taxId = await upsertTaxId(pool, orgId, body.type, body.value, body.country ?? null)
+ return Response.json(taxId, { headers: corsHeaders })
+ }
+
+ if (subPath === '/tax-ids' && method === 'DELETE') {
+ const body = await req.json()
+ const deleted = await deleteTaxId(pool, orgId, body.id)
+ return Response.json({ success: deleted }, { headers: corsHeaders })
+ }
+
+ // ── Credits ──────────────────────────────────────────
+
+ if (subPath === '/billing/credits/top-up' && method === 'POST') {
+ const body = await req.json()
+ const result = await topUpCredits(pool, orgId, body.amount ?? 0)
+ return Response.json(result, { headers: corsHeaders })
+ }
+
+ if (subPath === '/billing/credits/redeem' && method === 'POST') {
+ const body = await req.json()
+ const result = await redeemCredits(pool, orgId, body.amount ?? 0, body.code ?? '')
+ return Response.json(result, { headers: corsHeaders })
+ }
+
+ // ── Upgrade Request ──────────────────────────────────
+
+ if (subPath === '/billing/upgrade-request' && method === 'POST') {
+ const body = await req.json()
+ const result = await createUpgradeRequest(pool, orgId, body.plan ?? '', body.note)
+ return Response.json(result, { status: 201, headers: corsHeaders })
+ }
+
+ return Response.json({ message: 'Not Found' }, { status: 404, headers: corsHeaders })
+}
+
+// ── Stripe top-level routes ────────────────────────────
+
+// `handleStripe` is mounted at `/api/platform/stripe`; it doesn't need a
+// database pool because every path delegates to Stripe or returns a stub.
+// The old signature passed `pool` but never used it, masking a real wiring
+// bug (M8). Callers should pass `createSetupIntent`-compatible bodies
+// (`{ customer_id: string }`) so we can proxy them through when Stripe is
+// configured (H3).
+export async function handleStripe(
+ req: Request,
+ subPath: string,
+ method: string,
+): Promise {
+ if (subPath === '/invoices/overdue' && method === 'GET') {
+ return Response.json([], { headers: corsHeaders })
+ }
+
+ if (subPath === '/setup-intent' && method === 'POST') {
+ if (!isStripeEnabled()) {
+ return Response.json(
+ { id: 'seti_local', client_secret: 'local_mode' },
+ { headers: corsHeaders },
+ )
+ }
+ // H3: previously returned `{ id: '', client_secret: '' }` — Studio cannot
+ // attach a payment method with an empty client secret. Reuse the shared
+ // `createSetupIntent` helper so enabled Stripe deploys work the same way
+ // `/platform/organizations/{slug}/payments/setup-intent` already does.
+ let customerId = ''
+ try {
+ const body = (await req.json()) as { customer_id?: string }
+ customerId = body?.customer_id ?? ''
+ } catch {
+ // empty body → createSetupIntent will error out below if Stripe requires one.
+ }
+ const intent = await createSetupIntent(customerId)
+ return Response.json(intent ?? { id: '', client_secret: '' }, { headers: corsHeaders })
+ }
+
+ return Response.json({ message: 'Not Found' }, { status: 404, headers: corsHeaders })
+}
+
+// ── Project billing routes ─────────────────────────────
+
+// Every `/api/platform/projects/{ref}/billing/*` request must first prove the
+// caller is a member of the project's org. Without this gate any authenticated
+// user with a valid JWT could read and mutate addons/subscription data on an
+// arbitrary project. The 404 (instead of 403) matches the rest of the codebase
+// so we don't leak project existence to non-members.
+export async function handleProjectBilling(
+ req: Request,
+ subPath: string,
+ method: string,
+ pool: Pool,
+ ref: string,
+ profileId: number,
+): Promise {
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) {
+ return Response.json({ message: 'Project not found' }, { status: 404, headers: corsHeaders })
+ }
+
+ if (subPath === '/billing/addons' && method === 'GET') {
+ const addons = await getProjectAddons(pool, ref)
+ return Response.json(addons, { headers: corsHeaders })
+ }
+
+ if (subPath === '/billing/addons' && method === 'POST') {
+ const body = await req.json()
+ const addons = await applyProjectAddon(
+ pool,
+ ref,
+ body.addon_type ?? body.type,
+ body.addon_variant ?? body.variant,
+ )
+ return Response.json(addons, { headers: corsHeaders })
+ }
+
+ const addonDeleteMatch = subPath.match(/^\/billing\/addons\/(.+)$/)
+ if (addonDeleteMatch && method === 'DELETE') {
+ const variant = addonDeleteMatch[1]
+ const removed = await removeProjectAddon(pool, ref, variant)
+ return Response.json({ success: removed }, { headers: corsHeaders })
+ }
+
+ if (subPath === '/billing/subscription' && method === 'GET') {
+ return Response.json(
+ {
+ billing_cycle_anchor: 0,
+ current_period_end: 0,
+ current_period_start: 0,
+ plan: { id: 'free', name: 'Free' },
+ addons: [],
+ usage_fees: [],
+ nano_enabled: true,
+ },
+ { headers: corsHeaders },
+ )
+ }
+
+ return Response.json({ message: 'Not Found' }, { status: 404, headers: corsHeaders })
+}
+
+// ── Confirm subscription on org creation ───────────────
+
+export function handleConfirmSubscription(_req: Request, _method: string): Response {
+ return Response.json({ message: 'Subscription confirmed' }, { headers: corsHeaders })
+}
diff --git a/traffic-one/functions/routes/branches.ts b/traffic-one/functions/routes/branches.ts
new file mode 100644
index 0000000000000..47e2d53b6d0e2
--- /dev/null
+++ b/traffic-one/functions/routes/branches.ts
@@ -0,0 +1,390 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import {
+ type BranchRow,
+ createBranch,
+ getBranchById,
+ listBranchesForProject,
+ mergeBranch,
+ pushBranch,
+ resetBranch,
+ restoreBranch,
+ softDeleteBranch,
+ type TransitionOutcome,
+ updateBranch,
+} from '../services/branches.service.ts'
+import { getProjectByRef } from '../services/project.service.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+import { assertValidRef } from '../utils/ref-validation.ts'
+
+// ── Response helpers ──────────────────────────────────────
+
+function notFoundResponse(message = 'Not Found'): Response {
+ return Response.json({ message }, { status: 404, headers: corsHeaders })
+}
+
+function forbiddenResponse(message = 'Forbidden'): Response {
+ return Response.json({ message }, { status: 403, headers: corsHeaders })
+}
+
+function methodNotAllowedResponse(): Response {
+ return Response.json({ message: 'Method not allowed' }, { status: 405, headers: corsHeaders })
+}
+
+function invalidBodyResponse(message = 'Invalid request body'): Response {
+ return Response.json({ message }, { status: 400, headers: corsHeaders })
+}
+
+// Studio expects UUID-shaped ids. Treat non-UUID path params as 404 rather
+// than letting the DB layer surface a 500 from an invalid cast.
+const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+
+function isValidUuid(id: string): boolean {
+ return UUID_REGEX.test(id)
+}
+
+// Shape-stable output used by both list + single-branch responses. Keeps
+// the JSON keys camelCase-free so Studio's BranchesQuery and the existing
+// Supabase CLI types can consume the same payload without an adapter.
+function toBranchResponse(row: BranchRow): Record {
+ return {
+ id: row.id,
+ project_ref: row.project_ref,
+ parent_project_ref: row.parent_project_ref,
+ branch_name: row.branch_name,
+ is_default: row.is_default,
+ git_branch: row.git_branch,
+ status: row.status,
+ pr_number: row.pr_number,
+ created_at: row.created_at,
+ updated_at: row.updated_at,
+ merged_at: row.merged_at,
+ deleted_at: row.deleted_at,
+ persistent: row.is_default,
+ review_requested_at: null,
+ }
+}
+
+function getIp(req: Request): string {
+ return getClientIp(req)
+}
+
+function transitionStatus(outcome: TransitionOutcome): number {
+ if (outcome.status === 'not_found') return 404
+ if (outcome.status === 'invalid_state') return 409
+ return 200
+}
+
+function transitionBody(outcome: TransitionOutcome): Record {
+ if (outcome.status === 'not_found') return { message: 'Branch not found' }
+ if (outcome.status === 'invalid_state') {
+ return {
+ code: 'invalid_state',
+ message: outcome.message,
+ current_status: outcome.current,
+ }
+ }
+ return toBranchResponse(outcome.branch)
+}
+
+// ── /{ref}/branches — project-scoped list + create ────────
+
+export async function handleProjectBranches(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+): Promise {
+ const match = path.match(/^\/([^/]+)\/branches\/?$/)
+ if (!match) {
+ return notFoundResponse()
+ }
+ const ref = match[1]
+
+ // L4: malformed ref → 400 before DB lookup.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) {
+ return notFoundResponse('Project not found')
+ }
+
+ if (method === 'GET') {
+ const rows = await listBranchesForProject(pool, ref)
+ return Response.json(rows.map(toBranchResponse), { headers: corsHeaders })
+ }
+
+ if (method === 'POST') {
+ let body: Record
+ try {
+ body = await req.json()
+ } catch {
+ return invalidBodyResponse('Body must be valid JSON')
+ }
+
+ const branchName = typeof body.branch_name === 'string' ? body.branch_name : undefined
+ if (!branchName || !branchName.trim()) {
+ return invalidBodyResponse('branch_name is required')
+ }
+
+ const auditContext = {
+ email,
+ ip: getIp(req),
+ method,
+ route: '/v1/projects/' + ref + '/branches',
+ }
+
+ const outcome = await createBranch(
+ pool,
+ ref,
+ profileId,
+ {
+ branchName,
+ isDefault: typeof body.is_default === 'boolean' ? body.is_default : false,
+ gitBranch: typeof body.git_branch === 'string' ? body.git_branch : null,
+ parentProjectRef: typeof body.parent_project_ref === 'string'
+ ? body.parent_project_ref
+ : null,
+ prNumber: typeof body.pr_number === 'number' ? body.pr_number : null,
+ },
+ gotrueId,
+ project.organization_id,
+ auditContext,
+ )
+
+ if (outcome.status === 'conflict') {
+ return Response.json(
+ { code: 'conflict', message: outcome.message },
+ { status: 409, headers: corsHeaders },
+ )
+ }
+
+ return Response.json(toBranchResponse(outcome.branch), {
+ status: 201,
+ headers: corsHeaders,
+ })
+ }
+
+ return methodNotAllowedResponse()
+}
+
+// ── /{id} and /{id}/(diff|merge|push|reset|restore) ───────
+//
+// This handler lives under a dedicated Kong service (`v1-branches`) and
+// receives paths stripped to `/{id}` / `/{id}/`. Membership is
+// enforced by first looking up the branch's project_ref, then calling
+// getProjectByRef with the caller's profileId — non-members see 403.
+
+export async function handleBranchById(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+): Promise {
+ const match = path.match(/^\/([^/]+)(\/.*)?$/)
+ if (!match) return notFoundResponse()
+
+ const id = match[1]
+ const action = (match[2] ?? '').replace(/\/$/, '')
+
+ if (!isValidUuid(id)) {
+ return notFoundResponse('Branch not found')
+ }
+
+ const branch = await getBranchById(pool, id)
+ if (!branch) {
+ return notFoundResponse('Branch not found')
+ }
+
+ // Membership: the caller must be a member of the branch's project.
+ const project = await getProjectByRef(pool, branch.project_ref, profileId)
+ if (!project) {
+ // Treat unauthorized access to a known id as 403 (distinguishable from
+ // "branch does not exist at all" which is handled above).
+ return forbiddenResponse('Not a member of this project')
+ }
+
+ const auditContext = {
+ email,
+ ip: getIp(req),
+ method,
+ route: '/v1/branches/' + id + (action || ''),
+ }
+
+ // ── Action routes: /{id}/ ──
+
+ if (action === '/diff') {
+ if (method !== 'GET') return methodNotAllowedResponse()
+ // Self-hosted has no schema-diff engine; return a shape-correct stub
+ // so Studio's diff panel renders an empty state instead of a crash.
+ return Response.json(
+ {
+ migrations_ahead: 0,
+ schema_changes: [],
+ data_changes: [],
+ },
+ { headers: corsHeaders },
+ )
+ }
+
+ if (action === '/merge') {
+ if (method !== 'POST') return methodNotAllowedResponse()
+ const outcome = await mergeBranch(
+ pool,
+ id,
+ profileId,
+ gotrueId,
+ project.organization_id,
+ auditContext,
+ )
+ return Response.json(transitionBody(outcome), {
+ status: transitionStatus(outcome),
+ headers: corsHeaders,
+ })
+ }
+
+ if (action === '/push') {
+ if (method !== 'POST') return methodNotAllowedResponse()
+ const outcome = await pushBranch(
+ pool,
+ id,
+ profileId,
+ gotrueId,
+ project.organization_id,
+ auditContext,
+ )
+ return Response.json(transitionBody(outcome), {
+ status: transitionStatus(outcome),
+ headers: corsHeaders,
+ })
+ }
+
+ if (action === '/reset') {
+ if (method !== 'POST') return methodNotAllowedResponse()
+ const outcome = await resetBranch(
+ pool,
+ id,
+ profileId,
+ gotrueId,
+ project.organization_id,
+ auditContext,
+ )
+ return Response.json(transitionBody(outcome), {
+ status: transitionStatus(outcome),
+ headers: corsHeaders,
+ })
+ }
+
+ if (action === '/restore') {
+ if (method !== 'POST') return methodNotAllowedResponse()
+ if (!branch.deleted_at) {
+ return Response.json(
+ { code: 'invalid_state', message: 'Branch is not deleted' },
+ { status: 409, headers: corsHeaders },
+ )
+ }
+ const restored = await restoreBranch(
+ pool,
+ id,
+ profileId,
+ gotrueId,
+ project.organization_id,
+ auditContext,
+ )
+ if (!restored) {
+ return notFoundResponse('Branch not found')
+ }
+ return Response.json(toBranchResponse(restored), { headers: corsHeaders })
+ }
+
+ // ── Bare /{id} ──
+
+ if (action !== '') {
+ return notFoundResponse()
+ }
+
+ if (method === 'GET') {
+ return Response.json(toBranchResponse(branch), { headers: corsHeaders })
+ }
+
+ if (method === 'PATCH') {
+ if (branch.deleted_at) {
+ return notFoundResponse('Branch not found')
+ }
+ let body: Record
+ try {
+ body = await req.json()
+ } catch {
+ return invalidBodyResponse('Body must be valid JSON')
+ }
+
+ const outcome = await updateBranch(
+ pool,
+ id,
+ {
+ branchName: typeof body.branch_name === 'string' ? body.branch_name : undefined,
+ isDefault: typeof body.is_default === 'boolean' ? body.is_default : undefined,
+ gitBranch: typeof body.git_branch === 'string'
+ ? body.git_branch
+ : body.git_branch === null
+ ? null
+ : undefined,
+ parentProjectRef: typeof body.parent_project_ref === 'string'
+ ? body.parent_project_ref
+ : body.parent_project_ref === null
+ ? null
+ : undefined,
+ prNumber: typeof body.pr_number === 'number'
+ ? body.pr_number
+ : body.pr_number === null
+ ? null
+ : undefined,
+ },
+ profileId,
+ gotrueId,
+ project.organization_id,
+ auditContext,
+ )
+
+ if (outcome.status === 'not_found') {
+ return notFoundResponse('Branch not found')
+ }
+ if (outcome.status === 'conflict') {
+ return Response.json(
+ { code: 'conflict', message: outcome.message },
+ { status: 409, headers: corsHeaders },
+ )
+ }
+ return Response.json(toBranchResponse(outcome.branch), {
+ headers: corsHeaders,
+ })
+ }
+
+ if (method === 'DELETE') {
+ if (branch.deleted_at) {
+ return notFoundResponse('Branch not found')
+ }
+ const deleted = await softDeleteBranch(
+ pool,
+ id,
+ profileId,
+ gotrueId,
+ project.organization_id,
+ auditContext,
+ )
+ if (!deleted) {
+ return notFoundResponse('Branch not found')
+ }
+ return Response.json(toBranchResponse(deleted), { headers: corsHeaders })
+ }
+
+ return methodNotAllowedResponse()
+}
diff --git a/traffic-one/functions/routes/cli.ts b/traffic-one/functions/routes/cli.ts
new file mode 100644
index 0000000000000..de5ffc28f49b8
--- /dev/null
+++ b/traffic-one/functions/routes/cli.ts
@@ -0,0 +1,56 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import { createScopedAccessToken } from '../services/access-token.service.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+
+const DEFAULT_CLI_PERMISSIONS = [
+ 'organizations_read',
+ 'projects_read',
+ 'organization_admin_read',
+ 'project_admin_read',
+]
+
+function pickString(body: Record, ...keys: string[]): string | undefined {
+ for (const key of keys) {
+ const value = body[key]
+ if (typeof value === 'string' && value.length > 0) return value
+ }
+ return undefined
+}
+
+export async function handleCli(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+): Promise {
+ if (method === 'POST' && path === '/login') {
+ const body = (await req.json().catch(() => ({}))) as Record
+
+ const ip = getClientIp(req)
+ const auditContext = { email, ip, method, route: '/cli' + path }
+
+ const name = pickString(body, 'token_name', 'name') ?? `cli-${Date.now()}`
+ const expiresAt = pickString(body, 'expires_at')
+
+ const token = await createScopedAccessToken(
+ pool,
+ profileId,
+ {
+ name,
+ permissions: DEFAULT_CLI_PERMISSIONS,
+ expires_at: expiresAt,
+ },
+ gotrueId,
+ auditContext,
+ )
+
+ return Response.json(token, { status: 201, headers: corsHeaders })
+ }
+
+ return Response.json({ message: 'Method not allowed' }, { status: 405, headers: corsHeaders })
+}
diff --git a/traffic-one/functions/routes/content.ts b/traffic-one/functions/routes/content.ts
new file mode 100644
index 0000000000000..58bed31158748
--- /dev/null
+++ b/traffic-one/functions/routes/content.ts
@@ -0,0 +1,693 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import {
+ type AuditContext,
+ ContentForbiddenError,
+ type ContentType,
+ type ContentVisibility,
+ countContent,
+ createFolder,
+ deleteContentBulk,
+ deleteFoldersBulk,
+ getContentById,
+ listContent,
+ listFolderContents,
+ listRootFolder,
+ patchContent,
+ toDetailItem,
+ toFolderListItem,
+ toFolderMetadata,
+ toListItem,
+ updateFolder,
+ upsertContent,
+ type UpsertContentInput,
+} from '../services/content.service.ts'
+import { getProjectByRef } from '../services/project.service.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+import { assertValidRef } from '../utils/ref-validation.ts'
+
+// ── Response helpers ───────────────────────────────────────
+
+function json(body: unknown, status = 200): Response {
+ return Response.json(body, { status, headers: corsHeaders })
+}
+
+function badRequest(message: string): Response {
+ return json({ message }, 400)
+}
+
+function forbidden(message = 'Forbidden'): Response {
+ return json({ message }, 403)
+}
+
+function notFound(message = 'Not Found'): Response {
+ return json({ message }, 404)
+}
+
+function methodNotAllowed(): Response {
+ return json({ message: 'Method not allowed' }, 405)
+}
+
+// ── Parsing helpers ────────────────────────────────────────
+
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+
+function isUuid(v: string): boolean {
+ return UUID_RE.test(v)
+}
+
+function parseIntSafe(raw: string | null): number | undefined {
+ if (raw === null) return undefined
+ const parsed = Number.parseInt(raw, 10)
+ return Number.isFinite(parsed) ? parsed : undefined
+}
+
+function parseBooleanSafe(raw: string | null): boolean | undefined {
+ if (raw === null) return undefined
+ if (raw === 'true') return true
+ if (raw === 'false') return false
+ return undefined
+}
+
+function parseType(raw: string | null | undefined): ContentType | undefined {
+ if (raw === 'sql' || raw === 'report' || raw === 'log_sql') return raw
+ return undefined
+}
+
+function parseVisibility(raw: string | null | undefined): ContentVisibility | undefined {
+ if (raw === 'user' || raw === 'project') return raw
+ return undefined
+}
+
+function parseSortBy(raw: string | null | undefined): 'name' | 'inserted_at' | undefined {
+ if (raw === 'name' || raw === 'inserted_at') return raw
+ return undefined
+}
+
+function parseSortOrder(raw: string | null | undefined): 'asc' | 'desc' | undefined {
+ if (raw === 'asc' || raw === 'desc') return raw
+ return undefined
+}
+
+function offsetFromCursor(cursor: string | null): number | undefined {
+ if (!cursor) return undefined
+ const n = Number.parseInt(cursor, 10)
+ return Number.isFinite(n) && n >= 0 ? n : undefined
+}
+
+async function readJsonBody(req: Request): Promise> {
+ try {
+ const body = await req.json()
+ if (body && typeof body === 'object' && !Array.isArray(body)) {
+ return body as Record
+ }
+ } catch {
+ // ignore
+ }
+ return {}
+}
+
+function parseIdsList(raw: unknown): string[] {
+ if (Array.isArray(raw)) {
+ return raw.filter((x): x is string => typeof x === 'string' && isUuid(x))
+ }
+ if (typeof raw === 'string' && raw.length > 0) {
+ return raw
+ .split(',')
+ .map((s) => s.trim())
+ .filter(isUuid)
+ }
+ return []
+}
+
+function mapServiceError(err: unknown): Response {
+ if (err instanceof ContentForbiddenError) {
+ return forbidden(err.message)
+ }
+ if (err instanceof Error) {
+ const msg = err.message
+ if (msg === 'Parent folder not found' || msg === 'Cannot set a folder as its own parent') {
+ return badRequest(msg)
+ }
+ }
+ throw err
+}
+
+// ── Handler ────────────────────────────────────────────────
+
+export async function handleContent(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+): Promise {
+ const refMatch = path.match(/^\/([^/]+)\/content(\/.*)?$/)
+ if (!refMatch) return notFound()
+
+ const ref = refMatch[1]
+ const subPath = refMatch[2] ?? ''
+
+ // L4: malformed ref → 400 before DB lookup.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) {
+ return notFound('Project not found')
+ }
+ const projectId = project.id
+ const projectOrgId = project.organization_id
+
+ const ip = getClientIp(req)
+ const auditContext: AuditContext = {
+ email,
+ ip,
+ method,
+ route: '/projects/' + ref + '/content' + subPath,
+ }
+
+ const url = new URL(req.url)
+
+ try {
+ // ── /content (root resource) ──────────────────────────
+ if (subPath === '' || subPath === '/') {
+ if (method === 'GET') {
+ return await handleListRoot(url, pool, ref, profileId, projectId)
+ }
+ if (method === 'POST' || method === 'PUT') {
+ return await handleUpsert(
+ req,
+ pool,
+ ref,
+ projectId,
+ projectOrgId,
+ profileId,
+ gotrueId,
+ auditContext,
+ method === 'POST',
+ )
+ }
+ if (method === 'DELETE') {
+ return await handleBulkDelete(
+ req,
+ url,
+ pool,
+ ref,
+ projectOrgId,
+ profileId,
+ gotrueId,
+ auditContext,
+ )
+ }
+ return methodNotAllowed()
+ }
+
+ // ── /content/count ────────────────────────────────────
+ if (subPath === '/count') {
+ if (method === 'GET') {
+ return await handleCount(url, pool, ref, profileId)
+ }
+ return methodNotAllowed()
+ }
+
+ // ── /content/item/{id} ────────────────────────────────
+ const itemMatch = subPath.match(/^\/item\/([^/]+)$/)
+ if (itemMatch) {
+ const id = itemMatch[1]
+ if (!isUuid(id)) return notFound('Invalid content id')
+
+ if (method === 'GET') {
+ return await handleGetItem(pool, ref, profileId, projectId, id)
+ }
+ if (method === 'PATCH') {
+ return await handlePatchItem(
+ req,
+ pool,
+ ref,
+ projectId,
+ projectOrgId,
+ profileId,
+ gotrueId,
+ auditContext,
+ id,
+ )
+ }
+ return methodNotAllowed()
+ }
+
+ // ── /content/folders ──────────────────────────────────
+ if (subPath === '/folders') {
+ if (method === 'GET') {
+ return await handleListRootFolders(url, pool, ref, profileId, projectId)
+ }
+ if (method === 'POST') {
+ return await handleCreateFolder(
+ req,
+ pool,
+ ref,
+ projectId,
+ projectOrgId,
+ profileId,
+ gotrueId,
+ auditContext,
+ )
+ }
+ if (method === 'DELETE') {
+ return await handleBulkDeleteFolders(
+ req,
+ url,
+ pool,
+ ref,
+ projectOrgId,
+ profileId,
+ gotrueId,
+ auditContext,
+ )
+ }
+ return methodNotAllowed()
+ }
+
+ // ── /content/folders/{id} ─────────────────────────────
+ const folderMatch = subPath.match(/^\/folders\/([^/]+)$/)
+ if (folderMatch) {
+ const id = folderMatch[1]
+ if (!isUuid(id)) return notFound('Invalid folder id')
+
+ if (method === 'GET') {
+ return await handleGetFolder(url, pool, ref, profileId, projectId, id)
+ }
+ if (method === 'PATCH') {
+ return await handlePatchFolder(
+ req,
+ pool,
+ ref,
+ projectId,
+ projectOrgId,
+ profileId,
+ gotrueId,
+ auditContext,
+ id,
+ )
+ }
+ return methodNotAllowed()
+ }
+
+ return notFound()
+ } catch (err) {
+ return mapServiceError(err)
+ }
+}
+
+// ── GET /content — list ────────────────────────────────────
+
+async function handleListRoot(
+ url: URL,
+ pool: Pool,
+ ref: string,
+ profileId: number,
+ projectId: number,
+): Promise {
+ const q = url.searchParams
+ const limit = parseIntSafe(q.get('limit'))
+ const cursor = q.get('cursor')
+ const offset = offsetFromCursor(cursor) ?? parseIntSafe(q.get('offset'))
+
+ const result = await listContent(pool, ref, profileId, {
+ type: parseType(q.get('type')),
+ visibility: parseVisibility(q.get('visibility')),
+ favorite: parseBooleanSafe(q.get('favorite')),
+ name: q.get('name') ?? undefined,
+ limit,
+ offset,
+ sortBy: parseSortBy(q.get('sort_by')),
+ sortOrder: parseSortOrder(q.get('sort_order')),
+ })
+
+ return json({
+ data: result.rows.map((row) => toListItem(row, projectId)),
+ cursor: result.cursor,
+ })
+}
+
+// ── GET /content/count ─────────────────────────────────────
+
+async function handleCount(
+ url: URL,
+ pool: Pool,
+ ref: string,
+ profileId: number,
+): Promise {
+ const q = url.searchParams
+ const result = await countContent(pool, ref, profileId, {
+ type: parseType(q.get('type')),
+ name: q.get('name') ?? undefined,
+ })
+ return json(result)
+}
+
+// ── GET /content/item/{id} ─────────────────────────────────
+
+async function handleGetItem(
+ pool: Pool,
+ ref: string,
+ profileId: number,
+ projectId: number,
+ id: string,
+): Promise {
+ const row = await getContentById(pool, ref, profileId, id)
+ if (!row) return notFound('Content not found')
+ return json(toDetailItem(row, projectId))
+}
+
+// ── POST/PUT /content — upsert ─────────────────────────────
+
+async function handleUpsert(
+ req: Request,
+ pool: Pool,
+ ref: string,
+ projectId: number,
+ projectOrgId: number,
+ profileId: number,
+ gotrueId: string,
+ auditContext: AuditContext,
+ isCreate: boolean,
+): Promise {
+ const body = await readJsonBody(req)
+ const typeRaw = typeof body.type === 'string' ? body.type : undefined
+ const type = parseType(typeRaw) ?? 'sql'
+ const visibility = parseVisibility(
+ typeof body.visibility === 'string' ? body.visibility : undefined,
+ )
+
+ const rawId = typeof body.id === 'string' ? body.id : undefined
+ if (rawId !== undefined && !isUuid(rawId)) {
+ return badRequest('Invalid id format')
+ }
+
+ const folderIdRaw = body.folder_id
+ let folderId: string | null | undefined = undefined
+ if (folderIdRaw === null) {
+ folderId = null
+ } else if (typeof folderIdRaw === 'string') {
+ if (!isUuid(folderIdRaw)) return badRequest('Invalid folder_id')
+ folderId = folderIdRaw
+ }
+
+ const input: UpsertContentInput = {
+ id: rawId,
+ name: typeof body.name === 'string' ? body.name : undefined,
+ description: typeof body.description === 'string' ? body.description : undefined,
+ type,
+ visibility,
+ content: body.content && typeof body.content === 'object' && !Array.isArray(body.content)
+ ? (body.content as Record)
+ : undefined,
+ favorite: typeof body.favorite === 'boolean' ? body.favorite : undefined,
+ folder_id: folderId,
+ }
+
+ const row = await upsertContent(pool, ref, projectOrgId, profileId, gotrueId, input, auditContext)
+ return json(toListItem(row, projectId), isCreate ? 201 : 200)
+}
+
+// ── DELETE /content — bulk ────────────────────────────────
+
+async function handleBulkDelete(
+ req: Request,
+ url: URL,
+ pool: Pool,
+ ref: string,
+ projectOrgId: number,
+ profileId: number,
+ gotrueId: string,
+ auditContext: AuditContext,
+): Promise {
+ const queryIds = parseIdsList(url.searchParams.get('ids'))
+ let ids = queryIds
+ if (ids.length === 0) {
+ const body = await readJsonBody(req)
+ ids = parseIdsList(body.ids)
+ }
+ if (ids.length === 0) {
+ return json({ deleted: 0 })
+ }
+
+ const result = await deleteContentBulk(
+ pool,
+ ref,
+ projectOrgId,
+ profileId,
+ gotrueId,
+ ids,
+ auditContext,
+ )
+ return json({ deleted: result.deletedIds.length, ids: result.deletedIds })
+}
+
+// ── PATCH /content/item/{id} ───────────────────────────────
+
+async function handlePatchItem(
+ req: Request,
+ pool: Pool,
+ ref: string,
+ projectId: number,
+ projectOrgId: number,
+ profileId: number,
+ gotrueId: string,
+ auditContext: AuditContext,
+ id: string,
+): Promise {
+ const body = await readJsonBody(req)
+
+ const visibility = parseVisibility(
+ typeof body.visibility === 'string' ? body.visibility : undefined,
+ )
+
+ const folderIdRaw = body.folder_id
+ let folderId: string | null | undefined = undefined
+ if (folderIdRaw === null) {
+ folderId = null
+ } else if (typeof folderIdRaw === 'string') {
+ if (!isUuid(folderIdRaw)) return badRequest('Invalid folder_id')
+ folderId = folderIdRaw
+ }
+
+ const patch = {
+ name: typeof body.name === 'string' ? body.name : undefined,
+ description: typeof body.description === 'string' ? body.description : undefined,
+ visibility,
+ content: body.content && typeof body.content === 'object' && !Array.isArray(body.content)
+ ? (body.content as Record)
+ : undefined,
+ favorite: typeof body.favorite === 'boolean' ? body.favorite : undefined,
+ ...(folderId !== undefined ? { folder_id: folderId } : {}),
+ }
+
+ const row = await patchContent(
+ pool,
+ ref,
+ projectOrgId,
+ profileId,
+ gotrueId,
+ id,
+ patch,
+ auditContext,
+ )
+ if (!row) return notFound('Content not found')
+ return json(toDetailItem(row, projectId))
+}
+
+// ── GET /content/folders — root listing ────────────────────
+
+async function handleListRootFolders(
+ url: URL,
+ pool: Pool,
+ ref: string,
+ profileId: number,
+ projectId: number,
+): Promise {
+ const q = url.searchParams
+ const limit = parseIntSafe(q.get('limit'))
+ const cursor = q.get('cursor')
+ const offset = offsetFromCursor(cursor) ?? parseIntSafe(q.get('offset'))
+
+ const result = await listRootFolder(pool, ref, profileId, {
+ type: parseType(q.get('type')),
+ name: q.get('name') ?? undefined,
+ limit,
+ offset,
+ sortBy: parseSortBy(q.get('sort_by')),
+ sortOrder: parseSortOrder(q.get('sort_order')),
+ })
+
+ return json({
+ data: {
+ folders: result.folders.map((f) => toFolderMetadata(f, projectId)),
+ contents: result.contents.map((row) => toFolderListItem(row, projectId)),
+ },
+ cursor: result.cursor,
+ })
+}
+
+// ── POST /content/folders ──────────────────────────────────
+
+async function handleCreateFolder(
+ req: Request,
+ pool: Pool,
+ ref: string,
+ projectId: number,
+ projectOrgId: number,
+ profileId: number,
+ gotrueId: string,
+ auditContext: AuditContext,
+): Promise {
+ const body = await readJsonBody(req)
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
+ if (name.length === 0) return badRequest('name is required')
+
+ const parentRaw = typeof body.parent_id === 'string'
+ ? body.parent_id
+ : typeof body.parentId === 'string'
+ ? body.parentId
+ : null
+ const parentId = parentRaw && isUuid(parentRaw) ? parentRaw : null
+ if (parentRaw && !parentId) {
+ return badRequest('Invalid parent folder id')
+ }
+
+ const folder = await createFolder(
+ pool,
+ ref,
+ projectOrgId,
+ profileId,
+ gotrueId,
+ name,
+ parentId,
+ auditContext,
+ )
+ return json(toFolderMetadata(folder, projectId), 201)
+}
+
+// ── DELETE /content/folders — bulk ────────────────────────
+
+async function handleBulkDeleteFolders(
+ req: Request,
+ url: URL,
+ pool: Pool,
+ ref: string,
+ projectOrgId: number,
+ profileId: number,
+ gotrueId: string,
+ auditContext: AuditContext,
+): Promise {
+ const queryIds = parseIdsList(url.searchParams.getAll('ids').join(','))
+ let ids = queryIds
+ if (ids.length === 0) {
+ const singleQueryIds = parseIdsList(url.searchParams.get('ids'))
+ if (singleQueryIds.length > 0) ids = singleQueryIds
+ }
+ if (ids.length === 0) {
+ const body = await readJsonBody(req)
+ ids = parseIdsList(body.ids)
+ }
+ if (ids.length === 0) {
+ return json({ deleted: 0, ids: [] })
+ }
+
+ const result = await deleteFoldersBulk(
+ pool,
+ ref,
+ projectOrgId,
+ profileId,
+ gotrueId,
+ ids,
+ auditContext,
+ )
+ return json({ deleted: result.deletedIds.length, ids: result.deletedIds })
+}
+
+// ── GET /content/folders/{id} ─────────────────────────────
+
+async function handleGetFolder(
+ url: URL,
+ pool: Pool,
+ ref: string,
+ profileId: number,
+ projectId: number,
+ folderId: string,
+): Promise {
+ const q = url.searchParams
+ const limit = parseIntSafe(q.get('limit'))
+ const cursor = q.get('cursor')
+ const offset = offsetFromCursor(cursor) ?? parseIntSafe(q.get('offset'))
+
+ const result = await listFolderContents(pool, ref, profileId, folderId, {
+ name: q.get('name') ?? undefined,
+ limit,
+ offset,
+ sortBy: parseSortBy(q.get('sort_by')),
+ sortOrder: parseSortOrder(q.get('sort_order')),
+ })
+
+ if (!result.folder) return notFound('Folder not found')
+
+ return json({
+ data: {
+ folders: result.folders.map((f) => toFolderMetadata(f, projectId)),
+ contents: result.contents.map((row) => toFolderListItem(row, projectId)),
+ },
+ cursor: result.cursor,
+ })
+}
+
+// ── PATCH /content/folders/{id} ───────────────────────────
+
+async function handlePatchFolder(
+ req: Request,
+ pool: Pool,
+ ref: string,
+ projectId: number,
+ projectOrgId: number,
+ profileId: number,
+ gotrueId: string,
+ auditContext: AuditContext,
+ folderId: string,
+): Promise {
+ const body = await readJsonBody(req)
+ const name = typeof body.name === 'string' ? body.name.trim() : undefined
+
+ let parentId: string | null | undefined
+ if (body.parent_id === null || body.parentId === null) {
+ parentId = null
+ } else if (typeof body.parent_id === 'string') {
+ if (!isUuid(body.parent_id)) return badRequest('Invalid parent_id')
+ parentId = body.parent_id
+ } else if (typeof body.parentId === 'string') {
+ if (!isUuid(body.parentId)) return badRequest('Invalid parentId')
+ parentId = body.parentId
+ }
+
+ if (name === undefined && parentId === undefined) {
+ return badRequest('At least one of name or parent_id must be provided')
+ }
+ if (name !== undefined && name.length === 0) {
+ return badRequest('name cannot be empty')
+ }
+
+ const folder = await updateFolder(
+ pool,
+ ref,
+ projectOrgId,
+ profileId,
+ gotrueId,
+ folderId,
+ { name, parentId },
+ auditContext,
+ )
+ if (!folder) return notFound('Folder not found')
+ return json(toFolderMetadata(folder, projectId))
+}
diff --git a/traffic-one/functions/routes/custom-hostname.ts b/traffic-one/functions/routes/custom-hostname.ts
new file mode 100644
index 0000000000000..83220d398e351
--- /dev/null
+++ b/traffic-one/functions/routes/custom-hostname.ts
@@ -0,0 +1,181 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import {
+ type CustomHostnameRow,
+ getCustomHostnameByRef,
+ upsertInitializedCustomHostname,
+} from '../services/custom-hostnames.service.ts'
+import { getProjectByRef } from '../services/project.service.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+import { assertValidRef } from '../utils/ref-validation.ts'
+
+const CUSTOM_HOSTNAME_UNSUPPORTED_MESSAGE =
+ 'Custom hostname activation is not available in self-hosted deployments'
+
+function notFoundResponse(message = 'Not Found'): Response {
+ return Response.json({ message }, { status: 404, headers: corsHeaders })
+}
+
+function methodNotAllowedResponse(): Response {
+ return Response.json({ message: 'Method not allowed' }, { status: 405, headers: corsHeaders })
+}
+
+function invalidBodyResponse(message = 'Invalid request body'): Response {
+ return Response.json({ message }, { status: 400, headers: corsHeaders })
+}
+
+function notSupportedResponse(message = CUSTOM_HOSTNAME_UNSUPPORTED_MESSAGE): Response {
+ return Response.json(
+ { code: 'self_hosted_unsupported', message },
+ { status: 501, headers: corsHeaders },
+ )
+}
+
+function toCustomHostnameResponse(row: CustomHostnameRow): Record {
+ return {
+ status: row.status,
+ custom_hostname: row.custom_hostname,
+ verification_errors: row.verification_errors ?? [],
+ ownership_verification: {
+ verified: row.ownership_verified,
+ },
+ ssl: { verified: row.ssl_verified },
+ inserted_at: row.inserted_at,
+ updated_at: row.updated_at,
+ }
+}
+
+function emptyCustomHostnameResponse(): Record {
+ return {
+ status: 'not_configured',
+ custom_hostname: null,
+ verification_errors: [],
+ ownership_verification: { verified: false },
+ ssl: { verified: false },
+ }
+}
+
+async function emitInitializeAudit(
+ pool: Pool,
+ profileId: number,
+ organizationId: number,
+ gotrueId: string,
+ projectRef: string,
+ customHostname: string,
+ auditContext: { email: string; ip: string; method: string; route: string },
+): Promise {
+ const connection = await pool.connect()
+ try {
+ await connection.queryObject`
+ INSERT INTO traffic.audit_logs (
+ id, profile_id, organization_id, action_name, action_metadata,
+ actor_id, actor_type, actor_metadata,
+ target_description, target_metadata, occurred_at
+ ) VALUES (
+ gen_random_uuid(), ${profileId}, ${organizationId},
+ 'project.custom_hostname_initialized',
+ ${
+ JSON.stringify([{ method: auditContext.method, route: auditContext.route, status: 200 }])
+ }::jsonb,
+ ${gotrueId}, 'user',
+ ${JSON.stringify([{ email: auditContext.email, ip: auditContext.ip }])}::jsonb,
+ ${'custom_hostnames (ref: ' + projectRef + ', hostname: ' + customHostname + ')'},
+ ${JSON.stringify({ custom_hostname: customHostname })}::jsonb,
+ now()
+ )
+ `
+ } finally {
+ connection.release()
+ }
+}
+
+// ── Handler ───────────────────────────────────────────────
+
+export async function handleCustomHostname(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+): Promise {
+ const match = path.match(/^\/([^/]+)\/custom-hostname(\/initialize|\/activate|\/reverify)?\/?$/)
+ if (!match) return notFoundResponse()
+
+ const ref = match[1]
+ const action = match[2] ?? ''
+
+ // L4: malformed ref → 400 before DB lookup.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) {
+ return notFoundResponse('Project not found')
+ }
+
+ // GET /{ref}/custom-hostname — return stored row, or a not_configured stub.
+ if (action === '') {
+ if (method !== 'GET') return methodNotAllowedResponse()
+ const row = await getCustomHostnameByRef(pool, ref)
+ if (!row) {
+ return Response.json(emptyCustomHostnameResponse(), {
+ headers: corsHeaders,
+ })
+ }
+ return Response.json(toCustomHostnameResponse(row), { headers: corsHeaders })
+ }
+
+ // POST /{ref}/custom-hostname/initialize — persist + flip to pending.
+ if (action === '/initialize') {
+ if (method !== 'POST') return methodNotAllowedResponse()
+
+ let body: Record
+ try {
+ body = await req.json()
+ } catch {
+ return invalidBodyResponse('Body must be valid JSON')
+ }
+
+ const hostname = typeof body.custom_hostname === 'string' ? body.custom_hostname.trim() : ''
+ if (!hostname) {
+ return invalidBodyResponse('custom_hostname is required')
+ }
+
+ const row = await upsertInitializedCustomHostname(pool, ref, hostname)
+
+ const auditContext = {
+ email,
+ ip: getClientIp(req),
+ method,
+ route: '/v1/projects/' + ref + '/custom-hostname/initialize',
+ }
+ await emitInitializeAudit(
+ pool,
+ profileId,
+ project.organization_id,
+ gotrueId,
+ ref,
+ hostname,
+ auditContext,
+ )
+
+ return Response.json(toCustomHostnameResponse(row), { headers: corsHeaders })
+ }
+
+ // POST /{ref}/custom-hostname/activate — self-hosted doesn't control DNS.
+ if (action === '/activate') {
+ if (method !== 'POST') return methodNotAllowedResponse()
+ return notSupportedResponse()
+ }
+
+ // POST /{ref}/custom-hostname/reverify — same reason as /activate.
+ if (action === '/reverify') {
+ if (method !== 'POST') return methodNotAllowedResponse()
+ return notSupportedResponse()
+ }
+
+ return notFoundResponse()
+}
diff --git a/traffic-one/functions/routes/database-migrations.ts b/traffic-one/functions/routes/database-migrations.ts
new file mode 100644
index 0000000000000..2f64678bc7b07
--- /dev/null
+++ b/traffic-one/functions/routes/database-migrations.ts
@@ -0,0 +1,137 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import { getProjectByRef } from '../services/project.service.ts'
+import { insertMigration, listMigrations } from '../services/schema-migrations.service.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+import { assertValidRef } from '../utils/ref-validation.ts'
+
+function notFoundResponse(message = 'Not Found'): Response {
+ return Response.json({ message }, { status: 404, headers: corsHeaders })
+}
+
+function invalidBodyResponse(message = 'Invalid request body'): Response {
+ return Response.json({ message }, { status: 400, headers: corsHeaders })
+}
+
+function methodNotAllowedResponse(): Response {
+ return Response.json({ message: 'Method not allowed' }, { status: 405, headers: corsHeaders })
+}
+
+function generateVersionTimestamp(): string {
+ const now = new Date()
+ const pad = (n: number) => n.toString().padStart(2, '0')
+ return (
+ now.getUTCFullYear().toString() +
+ pad(now.getUTCMonth() + 1) +
+ pad(now.getUTCDate()) +
+ pad(now.getUTCHours()) +
+ pad(now.getUTCMinutes()) +
+ pad(now.getUTCSeconds())
+ )
+}
+
+// ── Handler for /{ref}/database/migrations ────────────────
+
+export async function handleDatabaseMigrations(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+): Promise {
+ const match = path.match(/^\/([^/]+)\/database\/migrations\/?$/)
+ if (!match) {
+ return notFoundResponse()
+ }
+ const ref = match[1]
+
+ // L4: malformed ref → 400 before DB lookup.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) {
+ return notFoundResponse('Project not found')
+ }
+
+ if (method === 'GET') {
+ const migrations = await listMigrations(pool, ref)
+ return Response.json(migrations, { headers: corsHeaders })
+ }
+
+ if (method === 'PUT') {
+ let body: Record
+ try {
+ body = await req.json()
+ } catch {
+ return invalidBodyResponse('Body must be valid JSON')
+ }
+
+ const statements = extractStatements(body)
+ if (statements.length === 0) {
+ return invalidBodyResponse('query (string) or statements (string[]) is required')
+ }
+
+ const idempotencyKey = req.headers.get('Idempotency-Key') ?? undefined
+ const version = extractVersion(body, idempotencyKey)
+ const name = typeof body.name === 'string' ? body.name : ''
+
+ const ip = getClientIp(req)
+ const auditContext = {
+ email,
+ ip,
+ method,
+ route: '/v1/projects/' + ref + '/database/migrations',
+ }
+
+ const outcome = await insertMigration(
+ pool,
+ ref,
+ version,
+ name,
+ statements,
+ profileId,
+ project.organization_id,
+ gotrueId,
+ auditContext,
+ )
+
+ if (outcome.status === 'conflict') {
+ return Response.json(
+ {
+ code: 'conflict',
+ message: 'Migration version already exists',
+ migration: outcome.migration,
+ },
+ { status: 409, headers: corsHeaders },
+ )
+ }
+
+ return Response.json(outcome.migration, { status: 201, headers: corsHeaders })
+ }
+
+ return methodNotAllowedResponse()
+}
+
+function extractStatements(body: Record): string[] {
+ if (Array.isArray(body.statements)) {
+ return body.statements.filter((s): s is string => typeof s === 'string')
+ }
+ if (typeof body.query === 'string' && body.query.trim().length > 0) {
+ return [body.query]
+ }
+ return []
+}
+
+function extractVersion(body: Record, idempotencyKey: string | undefined): string {
+ if (typeof body.version === 'string' && body.version.trim().length > 0) {
+ return body.version.trim()
+ }
+ if (idempotencyKey && idempotencyKey.trim().length > 0) {
+ return idempotencyKey.trim()
+ }
+ return generateVersionTimestamp()
+}
diff --git a/traffic-one/functions/routes/edge-function-mutations.ts b/traffic-one/functions/routes/edge-function-mutations.ts
new file mode 100644
index 0000000000000..779bb0a28a507
--- /dev/null
+++ b/traffic-one/functions/routes/edge-function-mutations.ts
@@ -0,0 +1,579 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import {
+ deleteRemoteFunction,
+ deployRemoteFunction,
+ type FunctionMeta,
+ FUNCTIONS_DIR,
+ loadFunctionMeta,
+ parseFunctionDir,
+ patchRemoteFunction,
+} from '../services/edge-functions.service.ts'
+import {
+ getProjectBackend,
+ isSharedStack,
+ type ProjectBackend,
+ ProjectBackendNotProvisionedError,
+} from '../services/project-backend.service.ts'
+import { getProjectByRef } from '../services/project.service.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+import { notProvisionedResponse } from '../utils/project-backend-response.ts'
+import { assertValidRef } from '../utils/ref-validation.ts'
+
+// ── Constants ──────────────────────────────────────────────
+//
+// L4: `FUNCTIONS_DIR`, `FunctionEntry`, `FunctionMeta`, `parseFunctionDir`,
+// and `loadFunctionMeta` are imported from
+// `services/edge-functions.service.ts`. The read handlers in
+// routes/projects.ts use the same helpers, which means a function returned
+// by GET and the same function after a PATCH now go through a single
+// implementation — no more silent drift between the two copies.
+
+const RESERVED_SLUGS = new Set(['main', 'traffic-one'])
+const SLUG_PATTERN = /^[a-z0-9_-]+$/
+const FS_READONLY_MESSAGE = 'Functions directory is not writable'
+
+interface AuditParams {
+ profileId: number
+ organizationId: number
+ gotrueId: string
+ email: string
+ ip: string
+ method: string
+ route: string
+ status: number
+ action: string
+ target: string
+}
+
+interface RequestContext {
+ profileId: number
+ gotrueId: string
+ email: string
+ ip: string
+ method: string
+}
+
+// ── Response helpers ───────────────────────────────────────
+
+function notFoundResponse(message = 'Not found'): Response {
+ return Response.json({ message }, { status: 404, headers: corsHeaders })
+}
+
+function methodNotAllowedResponse(): Response {
+ return Response.json({ message: 'Method not allowed' }, { status: 405, headers: corsHeaders })
+}
+
+function badRequestResponse(message: string, code?: string): Response {
+ const body: Record = { message }
+ if (code) body.code = code
+ return Response.json(body, { status: 400, headers: corsHeaders })
+}
+
+function reservedSlugResponse(): Response {
+ return Response.json(
+ { code: 'reserved_slug', message: 'This slug is reserved' },
+ { status: 403, headers: corsHeaders },
+ )
+}
+
+function invalidSlugResponse(): Response {
+ return badRequestResponse('Slug must match /^[a-z0-9_-]+$/', 'invalid_slug')
+}
+
+function fsReadonlyResponse(): Response {
+ return Response.json(
+ { code: 'fs_readonly', message: FS_READONLY_MESSAGE },
+ { status: 503, headers: corsHeaders },
+ )
+}
+
+// ── FS writability probe (cached per-process) ──────────────
+
+let fsWritableCache: boolean | null = null
+
+async function isFunctionsDirWritable(): Promise {
+ if (fsWritableCache !== null) return fsWritableCache
+ try {
+ const stat = await Deno.stat(FUNCTIONS_DIR)
+ if (!stat.isDirectory) {
+ fsWritableCache = false
+ return false
+ }
+ const marker = `${FUNCTIONS_DIR}/.traffic-one-write-probe-${crypto.randomUUID()}`
+ await Deno.writeTextFile(marker, 'probe')
+ await Deno.remove(marker).catch(() => undefined)
+ fsWritableCache = true
+ return true
+ } catch {
+ fsWritableCache = false
+ return false
+ }
+}
+
+function isReadonlyFsError(err: unknown): boolean {
+ if (err instanceof Deno.errors.PermissionDenied) return true
+ const code = (err as { code?: string })?.code
+ return code === 'EROFS' || code === 'EACCES'
+}
+
+// ── Meta sidecar IO ────────────────────────────────────────
+//
+// L4: `loadFunctionMeta` (formerly local `loadMeta`) is imported from the
+// shared service. `writeMeta` stays local because the mutation side is the
+// only caller that persists `.meta.json`.
+
+async function writeMeta(slug: string, meta: FunctionMeta): Promise {
+ const path = `${FUNCTIONS_DIR}/${slug}/.meta.json`
+ await Deno.writeTextFile(path, JSON.stringify(meta, null, 2))
+}
+
+// ── Audit logging ──────────────────────────────────────────
+
+async function writeAudit(pool: Pool, params: AuditParams): Promise {
+ const connection = await pool.connect()
+ try {
+ await connection.queryObject`
+ INSERT INTO traffic.audit_logs (
+ id, profile_id, organization_id, action_name, action_metadata,
+ actor_id, actor_type, actor_metadata,
+ target_description, target_metadata, occurred_at
+ ) VALUES (
+ gen_random_uuid(), ${params.profileId}, ${params.organizationId}, ${params.action},
+ ${
+ JSON.stringify([
+ { method: params.method, route: params.route, status: params.status },
+ ])
+ }::jsonb,
+ ${params.gotrueId}, 'user',
+ ${JSON.stringify([{ email: params.email, ip: params.ip }])}::jsonb,
+ ${params.target}, '{}'::jsonb, now()
+ )
+ `
+ } finally {
+ connection.release()
+ }
+}
+
+// ── Filename safety ────────────────────────────────────────
+
+function sanitizeFilename(name: string): string | null {
+ if (!name || name.length === 0) return null
+ if (name.includes('..') || name.includes('/') || name.includes('\\')) return null
+ // Reserve the dotfile namespace for the `.meta.json` sidecar.
+ if (name.startsWith('.')) return null
+ return name
+}
+
+function auditTarget(ref: string, slug: string): string {
+ return `edge_function ${slug} (project: ${ref})`
+}
+
+// ── Deploy body parsing ────────────────────────────────────
+
+interface DeployInput {
+ slug?: string
+ name?: string
+ verify_jwt?: boolean
+ entrypoint_path?: string
+ import_map_path?: string
+ files: Array<{ name: string; content: string }>
+}
+
+function coerceBool(value: unknown): boolean | undefined {
+ if (typeof value === 'boolean') return value
+ if (typeof value === 'string') {
+ if (value === 'true' || value === '1') return true
+ if (value === 'false' || value === '0') return false
+ }
+ return undefined
+}
+
+async function parseDeployBody(req: Request): Promise {
+ const contentType = req.headers.get('content-type') ?? ''
+ const input: DeployInput = { files: [] }
+
+ if (contentType.includes('multipart/form-data')) {
+ let formData: FormData
+ try {
+ formData = await req.formData()
+ } catch {
+ return badRequestResponse('Invalid multipart body')
+ }
+
+ const slugField = formData.get('slug')
+ if (typeof slugField === 'string') input.slug = slugField
+ const nameField = formData.get('name')
+ if (typeof nameField === 'string') input.name = nameField
+ const verifyJwt = coerceBool(formData.get('verify_jwt'))
+ if (verifyJwt !== undefined) input.verify_jwt = verifyJwt
+ const entrypointField = formData.get('entrypoint_path')
+ if (typeof entrypointField === 'string') input.entrypoint_path = entrypointField
+ const importMapField = formData.get('import_map_path')
+ if (typeof importMapField === 'string') input.import_map_path = importMapField
+
+ for (const fieldName of ['file', 'files']) {
+ for (const entry of formData.getAll(fieldName)) {
+ if (entry instanceof File) {
+ const filename = entry.name || 'index.ts'
+ input.files.push({ name: filename, content: await entry.text() })
+ }
+ }
+ }
+
+ return input
+ }
+
+ let body: Record | null
+ try {
+ body = (await req.json()) as Record
+ } catch {
+ return badRequestResponse('Invalid JSON body')
+ }
+ if (!body || typeof body !== 'object') {
+ return badRequestResponse('Invalid body')
+ }
+
+ if (typeof body.slug === 'string') input.slug = body.slug
+ if (typeof body.name === 'string') input.name = body.name
+ if (typeof body.verify_jwt === 'boolean') input.verify_jwt = body.verify_jwt
+ if (typeof body.entrypoint_path === 'string') input.entrypoint_path = body.entrypoint_path
+ if (typeof body.import_map_path === 'string') input.import_map_path = body.import_map_path
+
+ const rawFiles = Array.isArray(body.body)
+ ? body.body
+ : Array.isArray(body.files)
+ ? body.files
+ : []
+ for (const f of rawFiles as unknown[]) {
+ if (f && typeof f === 'object') {
+ const entry = f as { name?: unknown; content?: unknown }
+ if (typeof entry.name === 'string' && typeof entry.content === 'string') {
+ input.files.push({ name: entry.name, content: entry.content })
+ }
+ }
+ }
+
+ return input
+}
+
+// ── Sub-handlers ───────────────────────────────────────────
+
+async function handleDeploy(
+ req: Request,
+ pool: Pool,
+ project: { id: number; ref: string; organization_id: number },
+ backend: ProjectBackend,
+ ctx: RequestContext,
+): Promise {
+ const parsed = await parseDeployBody(req)
+ if (parsed instanceof Response) return parsed
+
+ const { slug, name, verify_jwt, entrypoint_path, import_map_path, files } = parsed
+
+ if (!slug) return badRequestResponse('slug is required')
+ if (!SLUG_PATTERN.test(slug)) return invalidSlugResponse()
+ if (RESERVED_SLUGS.has(slug)) return reservedSlugResponse()
+ if (files.length === 0) return badRequestResponse('at least one file is required')
+ for (const file of files) {
+ if (!sanitizeFilename(file.name)) {
+ return badRequestResponse(`invalid filename: ${file.name}`)
+ }
+ }
+
+ // Per-project path: proxy to the project's runtime over HTTPS. The
+ // orchestrator-owned service owns the filesystem, so we never touch disk
+ // here. Audit on success.
+ if (!isSharedStack(backend)) {
+ const result = await deployRemoteFunction(backend, {
+ slug,
+ name,
+ verify_jwt,
+ entrypoint_path,
+ import_map_path,
+ files,
+ })
+ if (result.ok !== true) {
+ return Response.json(
+ { message: result.message },
+ { status: result.status, headers: corsHeaders },
+ )
+ }
+ await writeAudit(pool, {
+ profileId: ctx.profileId,
+ organizationId: project.organization_id,
+ gotrueId: ctx.gotrueId,
+ email: ctx.email,
+ ip: ctx.ip,
+ method: ctx.method,
+ route: `/v1/projects/${project.ref}/functions/deploy`,
+ status: 201,
+ action: 'project.edge_function_deployed',
+ target: auditTarget(project.ref, slug),
+ }).catch((err) => console.error('edge_function_deployed audit insert failed:', err))
+ return Response.json(result.entry, { status: 201, headers: corsHeaders })
+ }
+
+ // Shared-stack path: traffic-one owns the filesystem mount and writes
+ // directly. This is the local Docker / single-tenant mode.
+ if (!(await isFunctionsDirWritable())) return fsReadonlyResponse()
+
+ const dir = `${FUNCTIONS_DIR}/${slug}`
+
+ try {
+ await Deno.mkdir(dir, { recursive: true })
+ for (const file of files) {
+ const safeName = sanitizeFilename(file.name)
+ if (!safeName) return badRequestResponse(`invalid filename: ${file.name}`)
+ await Deno.writeTextFile(`${dir}/${safeName}`, file.content)
+ }
+
+ const existingMeta = await loadFunctionMeta(slug)
+ const meta: FunctionMeta = { ...existingMeta }
+ if (name !== undefined) meta.name = name
+ if (verify_jwt !== undefined) meta.verify_jwt = verify_jwt
+ if (entrypoint_path !== undefined) meta.entrypoint_path = entrypoint_path
+ if (import_map_path !== undefined) meta.import_map_path = import_map_path
+ await writeMeta(slug, meta)
+
+ // NOTE: We intentionally do NOT call Deno.reload() here. The edge-runtime
+ // service (`supabase-edge-functions`) picks up new function directories on
+ // the next cold start of the request handler; there is no supported
+ // hot-reload signal, and forcing a process-level reload would interrupt
+ // other in-flight function invocations. Document this contract so Studio
+ // users know to invoke the function once to warm the new version.
+
+ const entry = await parseFunctionDir(slug, meta)
+
+ await writeAudit(pool, {
+ profileId: ctx.profileId,
+ organizationId: project.organization_id,
+ gotrueId: ctx.gotrueId,
+ email: ctx.email,
+ ip: ctx.ip,
+ method: ctx.method,
+ route: `/v1/projects/${project.ref}/functions/deploy`,
+ status: 201,
+ action: 'project.edge_function_deployed',
+ target: auditTarget(project.ref, slug),
+ }).catch((err) => console.error('edge_function_deployed audit insert failed:', err))
+
+ return Response.json(entry ?? { slug, name: name ?? slug }, {
+ status: 201,
+ headers: corsHeaders,
+ })
+ } catch (err) {
+ if (isReadonlyFsError(err)) return fsReadonlyResponse()
+ console.error('edge function deploy error:', err)
+ return Response.json(
+ { message: 'Failed to deploy function' },
+ { status: 500, headers: corsHeaders },
+ )
+ }
+}
+
+async function handlePatch(
+ req: Request,
+ slug: string,
+ pool: Pool,
+ project: { id: number; ref: string; organization_id: number },
+ backend: ProjectBackend,
+ ctx: RequestContext,
+): Promise {
+ if (!SLUG_PATTERN.test(slug)) return invalidSlugResponse()
+ if (RESERVED_SLUGS.has(slug)) return reservedSlugResponse()
+
+ let body: Record
+ try {
+ body = (await req.json()) as Record
+ } catch {
+ return badRequestResponse('Invalid JSON body')
+ }
+ if (!body || typeof body !== 'object') {
+ return badRequestResponse('Invalid body')
+ }
+
+ const updates: FunctionMeta = {}
+ if (typeof body.name === 'string') updates.name = body.name
+ if (typeof body.verify_jwt === 'boolean') updates.verify_jwt = body.verify_jwt
+ if (typeof body.entrypoint_path === 'string') updates.entrypoint_path = body.entrypoint_path
+ if (typeof body.import_map_path === 'string') updates.import_map_path = body.import_map_path
+
+ if (!isSharedStack(backend)) {
+ const entry = await patchRemoteFunction(backend, slug, updates)
+ if (!entry) return notFoundResponse('Function not found')
+ await writeAudit(pool, {
+ profileId: ctx.profileId,
+ organizationId: project.organization_id,
+ gotrueId: ctx.gotrueId,
+ email: ctx.email,
+ ip: ctx.ip,
+ method: ctx.method,
+ route: `/v1/projects/${project.ref}/functions/${slug}`,
+ status: 200,
+ action: 'project.edge_function_updated',
+ target: auditTarget(project.ref, slug),
+ }).catch((err) => console.error('edge_function_updated audit insert failed:', err))
+ return Response.json(entry, { headers: corsHeaders })
+ }
+
+ if (!(await isFunctionsDirWritable())) return fsReadonlyResponse()
+
+ const dir = `${FUNCTIONS_DIR}/${slug}`
+ try {
+ const stat = await Deno.stat(dir)
+ if (!stat.isDirectory) return notFoundResponse('Function not found')
+ } catch {
+ return notFoundResponse('Function not found')
+ }
+
+ const existing = await loadFunctionMeta(slug)
+ const merged: FunctionMeta = { ...existing, ...updates }
+
+ try {
+ await writeMeta(slug, merged)
+ } catch (err) {
+ if (isReadonlyFsError(err)) return fsReadonlyResponse()
+ throw err
+ }
+
+ const entry = await parseFunctionDir(slug, merged)
+ if (!entry) return notFoundResponse('Function not found')
+
+ await writeAudit(pool, {
+ profileId: ctx.profileId,
+ organizationId: project.organization_id,
+ gotrueId: ctx.gotrueId,
+ email: ctx.email,
+ ip: ctx.ip,
+ method: ctx.method,
+ route: `/v1/projects/${project.ref}/functions/${slug}`,
+ status: 200,
+ action: 'project.edge_function_updated',
+ target: auditTarget(project.ref, slug),
+ }).catch((err) => console.error('edge_function_updated audit insert failed:', err))
+
+ return Response.json(entry, { headers: corsHeaders })
+}
+
+async function handleDelete(
+ slug: string,
+ pool: Pool,
+ project: { id: number; ref: string; organization_id: number },
+ backend: ProjectBackend,
+ ctx: RequestContext,
+): Promise {
+ if (!SLUG_PATTERN.test(slug)) return invalidSlugResponse()
+ if (RESERVED_SLUGS.has(slug)) return reservedSlugResponse()
+
+ if (!isSharedStack(backend)) {
+ const ok = await deleteRemoteFunction(backend, slug)
+ if (!ok) return notFoundResponse('Function not found')
+ await writeAudit(pool, {
+ profileId: ctx.profileId,
+ organizationId: project.organization_id,
+ gotrueId: ctx.gotrueId,
+ email: ctx.email,
+ ip: ctx.ip,
+ method: ctx.method,
+ route: `/v1/projects/${project.ref}/functions/${slug}`,
+ status: 200,
+ action: 'project.edge_function_deleted',
+ target: auditTarget(project.ref, slug),
+ }).catch((err) => console.error('edge_function_deleted audit insert failed:', err))
+ return Response.json({ slug, deleted: true }, { headers: corsHeaders })
+ }
+
+ if (!(await isFunctionsDirWritable())) return fsReadonlyResponse()
+
+ const dir = `${FUNCTIONS_DIR}/${slug}`
+ try {
+ const stat = await Deno.stat(dir)
+ if (!stat.isDirectory) return notFoundResponse('Function not found')
+ } catch {
+ return notFoundResponse('Function not found')
+ }
+
+ try {
+ await Deno.remove(dir, { recursive: true })
+ } catch (err) {
+ if (err instanceof Deno.errors.NotFound) {
+ return notFoundResponse('Function not found')
+ }
+ if (isReadonlyFsError(err)) return fsReadonlyResponse()
+ throw err
+ }
+
+ await writeAudit(pool, {
+ profileId: ctx.profileId,
+ organizationId: project.organization_id,
+ gotrueId: ctx.gotrueId,
+ email: ctx.email,
+ ip: ctx.ip,
+ method: ctx.method,
+ route: `/v1/projects/${project.ref}/functions/${slug}`,
+ status: 200,
+ action: 'project.edge_function_deleted',
+ target: auditTarget(project.ref, slug),
+ }).catch((err) => console.error('edge_function_deleted audit insert failed:', err))
+
+ return Response.json({ slug, deleted: true }, { headers: corsHeaders })
+}
+
+// ── Main dispatcher ────────────────────────────────────────
+
+// Parent routes PATCH/DELETE `/{ref}/functions/{slug}` and POST
+// `/{ref}/functions/deploy` here. GET handlers remain in projects.ts.
+export async function handleEdgeFunctionMutations(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+): Promise {
+ const deployMatch = path.match(/^\/([^/]+)\/functions\/deploy\/?$/)
+ const slugMatch = path.match(/^\/([^/]+)\/functions\/([^/]+)\/?$/)
+
+ if (!deployMatch && !slugMatch) {
+ return notFoundResponse()
+ }
+
+ const ref = (deployMatch ?? slugMatch)![1]
+
+ // L4: malformed ref → 400 before we touch the DB, the backend resolver,
+ // or the functions filesystem. Applies to both `/deploy` and `/{slug}`.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) {
+ return notFoundResponse('Project not found')
+ }
+
+ let backend: ProjectBackend
+ try {
+ backend = await getProjectBackend(ref, pool)
+ } catch (err) {
+ if (err instanceof ProjectBackendNotProvisionedError) {
+ return notProvisionedResponse(err)
+ }
+ throw err
+ }
+
+ const ip = getClientIp(req)
+ const ctx: RequestContext = { profileId, gotrueId, email, ip, method }
+
+ if (deployMatch) {
+ if (method !== 'POST') return methodNotAllowedResponse()
+ return handleDeploy(req, pool, project, backend, ctx)
+ }
+
+ const slug = slugMatch![2]
+
+ if (method === 'PATCH') return handlePatch(req, slug, pool, project, backend, ctx)
+ if (method === 'DELETE') return handleDelete(slug, pool, project, backend, ctx)
+ return methodNotAllowedResponse()
+}
diff --git a/traffic-one/functions/routes/feedback.ts b/traffic-one/functions/routes/feedback.ts
new file mode 100644
index 0000000000000..b24383fb2fbd5
--- /dev/null
+++ b/traffic-one/functions/routes/feedback.ts
@@ -0,0 +1,185 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import {
+ createFeedback,
+ type FeedbackAuditContext,
+ type FeedbackCategory,
+ type FeedbackCreateInput,
+ updateFeedbackCustomFields,
+} from '../services/feedback.service.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+
+function pickString(body: Record, ...keys: string[]): string | undefined {
+ for (const key of keys) {
+ const value = body[key]
+ if (typeof value === 'string' && value.length > 0) return value
+ }
+ return undefined
+}
+
+function pickStringArray(body: Record, ...keys: string[]): string[] | undefined {
+ for (const key of keys) {
+ const value = body[key]
+ if (Array.isArray(value) && value.every((v) => typeof v === 'string')) {
+ return value as string[]
+ }
+ }
+ return undefined
+}
+
+function buildMetadata(body: Record, omit: Set): Record {
+ const metadata: Record = {}
+ for (const [key, value] of Object.entries(body)) {
+ if (omit.has(key)) continue
+ metadata[key] = value
+ }
+ return metadata
+}
+
+async function insertAndRespond(
+ pool: Pool,
+ profileId: number,
+ input: FeedbackCreateInput,
+ gotrueId: string,
+ auditContext: FeedbackAuditContext,
+): Promise {
+ const row = await createFeedback(pool, profileId, input, gotrueId, auditContext)
+ return Response.json(
+ { id: row.id, created_at: row.created_at },
+ { status: 201, headers: corsHeaders },
+ )
+}
+
+async function handleSend(
+ req: Request,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ auditContext: FeedbackAuditContext,
+): Promise {
+ const body = (await req.json().catch(() => ({}))) as Record
+ const message = pickString(body, 'message')
+ if (!message) {
+ return Response.json({ message: 'message is required' }, { status: 400, headers: corsHeaders })
+ }
+ return insertAndRespond(
+ pool,
+ profileId,
+ {
+ category: 'general',
+ message,
+ projectRef: pickString(body, 'projectRef', 'project_ref'),
+ organizationSlug: pickString(body, 'organizationSlug', 'orgSlug', 'organization_slug'),
+ tags: pickStringArray(body, 'tags'),
+ metadata: buildMetadata(
+ body,
+ new Set([
+ 'message',
+ 'projectRef',
+ 'project_ref',
+ 'organizationSlug',
+ 'orgSlug',
+ 'organization_slug',
+ 'tags',
+ ]),
+ ),
+ },
+ gotrueId,
+ auditContext,
+ )
+}
+
+async function handleSurvey(
+ req: Request,
+ pool: Pool,
+ profileId: number,
+ category: Extract,
+ gotrueId: string,
+ auditContext: FeedbackAuditContext,
+): Promise {
+ const body = (await req.json().catch(() => ({}))) as Record
+ const message = pickString(body, 'message', 'additionalFeedback')
+ if (!message) {
+ return Response.json({ message: 'message is required' }, { status: 400, headers: corsHeaders })
+ }
+ return insertAndRespond(
+ pool,
+ profileId,
+ {
+ category,
+ message,
+ projectRef: pickString(body, 'projectRef', 'project_ref'),
+ organizationSlug: pickString(body, 'organizationSlug', 'orgSlug', 'organization_slug'),
+ metadata: buildMetadata(
+ body,
+ new Set([
+ 'message',
+ 'additionalFeedback',
+ 'projectRef',
+ 'project_ref',
+ 'organizationSlug',
+ 'orgSlug',
+ 'organization_slug',
+ ]),
+ ),
+ },
+ gotrueId,
+ auditContext,
+ )
+}
+
+export async function handleFeedback(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+): Promise {
+ const ip = getClientIp(req)
+ const auditContext: FeedbackAuditContext = { email, ip, method, route: '/feedback' + path }
+
+ if (method === 'POST' && path === '/send') {
+ return handleSend(req, pool, profileId, gotrueId, auditContext)
+ }
+
+ if (method === 'POST' && path === '/upgrade') {
+ return handleSurvey(req, pool, profileId, 'upgrade_survey', gotrueId, auditContext)
+ }
+
+ if (method === 'POST' && path === '/downgrade') {
+ return handleSurvey(req, pool, profileId, 'downgrade_survey', gotrueId, auditContext)
+ }
+
+ const customFieldsMatch = path.match(/^\/conversations\/([^/]+)\/custom-fields$/)
+ if (method === 'PATCH' && customFieldsMatch) {
+ const rawId = customFieldsMatch[1]
+ const id = Number.parseInt(rawId, 10)
+ if (!Number.isInteger(id) || String(id) !== rawId) {
+ return Response.json(
+ { message: 'Conversation not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ const body = (await req.json().catch(() => ({}))) as Record
+ const updated = await updateFeedbackCustomFields(
+ pool,
+ id,
+ profileId,
+ body,
+ gotrueId,
+ auditContext,
+ )
+ if (!updated) {
+ return Response.json(
+ { message: 'Conversation not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json({ id: updated.id }, { headers: corsHeaders })
+ }
+
+ return Response.json({ message: 'Method not allowed' }, { status: 405, headers: corsHeaders })
+}
diff --git a/traffic-one/functions/routes/jit.ts b/traffic-one/functions/routes/jit.ts
new file mode 100644
index 0000000000000..d17ee8343645f
--- /dev/null
+++ b/traffic-one/functions/routes/jit.ts
@@ -0,0 +1,215 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import {
+ getPolicy,
+ issueGrant,
+ type IssueGrantInput,
+ type JitPolicy,
+ type JitScope,
+ listGrants,
+ revokeGrant,
+ upsertPolicy,
+} from '../services/jit.service.ts'
+import {
+ getProjectBackend,
+ type ProjectBackend,
+ ProjectBackendNotProvisionedError,
+} from '../services/project-backend.service.ts'
+import { getProjectByRef } from '../services/project.service.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+import { notProvisionedResponse } from '../utils/project-backend-response.ts'
+import { assertValidRef } from '../utils/ref-validation.ts'
+
+// ── Response helpers ─────────────────────────────────────────
+
+function notFoundResponse(message = 'Not Found'): Response {
+ return Response.json({ message }, { status: 404, headers: corsHeaders })
+}
+
+function methodNotAllowedResponse(): Response {
+ return Response.json({ message: 'Method not allowed' }, { status: 405, headers: corsHeaders })
+}
+
+function invalidBodyResponse(message: string): Response {
+ return Response.json({ message }, { status: 400, headers: corsHeaders })
+}
+
+// ── Body normalizers ─────────────────────────────────────────
+
+function normalizePolicyPatch(body: Record): Partial {
+ const patch: Partial = {}
+ if (typeof body.enabled === 'boolean') {
+ patch.enabled = body.enabled
+ }
+ if (
+ typeof body.max_session_duration_minutes === 'number' &&
+ Number.isFinite(body.max_session_duration_minutes) &&
+ body.max_session_duration_minutes > 0
+ ) {
+ patch.max_session_duration_minutes = Math.floor(body.max_session_duration_minutes)
+ }
+ if (typeof body.approval_required === 'boolean') {
+ patch.approval_required = body.approval_required
+ }
+ if (body.default_scope === 'read-only' || body.default_scope === 'read-write') {
+ patch.default_scope = body.default_scope
+ }
+ return patch
+}
+
+function normalizeIssueInput(body: Record): IssueGrantInput {
+ const input: IssueGrantInput = {}
+ if (typeof body.user_id === 'number' && Number.isInteger(body.user_id)) {
+ input.user_id = body.user_id
+ }
+ if (
+ typeof body.duration_minutes === 'number' &&
+ Number.isFinite(body.duration_minutes) &&
+ body.duration_minutes > 0
+ ) {
+ input.duration_minutes = Math.floor(body.duration_minutes)
+ }
+ if (body.scope === 'read-only' || body.scope === 'read-write') {
+ input.scope = body.scope as JitScope
+ }
+ if (Array.isArray(body.tables)) {
+ input.tables = body.tables.filter((t): t is string => typeof t === 'string')
+ }
+ return input
+}
+
+// ── Handler ─────────────────────────────────────────────────
+
+export async function handleJit(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+): Promise {
+ // Extract ref from path: /{ref}/jit-access, /{ref}/database/jit[/...]
+ const match = path.match(/^\/([^/]+)(\/.+)$/)
+ if (!match) {
+ return notFoundResponse()
+ }
+ const ref = match[1]
+ const subPath = match[2]
+
+ // L4: malformed ref → 400 before DB lookup.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) {
+ return notFoundResponse('Project not found')
+ }
+
+ const ip = getClientIp(req)
+ const auditContext = {
+ email,
+ ip,
+ method,
+ route: '/v1/projects/' + ref + subPath,
+ organizationId: project.organization_id,
+ }
+
+ // ── /jit-access ───────────────────────────────────────────
+ if (subPath === '/jit-access') {
+ if (method === 'GET') {
+ const policy = await getPolicy(pool, ref)
+ return Response.json(policy, { headers: corsHeaders })
+ }
+
+ if (method === 'PUT') {
+ let body: Record
+ try {
+ body = (await req.json()) as Record
+ } catch {
+ body = {}
+ }
+ const patch = normalizePolicyPatch(body)
+ const policy = await upsertPolicy(pool, ref, patch, profileId, gotrueId, auditContext)
+ return Response.json(policy, { headers: corsHeaders })
+ }
+
+ return methodNotAllowedResponse()
+ }
+
+ // ── /database/jit/list ────────────────────────────────────
+ if (subPath === '/database/jit/list') {
+ if (method === 'GET') {
+ const grants = await listGrants(pool, ref)
+ return Response.json(grants, { headers: corsHeaders })
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // Resolve backend lazily — only the mutation paths need it. GET /list and
+ // GET /jit-access only touch `traffic.jit_*` so skip the Vault lookup.
+ async function resolveBackend(): Promise {
+ try {
+ return await getProjectBackend(ref, pool)
+ } catch (err) {
+ if (err instanceof ProjectBackendNotProvisionedError) {
+ return notProvisionedResponse(err)
+ }
+ throw err
+ }
+ }
+
+ // ── /database/jit ────────────────────────────────────────
+ if (subPath === '/database/jit') {
+ if (method === 'PUT' || method === 'POST') {
+ let body: Record
+ try {
+ body = (await req.json()) as Record
+ } catch {
+ body = {}
+ }
+ const input = normalizeIssueInput(body)
+ const backendOrResponse = await resolveBackend()
+ if (backendOrResponse instanceof Response) return backendOrResponse
+ const result = await issueGrant(
+ pool,
+ ref,
+ backendOrResponse,
+ input,
+ profileId,
+ gotrueId,
+ auditContext,
+ )
+ return Response.json(result, { status: 201, headers: corsHeaders })
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── /database/jit/{user_id} ──────────────────────────────
+ const revokeMatch = subPath.match(/^\/database\/jit\/([^/]+)$/)
+ if (revokeMatch) {
+ if (method !== 'DELETE') {
+ return methodNotAllowedResponse()
+ }
+ const rawUserId = revokeMatch[1]
+ const userId = Number.parseInt(rawUserId, 10)
+ if (!Number.isInteger(userId) || String(userId) !== rawUserId) {
+ return invalidBodyResponse('user_id must be an integer')
+ }
+ const backendOrResponse = await resolveBackend()
+ if (backendOrResponse instanceof Response) return backendOrResponse
+ const result = await revokeGrant(
+ pool,
+ ref,
+ backendOrResponse,
+ userId,
+ profileId,
+ gotrueId,
+ auditContext,
+ )
+ return Response.json(result, { headers: corsHeaders })
+ }
+
+ return notFoundResponse()
+}
diff --git a/traffic-one/functions/routes/members.ts b/traffic-one/functions/routes/members.ts
new file mode 100644
index 0000000000000..0745ad6bbf16a
--- /dev/null
+++ b/traffic-one/functions/routes/members.ts
@@ -0,0 +1,265 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import {
+ acceptInvitation,
+ assignMemberRole,
+ createInvitation,
+ deleteInvitation,
+ deleteMember,
+ getInvitationByToken,
+ getMemberHighestRoleId,
+ getMembersAtFreeProjectLimit,
+ getMfaEnforcement,
+ listInvitations,
+ listMembers,
+ listRoles,
+ unassignMemberRole,
+ updateMemberRole,
+ updateMfaEnforcement,
+} from '../services/member.service.ts'
+import type {
+ AssignMemberRoleBodyV2,
+ CreateInvitationBody,
+ UpdateMemberRoleBody,
+} from '../types/api.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+
+const ADMIN_ROLE_ID = 4
+
+export async function handleMembers(
+ req: Request,
+ subPath: string,
+ method: string,
+ pool: Pool,
+ orgId: number,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+): Promise {
+ const ip = getClientIp(req)
+ const auditCtx = { email, ip, method, route: '/organizations/*/members' + subPath }
+
+ // GET /roles
+ if (subPath === '/roles' && method === 'GET') {
+ const roles = await listRoles(pool, orgId)
+ return Response.json(roles, { headers: corsHeaders })
+ }
+
+ // Strip /members prefix for sub-routing
+ const memberPath = subPath.startsWith('/members') ? subPath.slice('/members'.length) : subPath
+
+ // GET /members
+ if (memberPath === '' && method === 'GET') {
+ const members = await listMembers(pool, orgId)
+ return Response.json(members, { headers: corsHeaders })
+ }
+
+ // GET /members/reached-free-project-limit
+ if (memberPath === '/reached-free-project-limit' && method === 'GET') {
+ const members = await getMembersAtFreeProjectLimit(pool, orgId)
+ return Response.json(members, { headers: corsHeaders })
+ }
+
+ // GET /members/mfa/enforcement
+ if (memberPath === '/mfa/enforcement' && method === 'GET') {
+ const mfa = await getMfaEnforcement(pool, orgId)
+ return Response.json(mfa, { headers: corsHeaders })
+ }
+
+ // PATCH /members/mfa/enforcement
+ if (memberPath === '/mfa/enforcement' && method === 'PATCH') {
+ const actorRole = await getMemberHighestRoleId(pool, orgId, profileId)
+ if (actorRole < ADMIN_ROLE_ID) {
+ return Response.json({ message: 'Forbidden' }, { status: 403, headers: corsHeaders })
+ }
+ const body = await req.json()
+ const mfa = await updateMfaEnforcement(
+ pool,
+ orgId,
+ body.enforced,
+ profileId,
+ gotrueId,
+ auditCtx,
+ )
+ return Response.json(mfa, { headers: corsHeaders })
+ }
+
+ // GET /members/invitations
+ if (memberPath === '/invitations' && method === 'GET') {
+ const invitations = await listInvitations(pool, orgId)
+ return Response.json(invitations, { headers: corsHeaders })
+ }
+
+ // POST /members/invitations
+ if (memberPath === '/invitations' && method === 'POST') {
+ const actorRole = await getMemberHighestRoleId(pool, orgId, profileId)
+ if (actorRole < ADMIN_ROLE_ID) {
+ return Response.json({ message: 'Forbidden' }, { status: 403, headers: corsHeaders })
+ }
+ const body: CreateInvitationBody = await req.json()
+ if (!body.email || !body.role_id) {
+ return Response.json(
+ { message: 'email and role_id are required' },
+ { status: 400, headers: corsHeaders },
+ )
+ }
+ const result = await createInvitation(pool, orgId, body, profileId, gotrueId, auditCtx)
+ if (result.error) {
+ return Response.json(
+ { message: result.error },
+ { status: result.status ?? 400, headers: corsHeaders },
+ )
+ }
+ return Response.json(result.invitation, { status: 201, headers: corsHeaders })
+ }
+
+ // Invitation by token: GET /members/invitations/{token}
+ const tokenGetMatch = memberPath.match(/^\/invitations\/([0-9a-f-]{36})$/)
+ if (tokenGetMatch && method === 'GET') {
+ const token = tokenGetMatch[1]
+ const info = await getInvitationByToken(pool, token, gotrueId, email)
+ return Response.json(info, { headers: corsHeaders })
+ }
+
+ // Accept invitation: POST /members/invitations/{token}
+ const tokenPostMatch = memberPath.match(/^\/invitations\/([0-9a-f-]{36})$/)
+ if (tokenPostMatch && method === 'POST') {
+ const token = tokenPostMatch[1]
+ const result = await acceptInvitation(pool, token, profileId, gotrueId, auditCtx)
+ if (!result.success) {
+ return Response.json(
+ { message: result.error },
+ { status: result.status ?? 400, headers: corsHeaders },
+ )
+ }
+ return Response.json({ message: 'Invitation accepted' }, { headers: corsHeaders })
+ }
+
+ // Delete invitation: DELETE /members/invitations/{id}
+ const invDeleteMatch = memberPath.match(/^\/invitations\/(\d+)$/)
+ if (invDeleteMatch && method === 'DELETE') {
+ const actorRole = await getMemberHighestRoleId(pool, orgId, profileId)
+ if (actorRole < ADMIN_ROLE_ID) {
+ return Response.json({ message: 'Forbidden' }, { status: 403, headers: corsHeaders })
+ }
+ const invitationId = parseInt(invDeleteMatch[1], 10)
+ const deleted = await deleteInvitation(pool, orgId, invitationId, profileId, gotrueId, auditCtx)
+ if (!deleted) {
+ return Response.json(
+ { message: 'Invitation not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json({ message: 'Invitation deleted' }, { headers: corsHeaders })
+ }
+
+ // Member role operations: /{gotrue_id}/roles/{role_id}
+ const memberRoleMatch = memberPath.match(/^\/([0-9a-f-]{36})\/roles\/(\d+)$/)
+ if (memberRoleMatch) {
+ const targetGotrueId = memberRoleMatch[1]
+ const roleId = parseInt(memberRoleMatch[2], 10)
+
+ if (method === 'PUT') {
+ const actorRole = await getMemberHighestRoleId(pool, orgId, profileId)
+ if (actorRole < ADMIN_ROLE_ID) {
+ return Response.json({ message: 'Forbidden' }, { status: 403, headers: corsHeaders })
+ }
+ const body: UpdateMemberRoleBody = await req.json()
+ const result = await updateMemberRole(
+ pool,
+ orgId,
+ targetGotrueId,
+ roleId,
+ body.role_scoped_projects ?? [],
+ profileId,
+ gotrueId,
+ auditCtx,
+ )
+ if (!result.success) {
+ return Response.json(
+ { message: result.error },
+ { status: result.status ?? 400, headers: corsHeaders },
+ )
+ }
+ return Response.json({ message: 'Role updated' }, { headers: corsHeaders })
+ }
+
+ if (method === 'DELETE') {
+ const actorRole = await getMemberHighestRoleId(pool, orgId, profileId)
+ if (actorRole < ADMIN_ROLE_ID) {
+ return Response.json({ message: 'Forbidden' }, { status: 403, headers: corsHeaders })
+ }
+ const result = await unassignMemberRole(
+ pool,
+ orgId,
+ targetGotrueId,
+ roleId,
+ profileId,
+ gotrueId,
+ auditCtx,
+ )
+ if (!result.success) {
+ return Response.json(
+ { message: result.error },
+ { status: result.status ?? 400, headers: corsHeaders },
+ )
+ }
+ return Response.json({ message: 'Role unassigned' }, { headers: corsHeaders })
+ }
+ }
+
+ // DELETE /members/{gotrue_id}
+ const memberDeleteMatch = memberPath.match(/^\/([0-9a-f-]{36})$/)
+ if (memberDeleteMatch && method === 'DELETE') {
+ const actorRole = await getMemberHighestRoleId(pool, orgId, profileId)
+ if (actorRole < ADMIN_ROLE_ID) {
+ return Response.json({ message: 'Forbidden' }, { status: 403, headers: corsHeaders })
+ }
+ const targetGotrueId = memberDeleteMatch[1]
+ const result = await deleteMember(pool, orgId, targetGotrueId, profileId, gotrueId, auditCtx)
+ if (!result.success) {
+ return Response.json(
+ { message: result.error },
+ { status: result.status ?? 400, headers: corsHeaders },
+ )
+ }
+ return Response.json({ message: 'Member removed' }, { headers: corsHeaders })
+ }
+
+ // PATCH /members/{gotrue_id} (Version 2 - assign role)
+ const memberPatchMatch = memberPath.match(/^\/([0-9a-f-]{36})$/)
+ if (memberPatchMatch && method === 'PATCH') {
+ const actorRole = await getMemberHighestRoleId(pool, orgId, profileId)
+ if (actorRole < ADMIN_ROLE_ID) {
+ return Response.json({ message: 'Forbidden' }, { status: 403, headers: corsHeaders })
+ }
+ const targetGotrueId = memberPatchMatch[1]
+ const body: AssignMemberRoleBodyV2 = await req.json()
+ if (!body.role_id) {
+ return Response.json(
+ { message: 'role_id is required' },
+ { status: 400, headers: corsHeaders },
+ )
+ }
+ const result = await assignMemberRole(
+ pool,
+ orgId,
+ targetGotrueId,
+ body.role_id,
+ body.role_scoped_projects,
+ profileId,
+ gotrueId,
+ auditCtx,
+ )
+ if (!result.success) {
+ return Response.json(
+ { message: result.error },
+ { status: result.status ?? 400, headers: corsHeaders },
+ )
+ }
+ return Response.json({ message: 'Role assigned' }, { headers: corsHeaders })
+ }
+
+ return Response.json({ message: 'Not Found' }, { status: 404, headers: corsHeaders })
+}
diff --git a/traffic-one/functions/routes/notifications.ts b/traffic-one/functions/routes/notifications.ts
new file mode 100644
index 0000000000000..194aa44f5cf61
--- /dev/null
+++ b/traffic-one/functions/routes/notifications.ts
@@ -0,0 +1,129 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import {
+ bulkUpdateNotificationStatus,
+ getSummary,
+ listNotifications,
+ markAllArchived,
+ updateNotificationStatus,
+} from '../services/notification.service.ts'
+import type { NotificationResponse, NotificationStatus } from '../types/api.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+
+export async function handleNotifications(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ gotrueId: string,
+ email: string,
+ profileId: number,
+): Promise {
+ const ip = getClientIp(req)
+ // L1: this handler was originally mounted under `/profile/notifications` and
+ // still logs audit `route` with that legacy prefix. Studio now calls
+ // `/api/platform/notifications/*` directly and Kong strips the `/api/platform`
+ // prefix before delegation, so the incoming `path` already starts with
+ // `/notifications`. Use it verbatim so audit log entries reflect the real
+ // Kong route instead of a stale profile-scoped one.
+ const auditContext = { email, ip, method, route: path }
+
+ if (method === 'GET' && path === '/notifications') {
+ const notifications = await listNotifications(pool, profileId)
+ return Response.json(notifications, { headers: corsHeaders })
+ }
+
+ if (method === 'GET' && path === '/notifications/summary') {
+ const summary = await getSummary(pool, profileId)
+ return Response.json(summary, { headers: corsHeaders })
+ }
+
+ if (method === 'PATCH' && path === '/notifications/archive-all') {
+ const archived = await markAllArchived(pool, profileId, gotrueId, auditContext)
+ return Response.json({ archived_count: archived }, { headers: corsHeaders })
+ }
+
+ if (method === 'PATCH' && path === '/notifications') {
+ const body = await req.json().catch(() => ({}))
+
+ if (Array.isArray(body)) {
+ const byStatus = new Map()
+ for (const entry of body) {
+ if (!entry?.id || !entry?.status) {
+ return Response.json(
+ { message: 'each entry must have id and status' },
+ { status: 400, headers: corsHeaders },
+ )
+ }
+ const group = byStatus.get(entry.status)
+ if (group) {
+ group.push(entry.id)
+ } else {
+ byStatus.set(entry.status, [entry.id])
+ }
+ }
+ const allUpdated: NotificationResponse[] = []
+ for (const [status, ids] of byStatus) {
+ const updated = await bulkUpdateNotificationStatus(
+ pool,
+ profileId,
+ ids,
+ status as NotificationStatus,
+ gotrueId,
+ auditContext,
+ )
+ allUpdated.push(...updated)
+ }
+ return Response.json(allUpdated, { headers: corsHeaders })
+ }
+
+ if (!body.ids || !body.status) {
+ return Response.json(
+ { message: 'ids and status are required' },
+ { status: 400, headers: corsHeaders },
+ )
+ }
+ const updated = await bulkUpdateNotificationStatus(
+ pool,
+ profileId,
+ body.ids,
+ body.status,
+ gotrueId,
+ auditContext,
+ )
+ return Response.json(updated, { headers: corsHeaders })
+ }
+
+ const singleMatch = path.match(/^\/notifications\/([a-f0-9-]+)$/i)
+ if (method === 'PATCH' && singleMatch) {
+ const notifId = singleMatch[1]
+ const body = await req.json().catch(() => ({}))
+ if (!body.status) {
+ return Response.json({ message: 'status is required' }, { status: 400, headers: corsHeaders })
+ }
+ const updated = await updateNotificationStatus(
+ pool,
+ profileId,
+ notifId,
+ body.status,
+ gotrueId,
+ auditContext,
+ )
+ if (!updated) {
+ return Response.json(
+ { message: 'Notification not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json(updated, { headers: corsHeaders })
+ }
+
+ return Response.json(
+ { message: 'Method not allowed' },
+ {
+ status: 405,
+ headers: corsHeaders,
+ },
+ )
+}
diff --git a/traffic-one/functions/routes/organizations.ts b/traffic-one/functions/routes/organizations.ts
new file mode 100644
index 0000000000000..5f8ddf75400db
--- /dev/null
+++ b/traffic-one/functions/routes/organizations.ts
@@ -0,0 +1,537 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import {
+ createSSOProvider,
+ deleteSSOProvider,
+ getOrgAuditLogs,
+ getSSOProvider,
+ updateSSOProvider,
+} from '../services/org-settings.service.ts'
+import {
+ createOrganization,
+ deleteOrganization,
+ generateSlugBase,
+ getOrganizationBySlug,
+ listOrganizations,
+ updateOrganization,
+} from '../services/organization.service.ts'
+import {
+ getProjectBackend,
+ ProjectBackendNotProvisionedError,
+} from '../services/project-backend.service.ts'
+import { getProjectByRef, listOrgProjects } from '../services/project.service.ts'
+import { getOrgDailyUsage, getOrgUsage } from '../services/usage.service.ts'
+import type { CreateOrganizationBody } from '../types/api.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+import { assertValidRef } from '../utils/ref-validation.ts'
+import { handleBilling } from './billing.ts'
+import { handleMembers } from './members.ts'
+
+const COMPLIANCE_DOC_TYPES = new Set([
+ 'standard-security-questionnaire',
+ 'soc2-type-2-report',
+ 'iso27001-certificate',
+])
+
+export async function handleOrganizations(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+): Promise {
+ const ip = getClientIp(req)
+ const auditContext = { email, ip, method, route: '/organizations' + path }
+
+ // GET /organizations — list all user's orgs
+ if (path === '/' && method === 'GET') {
+ const orgs = await listOrganizations(pool, profileId)
+ return Response.json(orgs, { headers: corsHeaders })
+ }
+
+ // POST /organizations — create org
+ if (path === '/' && method === 'POST') {
+ const body: CreateOrganizationBody = await req.json()
+ if (!body.name) {
+ return Response.json({ message: 'name is required' }, { status: 400, headers: corsHeaders })
+ }
+ const org = await createOrganization(pool, profileId, body, gotrueId, auditContext)
+ return Response.json(org, { status: 201, headers: corsHeaders })
+ }
+
+ // POST /organizations/cloud-marketplace — AWS Marketplace managed org creation.
+ // Must come before the slug regex so "cloud-marketplace" isn't parsed as a slug and
+ // bounced with 404 by the normal org lookup. Self-hosted has no marketplace integration;
+ // return a stable stub so Studio's aws-marketplace-onboarding flow renders gracefully.
+ if (path === '/cloud-marketplace' && method === 'POST') {
+ return Response.json({ installed: false, reason: 'self_hosted' }, { headers: corsHeaders })
+ }
+
+ // POST /organizations/preview-creation — preview pricing/slug before creating an org.
+ // Self-hosted is always tier_free with zero cost; compute the slug that the future org
+ // would get so Studio's creation wizard can display it.
+ if (path === '/preview-creation' && method === 'POST') {
+ const body = (await req.json().catch(() => ({}))) as Record
+ const name = typeof body.name === 'string' ? body.name : null
+ const slug = name ? generateSlugBase(name) || null : null
+ return Response.json(
+ {
+ name,
+ slug,
+ currency: 'USD',
+ plan_price: 0,
+ tax: null,
+ tax_status: 'not_applicable',
+ total: 0,
+ },
+ { headers: corsHeaders },
+ )
+ }
+
+ // Extract slug from path: /{slug} or /{slug}/sub-resource
+ const slugMatch = path.match(/^\/([^/]+)(\/.*)?$/)
+ if (!slugMatch) {
+ return Response.json({ message: 'Not Found' }, { status: 404, headers: corsHeaders })
+ }
+
+ const slug = slugMatch[1]
+ const subPath = slugMatch[2] || ''
+
+ // GET /organizations/{slug}/projects — list org projects from DB
+ if (method === 'GET' && subPath === '/projects') {
+ const org = await getOrganizationBySlug(pool, slug, profileId)
+ if (!org) {
+ return Response.json(
+ { message: 'Organization not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ const url = new URL(req.url)
+ const limit = parseInt(url.searchParams.get('limit') || '100', 10)
+ const offset = parseInt(url.searchParams.get('offset') || '0', 10)
+ const result = await listOrgProjects(pool, org.id, limit, offset)
+ return Response.json(result, { headers: corsHeaders })
+ }
+
+ // Delegate billing/payments/customer/tax sub-paths to billing handler
+ if (
+ subPath.startsWith('/billing') ||
+ subPath.startsWith('/customer') ||
+ subPath.startsWith('/tax-ids') ||
+ subPath.startsWith('/payments')
+ ) {
+ const org = await getOrganizationBySlug(pool, slug, profileId)
+ if (!org) {
+ return Response.json(
+ { message: 'Organization not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return handleBilling(req, subPath, method, pool, org.id, profileId, gotrueId, email)
+ }
+
+ // Usage endpoints (real metrics from Postgres + Logflare)
+ if (method === 'GET' && (subPath === '/usage' || subPath === '/usage/daily')) {
+ const org = await getOrganizationBySlug(pool, slug, profileId)
+ if (!org) {
+ return Response.json(
+ { message: 'Organization not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+
+ const url = new URL(req.url)
+ const usageOpts: {
+ projectRef: string | undefined
+ projectName?: string
+ start: string | undefined
+ end: string | undefined
+ } = {
+ projectRef: url.searchParams.get('project_ref') ?? undefined,
+ start: url.searchParams.get('start') ?? undefined,
+ end: url.searchParams.get('end') ?? undefined,
+ }
+
+ // Resolve the analytics backend for Logflare queries. Without an explicit
+ // project_ref we have no unambiguous backend to fan Logflare queries at
+ // (org-level analytics aggregation is out of scope for this retrofit), so
+ // we leave `backend` undefined and `usage.service.ts` skips Logflare — DB
+ // and storage sizes still come through.
+ //
+ // H1: the provided `project_ref` MUST belong to the org in the URL path.
+ // Without this cross-check, any org member could pass `?project_ref=`
+ // and trick traffic-one into proxying Logflare queries against a project
+ // they don't own (the outer `getOrganizationBySlug` call only verifies
+ // the caller is a member of `{slug}`, not that `{project_ref}` lives
+ // inside `{slug}`). We gate with `getProjectByRef` (which already
+ // enforces caller membership) and then assert `organization_id === org.id`
+ // so cross-org refs return 404 with the same shape as unknown refs —
+ // giving the caller no way to distinguish "ref exists elsewhere" from
+ // "ref doesn't exist" (M7 anti-enumeration).
+ let analyticsBackend
+ if (usageOpts.projectRef) {
+ // L4: a malformed `project_ref` query param can never match a real row.
+ // Returning 400 (not 404) matches the rest of the handler surface that
+ // uses `assertValidRef` on path refs — the query-string position just
+ // shifts where the ref enters.
+ const bad = assertValidRef(usageOpts.projectRef)
+ if (bad) return bad
+ const project = await getProjectByRef(pool, usageOpts.projectRef, profileId)
+ if (!project || project.organization_id !== org.id) {
+ return Response.json(
+ { message: 'Project not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ // L5: forward the resolved project name so usage.service.ts can use it
+ // as the allocation label instead of the platform-wide
+ // DEFAULT_PROJECT_NAME fallback.
+ usageOpts.projectName = project.name
+ try {
+ analyticsBackend = await getProjectBackend(usageOpts.projectRef, pool)
+ } catch (err) {
+ // M6: for org-level /usage we intentionally DON'T surface the
+ // canonical 501 here. A single unprovisioned project shouldn't
+ // black-hole the whole organization's usage response; we simply
+ // omit per-project analytics and let the aggregate continue.
+ // All other `getProjectBackend` callers use `notProvisionedResponse`.
+ if (!(err instanceof ProjectBackendNotProvisionedError)) throw err
+ }
+ }
+
+ try {
+ if (subPath === '/usage') {
+ const result = await getOrgUsage(pool, org.id, org.plan.id, usageOpts, analyticsBackend)
+ return Response.json(result, { headers: corsHeaders })
+ } else {
+ const result = await getOrgDailyUsage(pool, org.id, usageOpts, analyticsBackend)
+ return Response.json(result, { headers: corsHeaders })
+ }
+ } catch (err) {
+ console.error('Usage endpoint error:', err)
+ return Response.json(
+ { message: 'Failed to get usage stats' },
+ { status: 500, headers: corsHeaders },
+ )
+ }
+ }
+
+ // ── Org Audit Logs ────────────────────────────────────
+ if (method === 'GET' && subPath === '/audit') {
+ const org = await getOrganizationBySlug(pool, slug, profileId)
+ if (!org) {
+ return Response.json(
+ { message: 'Organization not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ const url = new URL(req.url)
+ const startTs = url.searchParams.get('iso_timestamp_start')
+ const endTs = url.searchParams.get('iso_timestamp_end')
+ if (!startTs || !endTs) {
+ return Response.json(
+ { message: 'iso_timestamp_start and iso_timestamp_end are required' },
+ { status: 400, headers: corsHeaders },
+ )
+ }
+ const logs = await getOrgAuditLogs(pool, org.id, startTs, endTs)
+ return Response.json(logs, { headers: corsHeaders })
+ }
+
+ // ── Members, Invitations, Roles ─────────────────────────
+ if (subPath.startsWith('/members') || subPath === '/roles') {
+ const org = await getOrganizationBySlug(pool, slug, profileId)
+ if (!org) {
+ return Response.json(
+ { message: 'Organization not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return handleMembers(req, subPath, method, pool, org.id, profileId, gotrueId, email)
+ }
+
+ // ── SSO Provider CRUD ───────────────────────────────────
+ if (subPath === '/sso') {
+ const org = await getOrganizationBySlug(pool, slug, profileId)
+ if (!org) {
+ return Response.json(
+ { message: 'Organization not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ if (method === 'GET') {
+ const provider = await getSSOProvider(pool, org.id)
+ if (!provider) {
+ return Response.json(
+ { message: 'No SSO provider configured for this organization' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json(provider, { headers: corsHeaders })
+ }
+ if (method === 'POST') {
+ const body = await req.json()
+ const provider = await createSSOProvider(
+ pool,
+ org.id,
+ body,
+ profileId,
+ gotrueId,
+ auditContext,
+ )
+ return Response.json(provider, { status: 201, headers: corsHeaders })
+ }
+ if (method === 'PUT') {
+ const body = await req.json()
+ const provider = await updateSSOProvider(
+ pool,
+ org.id,
+ body,
+ profileId,
+ gotrueId,
+ auditContext,
+ )
+ if (!provider) {
+ return Response.json(
+ { message: 'No SSO provider configured for this organization' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json(provider, { headers: corsHeaders })
+ }
+ if (method === 'DELETE') {
+ const deleted = await deleteSSOProvider(pool, org.id, profileId, gotrueId, auditContext)
+ if (!deleted) {
+ return Response.json(
+ { message: 'No SSO provider configured for this organization' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json({ message: 'SSO provider deleted' }, { headers: corsHeaders })
+ }
+ }
+
+ // ── Compliance documents ────────────────────────────────
+ // GET /documents/{standard-security-questionnaire|soc2-type-2-report|iso27001-certificate}
+ // returns a shape-correct "not available" response instead of {} (which Studio downloads
+ // as a broken PDF). Studio's useDocumentQuery reads `fileUrl` and skips the download when
+ // it's null.
+ const docMatch = subPath.match(/^\/documents\/([^/]+)$/)
+ if (docMatch && method === 'GET' && COMPLIANCE_DOC_TYPES.has(docMatch[1])) {
+ const org = await getOrganizationBySlug(pool, slug, profileId)
+ if (!org) {
+ return Response.json(
+ { message: 'Organization not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json({ fileUrl: null, available: false }, { headers: corsHeaders })
+ }
+
+ // POST /documents/dpa — Data Processing Addendum generation is a PandaDoc-backed
+ // cloud-only flow. Return 501 with the canonical `{ code, message }` envelope
+ // so Studio surfaces the "not available in self-hosted" state instead of
+ // "Failed to request DPA". L3: every self-hosted-unsupported response in
+ // traffic-one uses this exact shape for machine-readable rendering.
+ if (subPath === '/documents/dpa' && method === 'POST') {
+ const org = await getOrganizationBySlug(pool, slug, profileId)
+ if (!org) {
+ return Response.json(
+ { message: 'Organization not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json(
+ {
+ code: 'self_hosted_unsupported',
+ message: 'Data Processing Addendum requests are not available in self-hosted',
+ },
+ { status: 501, headers: corsHeaders },
+ )
+ }
+
+ // Sub-resource stubs for self-hosted (no marketplace).
+ //
+ // M3: pre-fix we fell through to `Response.json({}, 200)` for ANY unknown
+ // `/organizations/{slug}/` GET/POST once org membership was
+ // verified. That silently swallowed upstream API renames (and the
+ // corresponding breakages in Studio) because every call looked like a
+ // success with no body. Narrow to the explicit allow-list below and return
+ // 404 otherwise so bad routes surface as real errors.
+ const subResourceStubs: Record = {
+ '/entitlements': { entitlements: [] },
+ '/oauth/apps': [],
+ '/apps': [],
+ '/apps/installations': [],
+ }
+
+ // Stubs for mutations too — self-hosted has no backing store for marketplace
+ // link, OAuth apps, signing-key rotations, or client-secret admin flows.
+ // Keeping the whitelist unified means the same set of paths answers GET /
+ // POST / PATCH / PUT / DELETE consistently.
+ const MUTATION_STUB_PATHS = new Set([
+ '/apps',
+ '/apps/installations',
+ '/oauth/apps',
+ '/marketplace/link',
+ ])
+
+ if (method === 'GET' && subPath && subPath !== '/') {
+ const stubData = subResourceStubs[subPath]
+ if (stubData === undefined) {
+ return Response.json({ message: 'Not Found' }, { status: 404, headers: corsHeaders })
+ }
+ const org = await getOrganizationBySlug(pool, slug, profileId)
+ if (!org) {
+ return Response.json(
+ { message: 'Organization not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json(stubData, { headers: corsHeaders })
+ }
+
+ if (method === 'POST' && subPath && subPath !== '/') {
+ if (subPath === '/available-versions') {
+ const org = await getOrganizationBySlug(pool, slug, profileId)
+ if (!org) {
+ return Response.json(
+ { message: 'Organization not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json({ available_versions: [] }, { headers: corsHeaders })
+ }
+ if (MUTATION_STUB_PATHS.has(subPath)) {
+ const org = await getOrganizationBySlug(pool, slug, profileId)
+ if (!org) {
+ return Response.json(
+ { message: 'Organization not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json({}, { headers: corsHeaders })
+ }
+ return Response.json({ message: 'Not Found' }, { status: 404, headers: corsHeaders })
+ }
+
+ // PATCH / PUT / DELETE only succeed for the mutation-stub whitelist. Outside
+ // of that, return 404 instead of a misleading 200 {}.
+ if (
+ (method === 'PATCH' || method === 'PUT' || method === 'DELETE') &&
+ subPath &&
+ subPath !== '/'
+ ) {
+ if (!MUTATION_STUB_PATHS.has(subPath)) {
+ return Response.json({ message: 'Not Found' }, { status: 404, headers: corsHeaders })
+ }
+ const org = await getOrganizationBySlug(pool, slug, profileId)
+ if (!org) {
+ return Response.json(
+ { message: 'Organization not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json({}, { headers: corsHeaders })
+ }
+
+ // GET /organizations/{slug} — get org detail
+ if (method === 'GET') {
+ const org = await getOrganizationBySlug(pool, slug, profileId)
+ if (!org) {
+ return Response.json(
+ { message: 'Organization not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json(org, { headers: corsHeaders })
+ }
+
+ // PATCH /organizations/{slug} — update org
+ if (method === 'PATCH' && !subPath) {
+ const body = await req.json()
+ const result = await updateOrganization(
+ pool,
+ slug,
+ profileId,
+ {
+ name: body.name,
+ billing_email: body.billing_email,
+ opt_in_tags: body.opt_in_tags,
+ additional_billing_emails: body.additional_billing_emails,
+ },
+ gotrueId,
+ auditContext,
+ )
+ if (!result) {
+ return Response.json(
+ { message: 'Organization not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json(result, { headers: corsHeaders })
+ }
+
+ // DELETE /organizations/{slug} — delete org
+ if (method === 'DELETE' && !subPath) {
+ const deleted = await deleteOrganization(pool, slug, profileId, gotrueId, auditContext)
+ if (!deleted) {
+ return Response.json(
+ { message: 'Organization not found or not owner' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json({ message: 'Organization deleted' }, { headers: corsHeaders })
+ }
+
+ return Response.json({ message: 'Method not allowed' }, { status: 405, headers: corsHeaders })
+}
+
+// ── /api/v1/organizations/{slug}/... ────────────────────────────────────────
+//
+// Served by the `v1-organizations` Kong service. Today the only endpoint Studio
+// hits is /project-claim/{token} (used by the AWS-marketplace project transfer
+// flow). Keep the handler scoped to that subpath; unmatched paths 404.
+export async function handleV1Organizations(
+ _req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+): Promise {
+ const claimMatch = path.match(/^\/([^/]+)\/project-claim\/([^/]+)\/?$/)
+ if (!claimMatch) {
+ return Response.json({ message: 'Not Found' }, { status: 404, headers: corsHeaders })
+ }
+
+ const slug = claimMatch[1]
+
+ // Authorize against the target org — a claim token is only usable by a member of
+ // the org that's supposed to receive the project. Unknown slug / non-member: 404
+ // (not 403, to avoid leaking org existence).
+ const org = await getOrganizationBySlug(pool, slug, profileId)
+ if (!org) {
+ return Response.json(
+ { message: 'Organization not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+
+ // Self-hosted has no cross-region project transfer / AWS-marketplace provisioning,
+ // so there are no real claim tokens. GET returns a "not valid" stub so Studio's
+ // claim-project page shows its error admonition; POST is explicitly 501.
+ if (method === 'GET') {
+ return Response.json({ valid: false }, { headers: corsHeaders })
+ }
+ if (method === 'POST') {
+ return Response.json(
+ { code: 'self_hosted_unsupported', message: 'Project claim is not available in self-hosted' },
+ { status: 501, headers: corsHeaders },
+ )
+ }
+
+ return Response.json({ message: 'Method not allowed' }, { status: 405, headers: corsHeaders })
+}
diff --git a/traffic-one/functions/routes/permissions.ts b/traffic-one/functions/routes/permissions.ts
new file mode 100644
index 0000000000000..00ba72ffe07b8
--- /dev/null
+++ b/traffic-one/functions/routes/permissions.ts
@@ -0,0 +1,25 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import { getPermissions } from '../services/permission.service.ts'
+
+export async function handlePermissions(
+ _req: Request,
+ _path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+): Promise {
+ if (method !== 'GET') {
+ return Response.json(
+ { message: 'Method not allowed' },
+ {
+ status: 405,
+ headers: corsHeaders,
+ },
+ )
+ }
+
+ const permissions = await getPermissions(pool, profileId)
+ return Response.json(permissions, { headers: corsHeaders })
+}
diff --git a/traffic-one/functions/routes/profile.ts b/traffic-one/functions/routes/profile.ts
new file mode 100644
index 0000000000000..9b29e1710b207
--- /dev/null
+++ b/traffic-one/functions/routes/profile.ts
@@ -0,0 +1,56 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import { getOrCreateProfile, updateProfile } from '../services/profile.service.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+
+export async function handleProfile(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ gotrueId: string,
+ email: string,
+): Promise {
+ if (method === 'GET' && (path === '/' || path === '')) {
+ const profile = await getOrCreateProfile(pool, gotrueId, email)
+ return Response.json(profile, { headers: corsHeaders })
+ }
+
+ if (method === 'PUT' && (path === '/' || path === '/update')) {
+ const body = await req.json().catch(() => ({}))
+ const ip = getClientIp(req)
+ const profile = await updateProfile(pool, gotrueId, body, {
+ email,
+ ip,
+ method,
+ route: '/profile' + path,
+ })
+ return Response.json(profile, { headers: corsHeaders })
+ }
+
+ if (method === 'PATCH' && (path === '/' || path === '')) {
+ const body = await req.json().catch(() => ({}))
+ const ip = getClientIp(req)
+ const profile = await updateProfile(pool, gotrueId, body, {
+ email,
+ ip,
+ method,
+ route: '/profile',
+ })
+ return Response.json(profile, { headers: corsHeaders })
+ }
+
+ if (method === 'POST' && (path === '/' || path === '')) {
+ const profile = await getOrCreateProfile(pool, gotrueId, email)
+ return Response.json(profile, { headers: corsHeaders })
+ }
+
+ return Response.json(
+ { message: 'Method not allowed' },
+ {
+ status: 405,
+ headers: corsHeaders,
+ },
+ )
+}
diff --git a/traffic-one/functions/routes/project-analytics.ts b/traffic-one/functions/routes/project-analytics.ts
new file mode 100644
index 0000000000000..3a892f67ead54
--- /dev/null
+++ b/traffic-one/functions/routes/project-analytics.ts
@@ -0,0 +1,564 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import {
+ createLogDrain,
+ deleteLogDrain,
+ getLogDrain,
+ listLogDrainResponses,
+ toBackendResponse,
+ updateLogDrain,
+} from '../services/log-drains.service.ts'
+import { queryEndpoint } from '../services/logflare.client.ts'
+import {
+ fetchProjectJson,
+ getProjectBackend,
+ type ProjectBackend,
+ ProjectBackendNotProvisionedError,
+} from '../services/project-backend.service.ts'
+import { getProjectByRef } from '../services/project.service.ts'
+import { MAX_BODY_ANALYTICS, readBodyWithLimit } from '../utils/body-limits.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+import { notProvisionedResponse } from '../utils/project-backend-response.ts'
+import { assertValidRef } from '../utils/ref-validation.ts'
+
+// M11: small wrapper so every POST/PATCH handler below can share a single
+// body-size limit and failure path. Returns either parsed JSON, `undefined`
+// (empty / malformed body — matches the old `.catch(() => undefined)`
+// semantics used throughout this module), OR a pre-built 413 Response when
+// the caller exceeds `MAX_BODY_ANALYTICS`. Call-sites branch on
+// `instanceof Response` and `return` immediately on 413 to avoid auditing
+// or calling upstream.
+async function readAnalyticsBody(req: Request): Promise {
+ let text: string
+ try {
+ text = await readBodyWithLimit(req, MAX_BODY_ANALYTICS)
+ } catch (tooLarge) {
+ if (tooLarge instanceof Response) return tooLarge
+ return undefined
+ }
+ if (!text) return undefined
+ try {
+ return JSON.parse(text)
+ } catch {
+ return undefined
+ }
+}
+
+// ── Response helpers ──────────────────────────────────────
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return Response.json(body, { status, headers: corsHeaders })
+}
+
+function notFoundResponse(message = 'Not Found'): Response {
+ return jsonResponse({ message }, 404)
+}
+
+function methodNotAllowedResponse(): Response {
+ return jsonResponse({ message: 'Method not allowed' }, 405)
+}
+
+function invalidBodyResponse(message = 'Invalid request body'): Response {
+ return jsonResponse({ message }, 400)
+}
+
+// ── Infra-monitoring ──────────────────────────────────────
+
+/**
+ * All attribute keys accepted by the Studio API (mirrors
+ * `InfraMonitoringController_getUsageMetrics` in packages/api-types). Keeping
+ * this list in sync ensures `response.series?.[attr]` is always defined for any
+ * attribute the UI requests, so the "Cannot read properties of undefined
+ * (reading 'map')" crash in `useInfraMonitoringQueries` cannot surface.
+ */
+const INFRA_MONITORING_ATTRIBUTES = [
+ 'cpu_usage',
+ 'cpu_usage_busy_system',
+ 'cpu_usage_busy_user',
+ 'cpu_usage_busy_iowait',
+ 'cpu_usage_busy_irqs',
+ 'cpu_usage_busy_other',
+ 'cpu_usage_busy_idle',
+ 'max_cpu_usage',
+ 'avg_cpu_usage',
+ 'ram_usage',
+ 'ram_usage_total',
+ 'ram_usage_available',
+ 'ram_usage_used',
+ 'ram_usage_free',
+ 'ram_usage_cache_and_buffers',
+ 'ram_usage_swap',
+ 'swap_usage',
+ 'client_connections_pgbouncer',
+ 'network_receive_bytes',
+ 'network_transmit_bytes',
+ 'pgbouncer_pools_client_active_connections',
+ 'supavisor_connections_active',
+ 'client_connections_postgres',
+ 'client_connections_authenticator',
+ 'client_connections_supabase_auth_admin',
+ 'client_connections_supabase_storage_admin',
+ 'client_connections_supabase_admin',
+ 'client_connections_other',
+ 'realtime_connections_connected',
+ 'realtime_channel_joins',
+ 'realtime_channel_events',
+ 'realtime_channel_presence_events',
+ 'realtime_channel_db_events',
+ 'realtime_authorization_rls_execution_time',
+ 'realtime_read_authorization_rls_execution_time',
+ 'realtime_write_authorization_rls_execution_time',
+ 'realtime_payload_size',
+ 'realtime_replication_connection_lag',
+ 'realtime_sum_connections_connected',
+ 'disk_io_budget',
+ 'disk_io_consumption',
+ 'disk_io_usage',
+ 'disk_iops_read',
+ 'disk_iops_write',
+ 'disk_bytes_read',
+ 'disk_bytes_written',
+ 'pg_database_size',
+ 'disk_fs_size',
+ 'disk_fs_avail',
+ 'disk_fs_used',
+ 'disk_fs_used_wal',
+ 'disk_fs_used_system',
+ 'physical_replication_lag_physical_replication_lag_seconds',
+ 'pg_stat_database_num_backends',
+ 'max_db_connections',
+] as const
+
+function buildInfraMonitoringResponse(): {
+ data: { period_start: string; values: Record }[]
+ series: Record<
+ string,
+ { yAxisLimit: number; format: string; total: number; totalAverage: number }
+ >
+} {
+ const series: Record<
+ string,
+ { yAxisLimit: number; format: string; total: number; totalAverage: number }
+ > = {}
+ for (const attribute of INFRA_MONITORING_ATTRIBUTES) {
+ series[attribute] = {
+ yAxisLimit: 100,
+ format: '',
+ total: 0,
+ totalAverage: 0,
+ }
+ }
+ return { data: [], series }
+}
+
+// ── Logflare endpoint proxy ───────────────────────────────
+
+async function handleAnalyticsEndpoint(
+ req: Request,
+ backend: ProjectBackend,
+ endpointName: string,
+ method: string,
+): Promise {
+ const url = new URL(req.url)
+ const params: Record = {}
+ for (const [key, value] of url.searchParams.entries()) {
+ params[key] = value
+ }
+
+ let body: unknown
+ if (method === 'POST') {
+ const parsed = await readAnalyticsBody(req)
+ if (parsed instanceof Response) return parsed
+ body = parsed
+ if (body && typeof body === 'object') {
+ for (const [key, value] of Object.entries(body as Record)) {
+ if (typeof value === 'string' && value.length > 0 && params[key] === undefined) {
+ params[key] = value
+ }
+ }
+ }
+ }
+
+ const { result } = await queryEndpoint(
+ backend,
+ endpointName,
+ params,
+ body,
+ method === 'POST' ? 'POST' : 'GET',
+ )
+ return jsonResponse({ result }, 200)
+}
+
+// ── REST OpenAPI proxy ────────────────────────────────────
+
+const EMPTY_OPENAPI_SPEC = { openapi: '3.0.0', info: {}, paths: {} }
+
+async function handleRestSpec(backend: ProjectBackend): Promise {
+ if (!backend.endpoint) return jsonResponse(EMPTY_OPENAPI_SPEC, 200)
+
+ // M8: PostgREST's `/rest/v1/` OpenAPI document enumerates every
+ // table, view, and RPC the JWT's role can see. `fetchProjectJson` by
+ // default signs with `backend.serviceKey`, which would leak
+ // service_role-only schemas into Studio's "Docs" tab. The docs tab
+ // targets developers who are writing client-side code against the
+ // anon key, so the spec should reflect the anon role's view of the
+ // API — i.e. only the public schema + RLS-visible columns. We pin
+ // Authorization + apikey to `backend.anonKey` explicitly (and fall
+ // back to an empty spec if the resolver never populated an anon key,
+ // e.g. per-project mode where anon_key is NULL).
+ if (!backend.anonKey) return jsonResponse(EMPTY_OPENAPI_SPEC, 200)
+
+ try {
+ const res = await fetchProjectJson(backend, '/rest/v1/', {
+ method: 'GET',
+ headers: {
+ Accept: 'application/openapi+json,application/json',
+ Authorization: `Bearer ${backend.anonKey}`,
+ apikey: backend.anonKey,
+ },
+ })
+ if (!res.ok) {
+ const text = await res.text().catch(() => '')
+ console.error(`PostgREST spec fetch ${res.status}: ${text.slice(0, 200)}`)
+ return jsonResponse(EMPTY_OPENAPI_SPEC, 200)
+ }
+ const spec = await res.json().catch(() => null)
+ if (!spec || typeof spec !== 'object') return jsonResponse(EMPTY_OPENAPI_SPEC, 200)
+ return jsonResponse(spec, 200)
+ } catch (err) {
+ console.error('PostgREST spec fetch failed:', err)
+ return jsonResponse(EMPTY_OPENAPI_SPEC, 200)
+ }
+}
+
+// ── GraphQL introspection proxy ───────────────────────────
+
+const EMPTY_GRAPHQL_RESPONSE = { data: { __schema: { types: [] } } }
+
+const GRAPHQL_INTROSPECTION_QUERY = `query IntrospectionQuery {
+ __schema {
+ queryType { name }
+ mutationType { name }
+ subscriptionType { name }
+ types { ...FullType }
+ directives { name description locations args { ...InputValue } }
+ }
+}
+fragment FullType on __Type {
+ kind name description
+ fields(includeDeprecated: true) { name description args { ...InputValue } type { ...TypeRef } isDeprecated deprecationReason }
+ inputFields { ...InputValue }
+ interfaces { ...TypeRef }
+ enumValues(includeDeprecated: true) { name description isDeprecated deprecationReason }
+ possibleTypes { ...TypeRef }
+}
+fragment InputValue on __InputValue { name description type { ...TypeRef } defaultValue }
+fragment TypeRef on __Type { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name } } } } } } } }`
+
+async function handleGraphqlProxy(
+ req: Request,
+ backend: ProjectBackend,
+ method: string,
+): Promise {
+ if (!backend.endpoint) return jsonResponse(EMPTY_GRAPHQL_RESPONSE, 200)
+
+ let body: unknown = undefined
+ if (method === 'POST') {
+ const parsed = await readAnalyticsBody(req)
+ if (parsed instanceof Response) return parsed
+ body = parsed
+ }
+ if (body === undefined || body === null) {
+ body = { query: GRAPHQL_INTROSPECTION_QUERY }
+ }
+
+ // pg_graphql anon-role introspection: use the anon key when Studio doesn't
+ // forward an x-graphql-authorization header. Studio proxies the admin token
+ // through that header when users run authenticated queries from the GraphiQL
+ // editor.
+ const forwardedAuth = req.headers.get('x-graphql-authorization') ?? undefined
+ const authHeader = forwardedAuth ?? `Bearer ${backend.anonKey}`
+
+ try {
+ const res = await fetchProjectJson(backend, '/graphql/v1', {
+ method: 'POST',
+ headers: { Authorization: authHeader },
+ body: JSON.stringify(body),
+ })
+ if (!res.ok) {
+ const text = await res.text().catch(() => '')
+ console.error(`GraphQL introspection ${res.status}: ${text.slice(0, 200)}`)
+ return jsonResponse(EMPTY_GRAPHQL_RESPONSE, 200)
+ }
+ const data = await res.json().catch(() => null)
+ if (!data || typeof data !== 'object') {
+ return jsonResponse(EMPTY_GRAPHQL_RESPONSE, 200)
+ }
+ return jsonResponse(data, 200)
+ } catch (err) {
+ console.error('GraphQL introspection fetch failed:', err)
+ return jsonResponse(EMPTY_GRAPHQL_RESPONSE, 200)
+ }
+}
+
+// ── Log drain CRUD ────────────────────────────────────────
+
+async function handleLogDrainList(
+ pool: Pool,
+ projectRef: string,
+ profileId: number,
+): Promise {
+ const drains = await listLogDrainResponses(pool, projectRef, profileId)
+ return jsonResponse(drains, 200)
+}
+
+async function handleLogDrainCreate(
+ req: Request,
+ pool: Pool,
+ projectRef: string,
+ profileId: number,
+ gotrueId: string,
+ organizationId: number,
+ auditContext: { email: string; ip: string; method: string; route: string },
+): Promise {
+ const parsed = await readAnalyticsBody(req)
+ if (parsed instanceof Response) return parsed
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return invalidBodyResponse('Body must be valid JSON')
+ }
+ const body = parsed as Record
+
+ const name = typeof body.name === 'string' ? body.name : ''
+ const type = typeof body.type === 'string' ? body.type : ''
+ const description = typeof body.description === 'string' ? body.description : ''
+ const config = body.config && typeof body.config === 'object' && !Array.isArray(body.config)
+ ? (body.config as Record)
+ : {}
+ const filters = Array.isArray(body.filters) ? (body.filters as unknown[]) : []
+
+ if (!name.trim()) return invalidBodyResponse('name is required')
+ if (!type.trim()) return invalidBodyResponse('type is required')
+
+ const outcome = await createLogDrain(
+ pool,
+ projectRef,
+ profileId,
+ { name, description, type, config, filters },
+ gotrueId,
+ organizationId,
+ auditContext,
+ )
+ if (outcome.status === 'conflict') {
+ return jsonResponse({ code: 'conflict', message: outcome.message }, 409)
+ }
+ return jsonResponse(outcome.drain, 201)
+}
+
+async function handleLogDrainGet(
+ pool: Pool,
+ projectRef: string,
+ token: string,
+ userId: number,
+): Promise {
+ const row = await getLogDrain(pool, projectRef, token)
+ if (!row) return notFoundResponse('Log drain not found')
+ return jsonResponse(toBackendResponse(row, userId), 200)
+}
+
+async function handleLogDrainUpdate(
+ req: Request,
+ pool: Pool,
+ projectRef: string,
+ token: string,
+ userId: number,
+ profileId: number,
+ organizationId: number,
+ gotrueId: string,
+ auditContext: Parameters[8],
+): Promise {
+ const parsed = await readAnalyticsBody(req)
+ if (parsed instanceof Response) return parsed
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return invalidBodyResponse('Body must be valid JSON')
+ }
+ const body = parsed as Record
+
+ const patch: Parameters[3] = {}
+ if (typeof body.name === 'string') patch.name = body.name
+ if (typeof body.description === 'string' || body.description === null) {
+ patch.description = body.description as string | null
+ }
+ if (typeof body.type === 'string') patch.type = body.type
+ if (body.config && typeof body.config === 'object' && !Array.isArray(body.config)) {
+ patch.config = body.config as Record
+ }
+ if (Array.isArray(body.filters)) patch.filters = body.filters as unknown[]
+ if (typeof body.active === 'boolean') patch.active = body.active
+
+ const outcome = await updateLogDrain(
+ pool,
+ projectRef,
+ token,
+ patch,
+ userId,
+ profileId,
+ organizationId,
+ gotrueId,
+ auditContext,
+ )
+ if (outcome.status === 'not_found') return notFoundResponse('Log drain not found')
+ if (outcome.status === 'conflict') {
+ return jsonResponse({ code: 'conflict', message: outcome.message }, 409)
+ }
+ return jsonResponse(outcome.drain, 200)
+}
+
+async function handleLogDrainDelete(
+ pool: Pool,
+ projectRef: string,
+ token: string,
+ profileId: number,
+ gotrueId: string,
+ organizationId: number,
+ auditContext: { email: string; ip: string; method: string; route: string },
+): Promise {
+ const row = await deleteLogDrain(
+ pool,
+ projectRef,
+ token,
+ profileId,
+ gotrueId,
+ organizationId,
+ auditContext,
+ )
+ if (!row) return notFoundResponse('Log drain not found')
+ return jsonResponse(toBackendResponse(row, profileId), 200)
+}
+
+// ── Top-level handler ─────────────────────────────────────
+
+export async function handleProjectAnalytics(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+): Promise {
+ const refMatch = path.match(/^\/([^/]+)(\/.*)$/)
+ if (!refMatch) return notFoundResponse()
+
+ const ref = refMatch[1]
+ const subPath = refMatch[2]
+
+ // L4: reject malformed refs before hitting the DB.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) return notFoundResponse('Project not found')
+
+ const ip = getClientIp(req)
+ const auditContext = {
+ email,
+ ip,
+ method,
+ route: '/platform/projects/' + ref + subPath,
+ }
+
+ // ── Infra-monitoring ────────────────────────────────────
+ // No backend roundtrip: this is a static shape that Studio uses to populate
+ // the empty charts on self-hosted. Short-circuit BEFORE resolving the
+ // backend so an un-provisioned project doesn't 501 on every dashboard load.
+ if (subPath === '/infra-monitoring') {
+ if (method === 'GET') return jsonResponse(buildInfraMonitoringResponse(), 200)
+ return methodNotAllowedResponse()
+ }
+
+ // Everything below talks to either Logflare, PostgREST, or pg_graphql on
+ // the project's own backend — resolve it once and translate the "not
+ // provisioned" error into a 501 so Studio can render the empty state.
+ let backend: ProjectBackend
+ try {
+ backend = await getProjectBackend(ref, pool)
+ } catch (err) {
+ if (err instanceof ProjectBackendNotProvisionedError) {
+ return notProvisionedResponse(err)
+ }
+ throw err
+ }
+
+ // ── Analytics endpoints proxy ───────────────────────────
+ const endpointMatch = subPath.match(/^\/analytics\/endpoints\/([^/]+)\/?$/)
+ if (endpointMatch) {
+ const endpointName = endpointMatch[1]
+ if (method === 'GET' || method === 'POST') {
+ return handleAnalyticsEndpoint(req, backend, endpointName, method)
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── REST OpenAPI spec ───────────────────────────────────
+ if (subPath === '/api/rest') {
+ if (method === 'GET' || method === 'HEAD') return handleRestSpec(backend)
+ return methodNotAllowedResponse()
+ }
+
+ // ── GraphQL introspection ───────────────────────────────
+ if (subPath === '/api/graphql') {
+ if (method === 'GET' || method === 'POST') return handleGraphqlProxy(req, backend, method)
+ return methodNotAllowedResponse()
+ }
+
+ // ── Log drain CRUD ──────────────────────────────────────
+ if (subPath === '/analytics/log-drains' || subPath === '/analytics/log-drains/') {
+ if (method === 'GET') return handleLogDrainList(pool, ref, profileId)
+ if (method === 'POST') {
+ return handleLogDrainCreate(
+ req,
+ pool,
+ ref,
+ profileId,
+ gotrueId,
+ project.organization_id,
+ auditContext,
+ )
+ }
+ return methodNotAllowedResponse()
+ }
+
+ const tokenMatch = subPath.match(/^\/analytics\/log-drains\/([^/]+)\/?$/)
+ if (tokenMatch) {
+ const token = tokenMatch[1]
+ if (method === 'GET') return handleLogDrainGet(pool, ref, token, profileId)
+ if (method === 'PUT' || method === 'PATCH') {
+ return handleLogDrainUpdate(
+ req,
+ pool,
+ ref,
+ token,
+ profileId,
+ profileId,
+ project.organization_id,
+ gotrueId,
+ auditContext,
+ )
+ }
+ if (method === 'DELETE') {
+ return handleLogDrainDelete(
+ pool,
+ ref,
+ token,
+ profileId,
+ gotrueId,
+ project.organization_id,
+ auditContext,
+ )
+ }
+ return methodNotAllowedResponse()
+ }
+
+ return notFoundResponse()
+}
diff --git a/traffic-one/functions/routes/project-api-keys.ts b/traffic-one/functions/routes/project-api-keys.ts
new file mode 100644
index 0000000000000..879851b42ad9a
--- /dev/null
+++ b/traffic-one/functions/routes/project-api-keys.ts
@@ -0,0 +1,349 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import {
+ type ApiKeyType,
+ createApiKey,
+ createSigningKey,
+ type CreateSigningKeyInput,
+ createTemporaryApiKey,
+ deleteApiKey,
+ deleteSigningKey,
+ getApiKey,
+ getSigningKey,
+ listApiKeys,
+ listLegacyApiKeys,
+ listLegacySigningKeys,
+ listSigningKeys,
+ type SigningKeyStatus,
+ updateApiKey,
+ updateSigningKey,
+} from '../services/project-api-keys.service.ts'
+import {
+ getProjectBackend,
+ ProjectBackendNotProvisionedError,
+} from '../services/project-backend.service.ts'
+import { getProjectByRef } from '../services/project.service.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+import { notProvisionedResponse } from '../utils/project-backend-response.ts'
+import { assertValidRef } from '../utils/ref-validation.ts'
+
+// ── Response helpers ─────────────────────────────────────────
+
+function notFoundResponse(message = 'Not Found'): Response {
+ return Response.json({ message }, { status: 404, headers: corsHeaders })
+}
+
+function methodNotAllowedResponse(): Response {
+ return Response.json({ message: 'Method not allowed' }, { status: 405, headers: corsHeaders })
+}
+
+function badRequestResponse(message: string): Response {
+ return Response.json({ message }, { status: 400, headers: corsHeaders })
+}
+
+function notSupportedResponse(message: string): Response {
+ return Response.json(
+ { code: 'self_hosted_unsupported', message },
+ { status: 501, headers: corsHeaders },
+ )
+}
+
+async function readJson(req: Request): Promise> {
+ try {
+ const body = await req.json()
+ if (body && typeof body === 'object') return body as Record
+ return {}
+ } catch {
+ return {}
+ }
+}
+
+function parseId(raw: string): number | null {
+ const parsed = Number.parseInt(raw, 10)
+ if (!Number.isInteger(parsed) || String(parsed) !== raw) return null
+ return parsed
+}
+
+function isApiKeyType(value: unknown): value is ApiKeyType {
+ return value === 'publishable' || value === 'secret'
+}
+
+function isSigningKeyStatus(value: unknown): value is SigningKeyStatus {
+ return (
+ value === 'in_use' || value === 'standby' || value === 'previously_used' || value === 'revoked'
+ )
+}
+
+// ── Handler ──────────────────────────────────────────────────
+
+export async function handleProjectApiKeys(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+): Promise {
+ const refMatch = path.match(/^\/([^/]+)(\/.*)?$/)
+ if (!refMatch) return notFoundResponse()
+
+ const ref = refMatch[1]
+ const subPath = refMatch[2] || ''
+
+ // L4: reject malformed refs before hitting the DB.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) return notFoundResponse('Project not found')
+ const organizationId = project.organization_id
+
+ const ip = getClientIp(req)
+ const auditContext = { email, ip, method, route: path }
+
+ // ── /api-keys/legacy ─────────────────────────────────────
+ if (subPath === '/api-keys/legacy' || subPath === '/api-keys/legacy/') {
+ if (method === 'GET') {
+ try {
+ const backend = await getProjectBackend(ref, pool)
+ return Response.json(listLegacyApiKeys(backend), { headers: corsHeaders })
+ } catch (err) {
+ if (err instanceof ProjectBackendNotProvisionedError) {
+ return notProvisionedResponse(err)
+ }
+ throw err
+ }
+ }
+ if (method === 'PUT') {
+ return notSupportedResponse(
+ 'Rotating the legacy anon / service_role keys requires restarting the stack with new env vars',
+ )
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── /api-keys/temporary ──────────────────────────────────
+ if (subPath === '/api-keys/temporary' || subPath === '/api-keys/temporary/') {
+ if (method === 'POST') {
+ const body = await readJson(req)
+ const name = typeof body.name === 'string' ? body.name : undefined
+ const ttl = typeof body.ttl_seconds === 'number'
+ ? body.ttl_seconds
+ : typeof body.ttlSeconds === 'number'
+ ? body.ttlSeconds
+ : undefined
+ const response = await createTemporaryApiKey(
+ pool,
+ ref,
+ profileId,
+ organizationId,
+ { name, ttl_seconds: ttl },
+ gotrueId,
+ auditContext,
+ )
+ return Response.json(response, { status: 201, headers: corsHeaders })
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── /api-keys (list / create) ────────────────────────────
+ if (subPath === '/api-keys' || subPath === '/api-keys/') {
+ if (method === 'GET') {
+ const keys = await listApiKeys(pool, ref)
+ return Response.json(keys, { headers: corsHeaders })
+ }
+ if (method === 'POST') {
+ const body = await readJson(req)
+ if (typeof body.name !== 'string' || body.name.length === 0) {
+ return badRequestResponse('name is required')
+ }
+ if (!isApiKeyType(body.type)) {
+ return badRequestResponse("type must be 'publishable' or 'secret'")
+ }
+ const tags = Array.isArray(body.tags)
+ ? body.tags.filter((t): t is string => typeof t === 'string')
+ : undefined
+ const description = typeof body.description === 'string' ? body.description : undefined
+
+ const created = await createApiKey(
+ pool,
+ ref,
+ profileId,
+ organizationId,
+ { name: body.name, description, type: body.type, tags },
+ gotrueId,
+ auditContext,
+ )
+ return Response.json(created, { status: 201, headers: corsHeaders })
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── /api-keys/{id} ───────────────────────────────────────
+ const apiKeyIdMatch = subPath.match(/^\/api-keys\/([^/]+)\/?$/)
+ if (apiKeyIdMatch && apiKeyIdMatch[1] !== 'legacy' && apiKeyIdMatch[1] !== 'temporary') {
+ const id = parseId(apiKeyIdMatch[1])
+ if (id === null) return notFoundResponse('Api key not found')
+
+ if (method === 'GET') {
+ const key = await getApiKey(pool, ref, id)
+ if (!key) return notFoundResponse('Api key not found')
+ return Response.json(key, { headers: corsHeaders })
+ }
+ if (method === 'PATCH') {
+ const body = await readJson(req)
+ const patch: { name?: string; description?: string; tags?: string[] } = {}
+ if (typeof body.name === 'string') patch.name = body.name
+ if (typeof body.description === 'string') patch.description = body.description
+ if (Array.isArray(body.tags)) {
+ patch.tags = body.tags.filter((t): t is string => typeof t === 'string')
+ }
+ const updated = await updateApiKey(
+ pool,
+ ref,
+ id,
+ patch,
+ profileId,
+ organizationId,
+ gotrueId,
+ auditContext,
+ )
+ if (!updated) return notFoundResponse('Api key not found')
+ return Response.json(updated, { headers: corsHeaders })
+ }
+ if (method === 'DELETE') {
+ const deleted = await deleteApiKey(
+ pool,
+ ref,
+ id,
+ profileId,
+ organizationId,
+ gotrueId,
+ auditContext,
+ )
+ if (!deleted) return notFoundResponse('Api key not found')
+ return Response.json(deleted, { headers: corsHeaders })
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── /config/auth/signing-keys/legacy ─────────────────────
+ if (
+ subPath === '/config/auth/signing-keys/legacy' ||
+ subPath === '/config/auth/signing-keys/legacy/'
+ ) {
+ if (method === 'GET') {
+ return Response.json(listLegacySigningKeys(), { headers: corsHeaders })
+ }
+ if (method === 'POST') {
+ return notSupportedResponse(
+ 'Rotating the legacy HS256 signing secret requires restarting GoTrue with new env vars',
+ )
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── /config/auth/signing-keys (list / create) ────────────
+ if (subPath === '/config/auth/signing-keys' || subPath === '/config/auth/signing-keys/') {
+ if (method === 'GET') {
+ const keys = await listSigningKeys(pool, ref)
+ return Response.json(keys, { headers: corsHeaders })
+ }
+ if (method === 'POST') {
+ const body = await readJson(req)
+ if (typeof body.algorithm !== 'string' || body.algorithm.length === 0) {
+ return badRequestResponse('algorithm is required')
+ }
+ if (body.status !== undefined && !isSigningKeyStatus(body.status)) {
+ return badRequestResponse(
+ "status must be one of 'in_use', 'standby', 'previously_used', 'revoked'",
+ )
+ }
+ const input: CreateSigningKeyInput = {
+ algorithm: body.algorithm,
+ status: isSigningKeyStatus(body.status) ? body.status : undefined,
+ active: typeof body.active === 'boolean' ? body.active : undefined,
+ public_jwk: body.public_jwk && typeof body.public_jwk === 'object'
+ ? (body.public_jwk as Record)
+ : undefined,
+ private_jwk_secret_id: typeof body.private_jwk_secret_id === 'string'
+ ? body.private_jwk_secret_id
+ : null,
+ }
+ const created = await createSigningKey(
+ pool,
+ ref,
+ profileId,
+ organizationId,
+ input,
+ gotrueId,
+ auditContext,
+ )
+ return Response.json(created, { status: 201, headers: corsHeaders })
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── /config/auth/signing-keys/{id} ───────────────────────
+ const signingIdMatch = subPath.match(/^\/config\/auth\/signing-keys\/([^/]+)\/?$/)
+ if (signingIdMatch && signingIdMatch[1] !== 'legacy') {
+ const id = parseId(signingIdMatch[1])
+ if (id === null) return notFoundResponse('Signing key not found')
+
+ if (method === 'GET') {
+ const key = await getSigningKey(pool, ref, id)
+ if (!key) return notFoundResponse('Signing key not found')
+ return Response.json(key, { headers: corsHeaders })
+ }
+ if (method === 'PATCH') {
+ const body = await readJson(req)
+ if (body.status !== undefined && !isSigningKeyStatus(body.status)) {
+ return badRequestResponse(
+ "status must be one of 'in_use', 'standby', 'previously_used', 'revoked'",
+ )
+ }
+ const patch: {
+ algorithm?: string
+ status?: SigningKeyStatus
+ active?: boolean
+ public_jwk?: Record
+ } = {}
+ if (typeof body.algorithm === 'string') patch.algorithm = body.algorithm
+ if (isSigningKeyStatus(body.status)) patch.status = body.status
+ if (typeof body.active === 'boolean') patch.active = body.active
+ if (body.public_jwk && typeof body.public_jwk === 'object') {
+ patch.public_jwk = body.public_jwk as Record
+ }
+ const updated = await updateSigningKey(
+ pool,
+ ref,
+ id,
+ profileId,
+ organizationId,
+ patch,
+ gotrueId,
+ auditContext,
+ )
+ if (!updated) return notFoundResponse('Signing key not found')
+ return Response.json(updated, { headers: corsHeaders })
+ }
+ if (method === 'DELETE') {
+ const deleted = await deleteSigningKey(
+ pool,
+ ref,
+ id,
+ profileId,
+ organizationId,
+ gotrueId,
+ auditContext,
+ )
+ if (!deleted) return notFoundResponse('Signing key not found')
+ return Response.json(deleted, { headers: corsHeaders })
+ }
+ return methodNotAllowedResponse()
+ }
+
+ return notFoundResponse()
+}
diff --git a/traffic-one/functions/routes/project-auth-admin.ts b/traffic-one/functions/routes/project-auth-admin.ts
new file mode 100644
index 0000000000000..8feb7384cf5a7
--- /dev/null
+++ b/traffic-one/functions/routes/project-auth-admin.ts
@@ -0,0 +1,635 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import {
+ type FetchLike,
+ fetchProjectJson,
+ getProjectBackend,
+ type ProjectBackend,
+ ProjectBackendNotProvisionedError,
+} from '../services/project-backend.service.ts'
+import { getProjectByRef } from '../services/project.service.ts'
+import { MAX_BODY_AUTH_ADMIN, readBodyWithLimit } from '../utils/body-limits.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+import { notProvisionedResponse } from '../utils/project-backend-response.ts'
+import { assertValidRef } from '../utils/ref-validation.ts'
+
+// ─────────────────────────────────────────────────────────────────────────────
+//
+// Project-scoped GoTrue admin proxy (Phase 2).
+//
+// Replaces Studio's own `apps/studio/pages/api/platform/auth/[ref]/*` Next
+// stubs — those stubs signed outbound calls with `SUPABASE_SERVICE_KEY` and
+// `SUPABASE_URL`, which breaks the moment Studio needs to manage users on a
+// different project than the one Studio itself is logged into. Now every call
+// resolves the per-project backend via `getProjectBackend(ref)` first, then
+// dispatches to that backend's GoTrue using its service_role key.
+//
+// Routes (incoming is `/api/platform/auth/{ref}/...`; Kong strip_path: false
+// on the `platform-auth` route leaves the full path intact; traffic-one's
+// `index.ts` trims the `/api/platform/auth` prefix, so `path` here starts
+// at `/{ref}`):
+//
+// POST /{ref}/users -> /auth/v1/admin/users
+// PATCH /{ref}/users/{id} -> /auth/v1/admin/users/{id}
+// DELETE /{ref}/users/{id} -> /auth/v1/admin/users/{id}
+// DELETE /{ref}/users/{id}/factors -> list + delete all MFA factors
+// POST /{ref}/invite -> /auth/v1/invite
+// POST /{ref}/magiclink -> /auth/v1/magiclink
+// POST /{ref}/recover -> /auth/v1/recover
+// POST /{ref}/otp -> /auth/v1/otp
+// POST /{ref}/validate/spam -> local heuristic stub
+//
+// Every successful mutation emits a `traffic.audit_logs` row; sensitive body
+// fields (`password`) are stripped before they land in `action_metadata`.
+//
+// ─────────────────────────────────────────────────────────────────────────────
+
+// ── Response helpers ────────────────────────────────────────
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return Response.json(body, { status, headers: corsHeaders })
+}
+
+function errorResponse(message: string, status = 400, extra?: Record): Response {
+ return jsonResponse({ ...(extra ?? {}), message }, status)
+}
+
+function methodNotAllowed(): Response {
+ return errorResponse('Method not allowed', 405)
+}
+
+function notFound(message = 'Not Found'): Response {
+ return errorResponse(message, 404)
+}
+
+// M6: local `notProvisioned(ref, missing)` used to emit a bespoke 501
+// body ({ code, message }) that was missing the `missing` field most
+// other dispatchers expose. All project routes now share
+// `notProvisionedResponse` so Studio can switch on a single canonical
+// shape. See M6 in the plan.
+
+// ── Audit helpers ───────────────────────────────────────────
+
+interface AuditContext {
+ email: string
+ ip: string
+ method: string
+ route: string
+ organizationId?: number | null
+}
+
+const SAFE_BODY_KEYS = new Set([
+ 'email',
+ 'phone',
+ 'ban_duration',
+ 'email_confirm',
+ 'phone_confirm',
+ 'app_metadata',
+ 'user_metadata',
+ 'redirectTo',
+ 'redirect_to',
+])
+
+function sanitizeBodyForAudit(body: unknown): Record {
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return {}
+ const out: Record = {}
+ for (const [k, v] of Object.entries(body)) {
+ if (!SAFE_BODY_KEYS.has(k)) continue
+ if (k === 'email' && typeof v === 'string') {
+ // Record only the domain part — raw addresses get emitted by GoTrue's
+ // own audit log if the operator forwards it. We log the action + domain
+ // for platform-side auditing without duplicating PII.
+ const at = v.lastIndexOf('@')
+ out[k] = at >= 0 ? `***${v.slice(at)}` : '***'
+ } else {
+ out[k] = v
+ }
+ }
+ return out
+}
+
+async function writeAuditLog(
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ action: string,
+ projectRef: string,
+ ctx: AuditContext,
+ bodySummary: Record,
+ status: number,
+): Promise {
+ const connection = await pool.connect()
+ try {
+ await connection.queryObject`
+ INSERT INTO traffic.audit_logs (
+ id, organization_id, profile_id, action_name, action_metadata,
+ actor_id, actor_type, actor_metadata,
+ target_description, target_metadata, occurred_at
+ ) VALUES (
+ gen_random_uuid(), ${ctx.organizationId ?? null}, ${profileId}, ${action},
+ ${JSON.stringify([{ method: ctx.method, route: ctx.route, status }])}::jsonb,
+ ${gotrueId}, 'user',
+ ${JSON.stringify([{ email: ctx.email, ip: ctx.ip }])}::jsonb,
+ ${'project auth ref: ' + projectRef},
+ ${JSON.stringify({ ref: projectRef, body: bodySummary })}::jsonb,
+ now()
+ )
+ `
+ } finally {
+ connection.release()
+ }
+}
+
+// ── Body helpers ────────────────────────────────────────────
+
+// M11 / L9: returns either the parsed JSON body OR a pre-built Response
+// (413 when the caller exceeds `maxBytes`, 400 when the body is not valid
+// JSON or not a JSON object). Dispatchers branch on `instanceof Response`
+// before using the parsed payload. An empty body remains `{}` because
+// several downstream endpoints (e.g. admin magic-link / recover) legitimately
+// accept empty bodies and rely on default behaviour.
+//
+// L9: before this refactor, malformed JSON silently became `{}`, which let
+// the handler proceed as if the caller had sent a well-formed but empty
+// object — masking client bugs and making the `if (body.foo)` branches
+// behave unpredictably. Arrays / primitives now also 400 rather than being
+// coerced to `{}`, since no downstream admin endpoint actually expects
+// either.
+function invalidJsonBodyResponse(message: string): Response {
+ return Response.json({ code: 'invalid_body', message }, { status: 400, headers: corsHeaders })
+}
+
+async function readJsonBody(
+ req: Request,
+ maxBytes: number = MAX_BODY_AUTH_ADMIN,
+): Promise | Response> {
+ let text: string
+ try {
+ text = await readBodyWithLimit(req, maxBytes)
+ } catch (tooLarge) {
+ if (tooLarge instanceof Response) return tooLarge
+ throw tooLarge
+ }
+ if (!text) return {}
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(text)
+ } catch {
+ return invalidJsonBodyResponse('Body must be valid JSON')
+ }
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return invalidJsonBodyResponse('Body must be a JSON object')
+ }
+ return parsed as Record
+}
+
+// ── GoTrue admin factor listing ─────────────────────────────
+
+interface GoTrueFactor {
+ id: string
+ friendly_name?: string
+ factor_type?: string
+ status?: string
+}
+
+type FactorListOutcome =
+ | { ok: true; factors: GoTrueFactor[] }
+ | { ok: false; status: number; reason: 'upstream_error' | 'malformed_body' | 'network' }
+
+// H5: the old implementation swallowed every failure by returning `[]`,
+// which caused the factor-clear handler to audit a success on 200 while
+// actually having done nothing (or having returned 5xx from GoTrue).
+// Callers now see an explicit outcome and can translate failures into a
+// 502 without auditing.
+async function listUserFactors(
+ backend: ProjectBackend,
+ userId: string,
+ fetchImpl: FetchLike,
+): Promise {
+ let res: Response
+ try {
+ res = await fetchProjectJson(backend, `/auth/v1/admin/users/${userId}/factors`, {}, fetchImpl)
+ } catch (err) {
+ console.error('listUserFactors: network error:', err)
+ return { ok: false, status: 0, reason: 'network' }
+ }
+ if (!res.ok) {
+ await res.body?.cancel()
+ return { ok: false, status: res.status, reason: 'upstream_error' }
+ }
+ try {
+ const body = (await res.json()) as { factors?: GoTrueFactor[] } | GoTrueFactor[] | null
+ if (Array.isArray(body)) return { ok: true, factors: body }
+ if (body && Array.isArray(body.factors)) return { ok: true, factors: body.factors }
+ return { ok: true, factors: [] }
+ } catch {
+ return { ok: false, status: res.status, reason: 'malformed_body' }
+ }
+}
+
+// ── Spam scoring (proxy + heuristic fallback) ──────────────
+//
+// The ValidateSpamResponse shape Studio expects is
+// `{ rules: [{ name, desc, score }] }`. Supabase Cloud runs this behind a
+// dedicated SpamAssassin microservice; open-source GoTrue has no such
+// endpoint. We try three paths in order (M4 decision):
+//
+// 1. If `TRAFFIC_SPAM_CHECK_URL` is configured, POST to that URL — this
+// lets operators plug in their own scorer (SpamAssassin, rspamd, an
+// LLM guardrail, …) without rebuilding traffic-one.
+// 2. Otherwise, try `{backend.endpoint}/auth/v1/validate/spam` on the
+// project's GoTrue — cloud / forks may implement it; if they do, we
+// surface those rules untouched.
+// 3. Fall back to a minimal keyword heuristic so Studio's "Check for
+// spam" button always returns a usable shape (never a 501/502).
+//
+// The heuristic is intentional (deterministic, offline, zero network). It
+// is NOT a substitute for SpamAssassin and is documented as such in
+// `traffic-one/README.md` and `ARCHITECTURE.md`.
+
+interface SpamRule {
+ name: string
+ desc: string
+ score: number
+}
+
+function checkSpamHeuristic(subject: string, content: string): SpamRule[] {
+ const rules: SpamRule[] = []
+ const lowered = (subject + '\n' + content).toLowerCase()
+ const patterns: [RegExp, string, string, number][] = [
+ [/\bviagra\b/i, 'VIAGRA', 'Contains pharmaceutical spam keyword', 4.0],
+ [/\b(?:lottery|prize|winner|jackpot)\b/i, 'LOTTERY', 'Mentions lottery / prize keywords', 2.5],
+ [/\bfree\b.*\b(money|cash|gift)\b/i, 'FREE_MONEY', 'Offers free money / cash / gifts', 3.0],
+ [/\bclick here\b/i, 'CLICK_HERE', 'Uses generic "click here" call-to-action', 0.5],
+ [/[A-Z]{15,}/, 'ALL_CAPS', 'Contains a long ALL-CAPS run', 0.8],
+ ]
+ for (const [re, name, desc, score] of patterns) {
+ if (re.test(lowered)) rules.push({ name, desc, score })
+ }
+ if (subject.trim().length === 0) {
+ rules.push({ name: 'EMPTY_SUBJECT', desc: 'Subject line is empty', score: 1.0 })
+ }
+ return rules
+}
+
+type SpamSource = 'external' | 'gotrue' | 'heuristic'
+
+interface SpamResult {
+ rules: SpamRule[]
+ source: SpamSource
+}
+
+async function runSpamScore(
+ backend: ProjectBackend,
+ subject: string,
+ content: string,
+ fetchImpl: FetchLike,
+): Promise {
+ const externalUrl = Deno.env.get('TRAFFIC_SPAM_CHECK_URL')?.trim()
+ const bodyPayload = JSON.stringify({ subject, content })
+
+ // (1) external scorer if configured. We intentionally send ONLY
+ // {subject, content} so the external service never sees project refs
+ // or credentials.
+ if (externalUrl) {
+ try {
+ const res = await fetchImpl(externalUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: bodyPayload,
+ })
+ if (res.ok) {
+ const data = (await res.json()) as { rules?: SpamRule[] } | null
+ if (data && Array.isArray(data.rules)) {
+ return { rules: data.rules, source: 'external' }
+ }
+ } else {
+ await res.body?.cancel()
+ }
+ } catch (err) {
+ console.warn('runSpamScore: external scorer failed, falling back:', err)
+ }
+ }
+
+ // (2) per-project GoTrue — Cloud / forks may ship this endpoint.
+ try {
+ const res = await fetchProjectJson(
+ backend,
+ '/auth/v1/validate/spam',
+ { method: 'POST', body: bodyPayload },
+ fetchImpl,
+ )
+ if (res.ok) {
+ const data = (await res.json()) as { rules?: SpamRule[] } | null
+ if (data && Array.isArray(data.rules)) {
+ return { rules: data.rules, source: 'gotrue' }
+ }
+ } else {
+ await res.body?.cancel()
+ }
+ } catch (err) {
+ console.warn('runSpamScore: gotrue probe failed, falling back to heuristic:', err)
+ }
+
+ // (3) deterministic offline heuristic.
+ return { rules: checkSpamHeuristic(subject, content), source: 'heuristic' }
+}
+
+// ── Main handler ────────────────────────────────────────────
+
+export async function handleProjectAuthAdmin(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+ // H4: injectable fetch so unit tests can assert outbound URL / method /
+ // headers without a live GoTrue. Defaults to global `fetch` in prod.
+ fetchImpl: FetchLike = fetch,
+): Promise {
+ // Path here begins with `/{ref}/...` (index.ts strips `/api/platform/auth`).
+ const refMatch = path.match(/^\/([^/]+)(\/.*)$/)
+ if (!refMatch) return notFound()
+
+ const ref = refMatch[1]
+ const subPath = refMatch[2]
+
+ // L4: bail on malformed refs before we touch the DB. A 20-char
+ // lowercase-alphanumeric check matches both `generateRef()` output
+ // (hex) and cloud-style refs, and rejects paths with `..`, encoded
+ // slashes, etc. before they ever reach `getProjectByRef`.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) return notFound('Project not found')
+
+ const auditContext: AuditContext = {
+ email,
+ ip: getClientIp(req),
+ method,
+ route: '/api/platform/auth' + path,
+ organizationId: project.organization_id,
+ }
+
+ let backend: ProjectBackend
+ try {
+ backend = await getProjectBackend(ref, pool)
+ } catch (err) {
+ if (err instanceof ProjectBackendNotProvisionedError) {
+ return notProvisionedResponse(err)
+ }
+ throw err
+ }
+
+ // ── POST /{ref}/users ─────────────────────────────────────
+ if (subPath === '/users' && method === 'POST') {
+ const body = await readJsonBody(req)
+ if (body instanceof Response) return body
+ const res = await fetchProjectJson(
+ backend,
+ '/auth/v1/admin/users',
+ {
+ method: 'POST',
+ body: JSON.stringify(body),
+ },
+ fetchImpl,
+ )
+ const text = await res.text()
+ if (res.ok) {
+ await writeAuditLog(
+ pool,
+ profileId,
+ gotrueId,
+ 'project.app_user_create',
+ ref,
+ auditContext,
+ sanitizeBodyForAudit(body),
+ res.status,
+ )
+ }
+ return new Response(text, {
+ status: res.status,
+ headers: {
+ ...corsHeaders,
+ 'Content-Type': res.headers.get('Content-Type') ?? 'application/json',
+ },
+ })
+ }
+
+ // ── PATCH/DELETE /{ref}/users/{id} ────────────────────────
+ const userIdMatch = subPath.match(/^\/users\/([^/]+)$/)
+ if (userIdMatch) {
+ const userId = userIdMatch[1]
+ if (method === 'PATCH') {
+ const body = await readJsonBody(req)
+ if (body instanceof Response) return body
+ const res = await fetchProjectJson(
+ backend,
+ `/auth/v1/admin/users/${userId}`,
+ {
+ method: 'PUT',
+ body: JSON.stringify(body),
+ },
+ fetchImpl,
+ )
+ const text = await res.text()
+ if (res.ok) {
+ await writeAuditLog(
+ pool,
+ profileId,
+ gotrueId,
+ 'project.app_user_update',
+ ref,
+ auditContext,
+ { ...sanitizeBodyForAudit(body), user_id: userId },
+ res.status,
+ )
+ }
+ return new Response(text, {
+ status: res.status,
+ headers: {
+ ...corsHeaders,
+ 'Content-Type': res.headers.get('Content-Type') ?? 'application/json',
+ },
+ })
+ }
+ if (method === 'DELETE') {
+ const res = await fetchProjectJson(
+ backend,
+ `/auth/v1/admin/users/${userId}`,
+ { method: 'DELETE' },
+ fetchImpl,
+ )
+ // Swallow the response body — the Next stub returned `data` verbatim,
+ // which is typically `{}` from GoTrue on 200.
+ const text = (await res.text()) || '{}'
+ if (res.ok) {
+ await writeAuditLog(
+ pool,
+ profileId,
+ gotrueId,
+ 'project.app_user_delete',
+ ref,
+ auditContext,
+ { user_id: userId },
+ res.status,
+ )
+ }
+ return new Response(text, {
+ status: res.status,
+ headers: {
+ ...corsHeaders,
+ 'Content-Type': res.headers.get('Content-Type') ?? 'application/json',
+ },
+ })
+ }
+ return methodNotAllowed()
+ }
+
+ // ── DELETE /{ref}/users/{id}/factors ──────────────────────
+ const factorsMatch = subPath.match(/^\/users\/([^/]+)\/factors$/)
+ if (factorsMatch) {
+ if (method !== 'DELETE') return methodNotAllowed()
+ const userId = factorsMatch[1]
+
+ // H5: when the LIST step fails (upstream 5xx, network, malformed body)
+ // return 502 and skip the success audit. Prior code returned `[]` for
+ // every failure, which caused the handler to audit a successful
+ // "mfa_factors_delete" on 200 when in reality nothing happened (the
+ // list call itself errored). Operators would see a green audit row for
+ // a no-op, then be surprised when the user still had factors.
+ const listOutcome = await listUserFactors(backend, userId, fetchImpl)
+ if (listOutcome.ok === false) {
+ return jsonResponse(
+ {
+ data: null,
+ error: {
+ message: 'Failed to list MFA factors',
+ reason: listOutcome.reason,
+ upstream_status: listOutcome.status,
+ },
+ },
+ 502,
+ )
+ }
+ const factors = listOutcome.factors
+
+ // GoTrue exposes factor deletion per-id. Fire them sequentially to keep
+ // ordering deterministic and preserve "first failure stops loop" (which
+ // matches the cloud dashboard semantics).
+ const results: Array<{ id: string; ok: boolean; status: number }> = []
+ for (const factor of factors) {
+ const res = await fetchProjectJson(
+ backend,
+ `/auth/v1/admin/users/${userId}/factors/${factor.id}`,
+ { method: 'DELETE' },
+ fetchImpl,
+ )
+ await res.body?.cancel()
+ results.push({ id: factor.id, ok: res.ok, status: res.status })
+ if (!res.ok) break
+ }
+ // H5: only audit the success path when EVERY per-factor DELETE
+ // returned 2xx. Partial failures (one or more 5xx-on-DELETE) skip the
+ // audit write so the trail doesn't record a false "cleared" row —
+ // consistent with how the other mutation handlers (user_create,
+ // user_delete, invite, magiclink, recover, otp) only write the audit
+ // log when `res.ok` is true. The dispatcher still surfaces the 502 so
+ // Studio can show the failure to the operator.
+ const allOk = results.every((r) => r.ok)
+ const status = allOk ? 200 : 502
+ if (allOk) {
+ await writeAuditLog(
+ pool,
+ profileId,
+ gotrueId,
+ 'project.app_user_mfa_factors_delete',
+ ref,
+ auditContext,
+ { user_id: userId, factors: results.map((r) => r.id), deleted: true },
+ status,
+ )
+ }
+ // Match the Next stub shape: `{ data: null, error: null }`.
+ return jsonResponse({ data: null, error: allOk ? null : { results } }, status)
+ }
+
+ // ── POST /{ref}/invite, /magiclink, /recover, /otp ─────────
+ const simplePost: Array<[string, string, string]> = [
+ ['/invite', '/auth/v1/invite', 'project.app_user_invite'],
+ ['/magiclink', '/auth/v1/magiclink', 'project.app_user_magiclink'],
+ ['/recover', '/auth/v1/recover', 'project.app_user_recover'],
+ ['/otp', '/auth/v1/otp', 'project.app_user_otp'],
+ ]
+ for (const [fromPath, toPath, action] of simplePost) {
+ if (subPath === fromPath) {
+ if (method !== 'POST') return methodNotAllowed()
+ const body = await readJsonBody(req)
+ if (body instanceof Response) return body
+ const res = await fetchProjectJson(
+ backend,
+ toPath,
+ {
+ method: 'POST',
+ body: JSON.stringify(body),
+ },
+ fetchImpl,
+ )
+ const text = await res.text()
+ if (res.ok) {
+ await writeAuditLog(
+ pool,
+ profileId,
+ gotrueId,
+ action,
+ ref,
+ auditContext,
+ sanitizeBodyForAudit(body),
+ res.status,
+ )
+ }
+ return new Response(text, {
+ status: res.status,
+ headers: {
+ ...corsHeaders,
+ 'Content-Type': res.headers.get('Content-Type') ?? 'application/json',
+ },
+ })
+ }
+ }
+
+ // ── POST /{ref}/validate/spam ─────────────────────────────
+ if (subPath === '/validate/spam') {
+ if (method !== 'POST') return methodNotAllowed()
+ const body = await readJsonBody(req)
+ if (body instanceof Response) return body
+ const subject = typeof body.subject === 'string' ? body.subject : ''
+ const content = typeof body.content === 'string' ? body.content : ''
+ // M4: try external scorer → project GoTrue → local heuristic. The
+ // audit log records which backend actually produced the rules so
+ // operators can tell whether cloud scoring or the fallback was used.
+ const { rules, source } = await runSpamScore(backend, subject, content, fetchImpl)
+ await writeAuditLog(
+ pool,
+ profileId,
+ gotrueId,
+ 'project.app_user_validate_spam',
+ ref,
+ auditContext,
+ {
+ subject_len: subject.length,
+ content_len: content.length,
+ rule_count: rules.length,
+ source,
+ },
+ 200,
+ )
+ return jsonResponse({ rules })
+ }
+
+ return notFound()
+}
diff --git a/traffic-one/functions/routes/project-auth.ts b/traffic-one/functions/routes/project-auth.ts
new file mode 100644
index 0000000000000..627f528e6cac8
--- /dev/null
+++ b/traffic-one/functions/routes/project-auth.ts
@@ -0,0 +1,521 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import { createSecret, deleteSecret, listSecretNames } from '../services/project-secrets.service.ts'
+import {
+ createThirdPartyAuth,
+ deleteThirdPartyAuth,
+ getThirdPartyAuth,
+ InvalidThirdPartyAuthInputError,
+ listThirdPartyAuth,
+ type ThirdPartyAuthInput,
+ type ThirdPartyAuthRow,
+} from '../services/project-third-party-auth.service.ts'
+import { getProjectByRef } from '../services/project.service.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+import { assertValidRef } from '../utils/ref-validation.ts'
+
+// Handler for /v1/projects/{ref}/* auth-related paths:
+// /{ref}/config/auth/third-party-auth[/{id}] (GET, POST, DELETE)
+// /{ref}/ssl-enforcement (GET, PUT)
+// /{ref}/secrets (GET, POST, DELETE)
+//
+// Routed via handleProjectHealth in routes/projects.ts.
+
+const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+
+type SslDatabaseMode = 'enforced' | 'not_enforced'
+
+interface SslEnforcementStorage {
+ database?: SslDatabaseMode
+}
+
+interface ProjectConfigSslRow {
+ ssl_enforcement: SslEnforcementStorage | null
+}
+
+interface AuditContext {
+ email: string
+ ip: string
+ method: string
+ route: string
+}
+
+function notFoundResponse(message = 'Not Found'): Response {
+ return Response.json({ message }, { status: 404, headers: corsHeaders })
+}
+
+function invalidBodyResponse(message = 'Invalid request body'): Response {
+ return Response.json({ message }, { status: 400, headers: corsHeaders })
+}
+
+function methodNotAllowedResponse(): Response {
+ return Response.json({ message: 'Method not allowed' }, { status: 405, headers: corsHeaders })
+}
+
+async function parseJson(req: Request): Promise {
+ try {
+ return await req.json()
+ } catch {
+ return null
+ }
+}
+
+function serializeThirdPartyAuth(row: ThirdPartyAuthRow): Record {
+ return {
+ id: row.id,
+ type: row.type,
+ oidc_issuer_url: row.oidc_issuer_url,
+ jwks_url: row.jwks_url,
+ custom_jwks: row.custom_jwks,
+ resolved_jwks: row.resolved_jwks,
+ inserted_at: row.inserted_at,
+ updated_at: row.updated_at,
+ }
+}
+
+// ── Third-party auth handlers ──────────────────────────────
+
+async function handleThirdPartyAuthCollection(
+ req: Request,
+ method: string,
+ pool: Pool,
+ projectRef: string,
+ organizationId: number,
+ profileId: number,
+ gotrueId: string,
+ auditContext: AuditContext,
+): Promise {
+ if (method === 'GET') {
+ const rows = await listThirdPartyAuth(pool, projectRef)
+ return Response.json(rows.map(serializeThirdPartyAuth), { headers: corsHeaders })
+ }
+
+ if (method === 'POST') {
+ const body = await parseJson(req)
+ if (!body || typeof body !== 'object') {
+ return invalidBodyResponse('Body must be a JSON object')
+ }
+ const input = body as ThirdPartyAuthInput
+ try {
+ const row = await createThirdPartyAuth(
+ pool,
+ projectRef,
+ organizationId,
+ input,
+ profileId,
+ gotrueId,
+ auditContext,
+ )
+ return Response.json(serializeThirdPartyAuth(row), {
+ status: 201,
+ headers: corsHeaders,
+ })
+ } catch (err) {
+ if (err instanceof InvalidThirdPartyAuthInputError) {
+ return invalidBodyResponse(err.message)
+ }
+ throw err
+ }
+ }
+
+ return methodNotAllowedResponse()
+}
+
+async function handleThirdPartyAuthItem(
+ method: string,
+ pool: Pool,
+ projectRef: string,
+ organizationId: number,
+ id: string,
+ profileId: number,
+ gotrueId: string,
+ auditContext: AuditContext,
+): Promise {
+ if (!UUID_PATTERN.test(id)) {
+ return notFoundResponse('Integration not found')
+ }
+
+ if (method === 'GET') {
+ const row = await getThirdPartyAuth(pool, projectRef, id)
+ if (!row) return notFoundResponse('Integration not found')
+ return Response.json(serializeThirdPartyAuth(row), { headers: corsHeaders })
+ }
+
+ if (method === 'DELETE') {
+ const row = await deleteThirdPartyAuth(
+ pool,
+ projectRef,
+ organizationId,
+ id,
+ profileId,
+ gotrueId,
+ auditContext,
+ )
+ if (!row) return notFoundResponse('Integration not found')
+ return Response.json(serializeThirdPartyAuth(row), { headers: corsHeaders })
+ }
+
+ return methodNotAllowedResponse()
+}
+
+// ── SSL enforcement ────────────────────────────────────────
+
+async function readSslEnforcement(pool: Pool, projectRef: string): Promise {
+ const connection = await pool.connect()
+ try {
+ const result = await connection.queryObject`
+ SELECT ssl_enforcement
+ FROM traffic.project_config
+ WHERE project_ref = ${projectRef}
+ `
+ const stored = result.rows[0]?.ssl_enforcement ?? null
+ if (stored && (stored.database === 'enforced' || stored.database === 'not_enforced')) {
+ return stored.database
+ }
+ return 'enforced'
+ } finally {
+ connection.release()
+ }
+}
+
+async function writeSslEnforcement(
+ pool: Pool,
+ projectRef: string,
+ organizationId: number,
+ mode: SslDatabaseMode,
+ profileId: number,
+ gotrueId: string,
+ auditContext: AuditContext,
+): Promise {
+ const connection = await pool.connect()
+ try {
+ const tx = connection.createTransaction(`ssl_enforcement_update_${projectRef}_${Date.now()}`)
+ await tx.begin()
+
+ await tx.queryObject`
+ INSERT INTO traffic.project_config (project_ref, ssl_enforcement, updated_at)
+ VALUES (${projectRef}, ${JSON.stringify({ database: mode })}::jsonb, now())
+ ON CONFLICT (project_ref) DO UPDATE
+ SET ssl_enforcement = EXCLUDED.ssl_enforcement,
+ updated_at = now()
+ `
+
+ await tx.queryObject`
+ INSERT INTO traffic.audit_logs (
+ id, profile_id, organization_id, action_name, action_metadata,
+ actor_id, actor_type, actor_metadata,
+ target_description, target_metadata, occurred_at
+ ) VALUES (
+ gen_random_uuid(), ${profileId}, ${organizationId}, 'project.ssl_enforcement_updated',
+ ${
+ JSON.stringify([{ method: auditContext.method, route: auditContext.route, status: 200 }])
+ }::jsonb,
+ ${gotrueId}, 'user',
+ ${JSON.stringify([{ email: auditContext.email, ip: auditContext.ip }])}::jsonb,
+ ${'project_config ssl_enforcement (ref: ' + projectRef + ')'},
+ ${JSON.stringify({ database: mode })}::jsonb,
+ now()
+ )
+ `
+
+ await tx.commit()
+ } finally {
+ connection.release()
+ }
+}
+
+async function handleSslEnforcement(
+ req: Request,
+ method: string,
+ pool: Pool,
+ projectRef: string,
+ organizationId: number,
+ profileId: number,
+ gotrueId: string,
+ auditContext: AuditContext,
+): Promise {
+ if (method === 'GET') {
+ const mode = await readSslEnforcement(pool, projectRef)
+ return Response.json(
+ {
+ currentConfig: { database: mode },
+ appliedSuccessfully: true,
+ },
+ { headers: corsHeaders },
+ )
+ }
+
+ if (method === 'PUT') {
+ const body = await parseJson(req)
+ if (!body || typeof body !== 'object') {
+ return invalidBodyResponse('Body must be a JSON object')
+ }
+ const requested = (body as { requestedConfig?: { database?: unknown } }).requestedConfig
+ const db = requested?.database
+ if (db !== 'enforced' && db !== 'not_enforced') {
+ return invalidBodyResponse("requestedConfig.database must be 'enforced' or 'not_enforced'")
+ }
+ await writeSslEnforcement(
+ pool,
+ projectRef,
+ organizationId,
+ db,
+ profileId,
+ gotrueId,
+ auditContext,
+ )
+ return Response.json(
+ {
+ currentConfig: { database: db },
+ appliedSuccessfully: true,
+ },
+ { headers: corsHeaders },
+ )
+ }
+
+ return methodNotAllowedResponse()
+}
+
+// ── Secrets ────────────────────────────────────────────────
+
+interface SecretPayload {
+ name: string
+ value: string
+}
+
+function extractSecretPayloads(body: unknown): SecretPayload[] | null {
+ const accept = (entry: unknown): SecretPayload | null => {
+ if (!entry || typeof entry !== 'object') return null
+ const candidate = entry as Record
+ const name = candidate.name
+ const value = candidate.value
+ if (typeof name !== 'string' || name.length === 0) return null
+ if (typeof value !== 'string') return null
+ return { name, value }
+ }
+
+ if (Array.isArray(body)) {
+ const out: SecretPayload[] = []
+ for (const entry of body) {
+ const parsed = accept(entry)
+ if (!parsed) return null
+ out.push(parsed)
+ }
+ return out
+ }
+ const single = accept(body)
+ return single ? [single] : null
+}
+
+function extractSecretNames(body: unknown): string[] | null {
+ const fromArray = (arr: unknown): string[] | null => {
+ if (!Array.isArray(arr)) return null
+ const out: string[] = []
+ for (const entry of arr) {
+ if (typeof entry !== 'string' || entry.length === 0) return null
+ out.push(entry)
+ }
+ return out
+ }
+
+ if (Array.isArray(body)) return fromArray(body)
+ if (body && typeof body === 'object') {
+ return fromArray((body as { names?: unknown }).names)
+ }
+ return null
+}
+
+async function insertSecretAudit(
+ pool: Pool,
+ action: 'project.secret_set' | 'project.secret_deleted',
+ projectRef: string,
+ organizationId: number,
+ secretName: string,
+ profileId: number,
+ gotrueId: string,
+ auditContext: AuditContext,
+): Promise {
+ const connection = await pool.connect()
+ try {
+ const status = action === 'project.secret_set' ? 201 : 200
+ await connection.queryObject`
+ INSERT INTO traffic.audit_logs (
+ id, profile_id, organization_id, action_name, action_metadata,
+ actor_id, actor_type, actor_metadata,
+ target_description, target_metadata, occurred_at
+ ) VALUES (
+ gen_random_uuid(), ${profileId}, ${organizationId}, ${action},
+ ${
+ JSON.stringify([{ method: auditContext.method, route: auditContext.route, status }])
+ }::jsonb,
+ ${gotrueId}, 'user',
+ ${JSON.stringify([{ email: auditContext.email, ip: auditContext.ip }])}::jsonb,
+ ${'project_secrets (ref: ' + projectRef + ', name: ' + secretName + ')'},
+ ${JSON.stringify({ name: secretName })}::jsonb,
+ now()
+ )
+ `
+ } finally {
+ connection.release()
+ }
+}
+
+async function handleSecrets(
+ req: Request,
+ method: string,
+ pool: Pool,
+ projectRef: string,
+ organizationId: number,
+ profileId: number,
+ gotrueId: string,
+ auditContext: AuditContext,
+): Promise {
+ if (method === 'GET') {
+ const names = await listSecretNames(pool, projectRef)
+ return Response.json(names, { headers: corsHeaders })
+ }
+
+ if (method === 'POST') {
+ const body = await parseJson(req)
+ const payloads = extractSecretPayloads(body)
+ if (!payloads || payloads.length === 0) {
+ return invalidBodyResponse('Body must be { name, value } or an array of { name, value }')
+ }
+
+ const results: { name: string; status: 'created' | 'updated' }[] = []
+ for (const payload of payloads) {
+ const result = await createSecret(pool, projectRef, payload.name, payload.value)
+ await insertSecretAudit(
+ pool,
+ 'project.secret_set',
+ projectRef,
+ organizationId,
+ payload.name,
+ profileId,
+ gotrueId,
+ auditContext,
+ )
+ results.push({ name: result.name, status: result.status })
+ }
+
+ return Response.json({ secrets: results }, { status: 201, headers: corsHeaders })
+ }
+
+ if (method === 'DELETE') {
+ const body = await parseJson(req)
+ const names = extractSecretNames(body)
+ if (!names || names.length === 0) {
+ return invalidBodyResponse('Body must be a string[] of names or { names: string[] }')
+ }
+
+ const deleted: string[] = []
+ for (const name of names) {
+ const removed = await deleteSecret(pool, projectRef, name)
+ if (removed) {
+ await insertSecretAudit(
+ pool,
+ 'project.secret_deleted',
+ projectRef,
+ organizationId,
+ name,
+ profileId,
+ gotrueId,
+ auditContext,
+ )
+ deleted.push(name)
+ }
+ }
+
+ return Response.json({ deleted }, { headers: corsHeaders })
+ }
+
+ return methodNotAllowedResponse()
+}
+
+// ── Dispatcher ─────────────────────────────────────────────
+
+export async function handleProjectAuth(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+): Promise {
+ const refMatch = path.match(/^\/([^/]+)(\/.*)?$/)
+ if (!refMatch) return notFoundResponse()
+ const ref = refMatch[1]
+ const subPath = refMatch[2] || ''
+
+ // L4: reject malformed refs before hitting the DB.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) return notFoundResponse('Project not found')
+
+ const ip = getClientIp(req)
+ const auditContext: AuditContext = {
+ email,
+ ip,
+ method,
+ route: '/v1/projects/' + ref + subPath,
+ }
+
+ if (subPath === '/config/auth/third-party-auth') {
+ return handleThirdPartyAuthCollection(
+ req,
+ method,
+ pool,
+ ref,
+ project.organization_id,
+ profileId,
+ gotrueId,
+ auditContext,
+ )
+ }
+
+ const itemMatch = subPath.match(/^\/config\/auth\/third-party-auth\/([^/]+)$/)
+ if (itemMatch) {
+ return handleThirdPartyAuthItem(
+ method,
+ pool,
+ ref,
+ project.organization_id,
+ itemMatch[1],
+ profileId,
+ gotrueId,
+ auditContext,
+ )
+ }
+
+ if (subPath === '/ssl-enforcement') {
+ return handleSslEnforcement(
+ req,
+ method,
+ pool,
+ ref,
+ project.organization_id,
+ profileId,
+ gotrueId,
+ auditContext,
+ )
+ }
+
+ if (subPath === '/secrets') {
+ return handleSecrets(
+ req,
+ method,
+ pool,
+ ref,
+ project.organization_id,
+ profileId,
+ gotrueId,
+ auditContext,
+ )
+ }
+
+ return notFoundResponse()
+}
diff --git a/traffic-one/functions/routes/project-config.ts b/traffic-one/functions/routes/project-config.ts
new file mode 100644
index 0000000000000..e4f7b6645726f
--- /dev/null
+++ b/traffic-one/functions/routes/project-config.ts
@@ -0,0 +1,312 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import {
+ getProjectBackend,
+ ProjectBackendNotProvisionedError,
+} from '../services/project-backend.service.ts'
+import {
+ type ConfigSection,
+ deleteLintException,
+ getConfigSection,
+ getRotationStatus,
+ InvalidSensitivityError,
+ listLintExceptions,
+ rotateJwtSecret,
+ type SectionColumn,
+ SENSITIVITY_VALUES,
+ updateConfigSection,
+ updateDbPassword,
+ updateProjectSensitivity,
+ upsertLintException,
+} from '../services/project-config.service.ts'
+import { getProjectByRef } from '../services/project.service.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+import { notProvisionedResponse } from '../utils/project-backend-response.ts'
+import { assertValidRef } from '../utils/ref-validation.ts'
+
+// ── Response helpers ───────────────────────────────────────
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return Response.json(body, { status, headers: corsHeaders })
+}
+
+function notFound(message = 'Not Found'): Response {
+ return jsonResponse({ message }, 404)
+}
+
+function badRequest(message: string): Response {
+ return jsonResponse({ message }, 400)
+}
+
+function methodNotAllowed(): Response {
+ return jsonResponse({ message: 'Method not allowed' }, 405)
+}
+
+// ── Request helpers ────────────────────────────────────────
+
+async function readJsonBody(req: Request): Promise> {
+ try {
+ const text = await req.text()
+ if (!text) return {}
+ const parsed = JSON.parse(text)
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
+ ? (parsed as Record)
+ : {}
+ } catch {
+ return {}
+ }
+}
+
+function clientIp(req: Request): string {
+ return getClientIp(req)
+}
+
+function asStringRecord(value: unknown): Record {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
+ return value as Record
+}
+
+// ── Dispatch ───────────────────────────────────────────────
+
+export async function handleProjectConfig(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+): Promise {
+ const refMatch = path.match(/^\/([^/]+)(\/.*)$/)
+ if (!refMatch) {
+ return notFound()
+ }
+
+ const ref = refMatch[1]
+ const subPath = refMatch[2]
+
+ // L4: reject malformed refs before hitting the DB.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) {
+ return notFound('Project not found')
+ }
+
+ const orgId = project.organization_id
+ const auditContext = {
+ email,
+ ip: clientIp(req),
+ method,
+ route: '/projects' + path,
+ }
+
+ // ── /config/postgrest|storage|realtime|pgbouncer ─────────
+ const sectionMatch = subPath.match(/^\/config\/(postgrest|storage|realtime|pgbouncer)$/)
+ if (sectionMatch) {
+ const section = sectionMatch[1] as SectionColumn
+ if (method === 'GET') {
+ const data = await getConfigSection(pool, ref, section)
+ return jsonResponse(data)
+ }
+ if (method === 'PATCH') {
+ const body = await readJsonBody(req)
+ const data = await updateConfigSection(
+ pool,
+ ref,
+ section,
+ body,
+ profileId,
+ orgId,
+ gotrueId,
+ auditContext,
+ )
+ return jsonResponse(data)
+ }
+ return methodNotAllowed()
+ }
+
+ // ── /config/pgbouncer/status ─────────────────────────────
+ if (subPath === '/config/pgbouncer/status') {
+ if (method === 'GET') {
+ return jsonResponse({ enabled: true })
+ }
+ return methodNotAllowed()
+ }
+
+ // ── /config/secrets ──────────────────────────────────────
+ if (subPath === '/config/secrets') {
+ if (method === 'GET') {
+ const data = await getConfigSection(pool, ref, 'secrets' as ConfigSection)
+ return jsonResponse(data)
+ }
+ if (method === 'PATCH') {
+ const body = await readJsonBody(req)
+ const providedRequestId = typeof body.request_id === 'string'
+ ? body.request_id
+ : typeof body.requestId === 'string'
+ ? body.requestId
+ : null
+ const requestId = providedRequestId ?? crypto.randomUUID()
+ const state = await rotateJwtSecret(
+ pool,
+ ref,
+ requestId,
+ profileId,
+ orgId,
+ gotrueId,
+ auditContext,
+ )
+ return jsonResponse(state)
+ }
+ return methodNotAllowed()
+ }
+
+ // ── /config/secrets/update-status ────────────────────────
+ if (subPath === '/config/secrets/update-status') {
+ if (method === 'GET') {
+ const url = new URL(req.url)
+ const requestId = url.searchParams.get('request_id') ?? url.searchParams.get('requestId') ??
+ undefined
+ const state = await getRotationStatus(pool, ref, requestId ?? undefined)
+ if (!state) {
+ return jsonResponse({ status: 'idle', request_id: null })
+ }
+ return jsonResponse(state)
+ }
+ return methodNotAllowed()
+ }
+
+ // ── /settings/sensitivity ────────────────────────────────
+ if (subPath === '/settings/sensitivity') {
+ if (method === 'PATCH') {
+ const body = await readJsonBody(req)
+ const raw = body.sensitivity ?? body.level ?? body.value
+ if (typeof raw !== 'string') {
+ return badRequest(`sensitivity must be a string (one of: ${SENSITIVITY_VALUES.join(', ')})`)
+ }
+ try {
+ const result = await updateProjectSensitivity(
+ pool,
+ ref,
+ raw,
+ profileId,
+ orgId,
+ gotrueId,
+ auditContext,
+ )
+ return jsonResponse(result)
+ } catch (err) {
+ if (err instanceof InvalidSensitivityError) {
+ return badRequest(
+ `Invalid sensitivity. Expected one of: ${SENSITIVITY_VALUES.join(', ')}`,
+ )
+ }
+ throw err
+ }
+ }
+ return methodNotAllowed()
+ }
+
+ // ── /db-password ─────────────────────────────────────────
+ if (subPath === '/db-password') {
+ if (method === 'PATCH') {
+ const body = await readJsonBody(req)
+ const rawPassword = body.password ?? body.db_password ?? body.newPassword
+ if (typeof rawPassword !== 'string' || rawPassword.length === 0) {
+ return badRequest('password (non-empty string) is required')
+ }
+ let backend
+ try {
+ backend = await getProjectBackend(ref, pool)
+ } catch (err) {
+ if (err instanceof ProjectBackendNotProvisionedError) {
+ return notProvisionedResponse(err)
+ }
+ throw err
+ }
+ const outcome = await updateDbPassword(
+ pool,
+ ref,
+ backend,
+ rawPassword,
+ profileId,
+ orgId,
+ gotrueId,
+ auditContext,
+ )
+ // H3: surface `applied` so Studio (and CLI callers) can tell whether
+ // the ALTER ROLE actually reached the project DB or whether only the
+ // Vault-side credentials were rotated.
+ return jsonResponse({ result: outcome.result, applied: outcome.applied })
+ }
+ return methodNotAllowed()
+ }
+
+ // ── /notifications/advisor/exceptions ────────────────────
+ if (subPath === '/notifications/advisor/exceptions') {
+ if (method === 'GET') {
+ const list = await listLintExceptions(pool, ref)
+ return jsonResponse(list)
+ }
+
+ if (method === 'POST') {
+ const body = await readJsonBody(req)
+ const lintName = typeof body.lint_name === 'string'
+ ? body.lint_name
+ : typeof body.name === 'string'
+ ? body.name
+ : null
+ if (!lintName) {
+ return badRequest('lint_name is required')
+ }
+ const disabled = typeof body.disabled === 'boolean' ? body.disabled : true
+ const metadata = asStringRecord(body.metadata)
+ const exception = await upsertLintException(
+ pool,
+ ref,
+ lintName,
+ disabled,
+ metadata,
+ profileId,
+ orgId,
+ gotrueId,
+ auditContext,
+ )
+ return jsonResponse(exception, 201)
+ }
+
+ if (method === 'DELETE') {
+ const url = new URL(req.url)
+ const lintNameFromQuery = url.searchParams.get('lint_name') ?? url.searchParams.get('name')
+ let lintName: string | null = lintNameFromQuery
+ if (!lintName) {
+ const body = await readJsonBody(req)
+ if (typeof body.lint_name === 'string') lintName = body.lint_name
+ else if (typeof body.name === 'string') lintName = body.name
+ }
+ if (!lintName) {
+ return badRequest('lint_name (query or body) is required')
+ }
+ const deleted = await deleteLintException(
+ pool,
+ ref,
+ lintName,
+ profileId,
+ orgId,
+ gotrueId,
+ auditContext,
+ )
+ if (!deleted) {
+ return notFound('Lint exception not found')
+ }
+ return jsonResponse({ deleted: true, lint_name: lintName })
+ }
+
+ return methodNotAllowed()
+ }
+
+ return notFound()
+}
diff --git a/traffic-one/functions/routes/project-disk.ts b/traffic-one/functions/routes/project-disk.ts
new file mode 100644
index 0000000000000..630c1a568039a
--- /dev/null
+++ b/traffic-one/functions/routes/project-disk.ts
@@ -0,0 +1,200 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import { getProjectByRef } from '../services/project.service.ts'
+import { assertValidRef } from '../utils/ref-validation.ts'
+
+const DISK_UNSUPPORTED_MESSAGE =
+ 'Disk configuration changes are not available in self-hosted deployments'
+const RESIZE_UNSUPPORTED_MESSAGE = 'Project resize is not available in self-hosted deployments'
+
+function notSupportedResponse(message: string): Response {
+ return Response.json(
+ { code: 'self_hosted_unsupported', message },
+ { status: 501, headers: corsHeaders },
+ )
+}
+
+function notFoundResponse(message = 'Not Found'): Response {
+ return Response.json({ message }, { status: 404, headers: corsHeaders })
+}
+
+function methodNotAllowedResponse(): Response {
+ return Response.json({ message: 'Method not allowed' }, { status: 405, headers: corsHeaders })
+}
+
+function parseNumberEnv(value: string | undefined, fallback: number): number {
+ if (value === undefined || value === '') return fallback
+ const parsed = Number(value)
+ return Number.isFinite(parsed) ? parsed : fallback
+}
+
+interface DiskConfig {
+ size_gb: number
+ type: string
+ iops: number
+ throughput_mbps: number
+}
+
+function diskDefaults(): DiskConfig {
+ return {
+ size_gb: parseNumberEnv(Deno.env.get('LOCAL_DISK_SIZE_GB'), 8),
+ type: Deno.env.get('LOCAL_DISK_TYPE') || 'gp3',
+ iops: parseNumberEnv(Deno.env.get('LOCAL_DISK_IOPS'), 3000),
+ throughput_mbps: parseNumberEnv(Deno.env.get('LOCAL_DISK_THROUGHPUT_MBPS'), 125),
+ }
+}
+
+// Hard-coded fallback when the platform can't report disk usage.
+// Kept numeric + self-consistent so Studio's gauge renders sensibly.
+const DISK_UTIL_FALLBACK = { used_gb: 0.5, total_gb: 8, percent_used: 6.25 } as const
+
+async function computeDiskUtil(): Promise<{
+ used_gb: number
+ total_gb: number
+ percent_used: number
+}> {
+ try {
+ // Deno has no stable cross-platform disk-total API. Some runtimes expose a
+ // non-standard `Deno.statfs`; probe for it defensively. Anything thrown
+ // below funnels into the hardcoded fallback — this must never reject.
+ const denoMaybeStatfs = Deno as unknown as {
+ statfs?: (path: string) => Promise<{
+ blocks: number
+ bfree: number
+ bavail?: number
+ bsize: number
+ }>
+ }
+
+ if (typeof denoMaybeStatfs.statfs === 'function') {
+ try {
+ const s = await denoMaybeStatfs.statfs('/')
+ const totalBytes = s.blocks * s.bsize
+ const freeBytes = s.bfree * s.bsize
+ const usedBytes = Math.max(totalBytes - freeBytes, 0)
+ const gib = 1024 ** 3
+ const total_gb = totalBytes / gib
+ const used_gb = usedBytes / gib
+ if (total_gb > 0 && Number.isFinite(total_gb) && Number.isFinite(used_gb)) {
+ const percent_used = (used_gb / total_gb) * 100
+ return {
+ used_gb: Number(used_gb.toFixed(2)),
+ total_gb: Number(total_gb.toFixed(2)),
+ percent_used: Number(percent_used.toFixed(2)),
+ }
+ }
+ } catch (err) {
+ console.warn('project-disk: statfs probe failed:', err)
+ }
+ }
+
+ // Non-fatal sanity check that '/' is reachable; ignore the result.
+ try {
+ await Deno.stat('/')
+ } catch {
+ // swallow
+ }
+ } catch {
+ // swallow; we must never throw from here
+ }
+
+ return { ...DISK_UTIL_FALLBACK }
+}
+
+// ── Handlers ──────────────────────────────────────────────
+
+export async function handleProjectDisk(
+ _req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+): Promise {
+ const refMatch = path.match(/^\/([^/]+)(\/.*)?$/)
+ if (!refMatch) {
+ return notFoundResponse()
+ }
+
+ const ref = refMatch[1]
+ const subPath = refMatch[2] || ''
+
+ // L4: reject malformed refs before hitting the DB.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) {
+ return notFoundResponse('Project not found')
+ }
+
+ // ── /disk ───────────────────────────────────────────────
+ if (subPath === '/disk') {
+ if (method === 'GET') {
+ return Response.json(diskDefaults(), { headers: corsHeaders })
+ }
+ if (method === 'POST') {
+ return notSupportedResponse(DISK_UNSUPPORTED_MESSAGE)
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── /disk/util ──────────────────────────────────────────
+ if (subPath === '/disk/util') {
+ if (method === 'GET') {
+ const util = await computeDiskUtil()
+ return Response.json(util, { headers: corsHeaders })
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── /disk/custom-config ─────────────────────────────────
+ if (subPath === '/disk/custom-config') {
+ if (method === 'GET') {
+ const defaults = diskDefaults()
+ return Response.json(
+ {
+ compute_size: 'nano',
+ provisioned_iops: defaults.iops,
+ provisioned_throughput_mbps: defaults.throughput_mbps,
+ },
+ { headers: corsHeaders },
+ )
+ }
+ if (method === 'POST') {
+ return notSupportedResponse(DISK_UNSUPPORTED_MESSAGE)
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── /resize ─────────────────────────────────────────────
+ if (subPath === '/resize') {
+ if (method === 'POST') {
+ return notSupportedResponse(RESIZE_UNSUPPORTED_MESSAGE)
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── /restore/versions ───────────────────────────────────
+ if (subPath === '/restore/versions') {
+ if (method === 'GET') {
+ const currentPgVersion = Deno.env.get('POSTGRES_VERSION') ?? '15'
+ return Response.json([{ postgres_version: currentPgVersion }], { headers: corsHeaders })
+ }
+ return methodNotAllowedResponse()
+ }
+
+ return notFoundResponse()
+}
+
+// Top-level, not project-scoped. Parent dispatches this BEFORE matching
+// `/{ref}` or `/{ref}/subpath`, otherwise `/available-regions` would be
+// interpreted as a project ref by `handleProjects`.
+export function handleAvailableRegions(_req: Request, method: string): Response {
+ if (method === 'GET') {
+ return Response.json([{ region: 'local', name: 'Local', country_code: 'XX' }], {
+ headers: corsHeaders,
+ })
+ }
+ return methodNotAllowedResponse()
+}
diff --git a/traffic-one/functions/routes/project-lifecycle.ts b/traffic-one/functions/routes/project-lifecycle.ts
new file mode 100644
index 0000000000000..6432fc0bede09
--- /dev/null
+++ b/traffic-one/functions/routes/project-lifecycle.ts
@@ -0,0 +1,247 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import {
+ fetchProjectUrl,
+ getProjectBackend,
+ type ProjectBackend,
+ ProjectBackendNotProvisionedError,
+} from '../services/project-backend.service.ts'
+import { getProjectByRef } from '../services/project.service.ts'
+import { assertValidRef } from '../utils/ref-validation.ts'
+
+// Wave 3 / Bundle L — Project lifecycle v1 (upgrade, types, readonly, actions).
+//
+// This handler is dispatched from `handleProjectHealth` in `projects.ts` for
+// v1 sub-paths that aren't real cloud operations in self-hosted deployments:
+//
+// • /api/v1/projects/{ref}/upgrade — POST 501
+// • /api/v1/projects/{ref}/upgrade/eligibility — GET shape stub
+// • /api/v1/projects/{ref}/upgrade/status — GET shape stub
+// • /api/v1/projects/{ref}/types/typescript — GET pg-meta proxy
+// • /api/v1/projects/{ref}/readonly/temporary-disable — POST no-op
+// • /api/v1/projects/{ref}/actions[/{run_id}[/logs]] — GET empty list / 404
+//
+// Self-hosted stacks don't have a managed-upgrade pipeline, a read-only
+// auto-mode, or a cloud "actions" runner, so mutations return 501 with the
+// shared `self_hosted_unsupported` reason code. The only real integration is
+// the typescript-types proxy, which forwards to pg-meta inside the docker
+// network and falls back to an inert stub on failure so the Studio download
+// button never hangs.
+
+const UPGRADE_UNSUPPORTED_MESSAGE = 'Project upgrades are not available in self-hosted deployments'
+
+const PG_META_TIMEOUT_MS = 5000
+// Matches the shape Studio's `generateTypescriptTypes` reader expects, so
+// download-types buttons still produce a valid file when pg-meta is down.
+const TYPES_FALLBACK = 'export type Database = {} as any;'
+
+// ── Response helpers ─────────────────────────────────────────
+
+function notFoundResponse(message = 'Not Found'): Response {
+ return Response.json({ message }, { status: 404, headers: corsHeaders })
+}
+
+function notSupportedResponse(message = UPGRADE_UNSUPPORTED_MESSAGE): Response {
+ return Response.json(
+ { code: 'self_hosted_unsupported', message },
+ { status: 501, headers: corsHeaders },
+ )
+}
+
+function methodNotAllowedResponse(): Response {
+ return Response.json({ message: 'Method not allowed' }, { status: 405, headers: corsHeaders })
+}
+
+// ── Handler ──────────────────────────────────────────────────
+
+// L9: `_gotrueId` and `_email` are part of the uniform handler signature
+// dispatched from `index.ts`. Every project-scoped handler receives the
+// caller's authenticated identity; most use it for audit-log writes.
+// Project-lifecycle proxies only forward to pg-meta and never writes audit
+// rows, so the underscore prefix marks them as "intentionally unused here
+// but kept to match the shared signature". Don't remove them — doing so
+// breaks the dispatcher's positional arg contract (see index.ts).
+export async function handleProjectLifecycle(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ _gotrueId: string,
+ _email: string,
+): Promise {
+ const refMatch = path.match(/^\/([^/]+)(\/.*)?$/)
+ if (!refMatch) {
+ return notFoundResponse()
+ }
+
+ const ref = refMatch[1]
+ const subPath = refMatch[2] || ''
+
+ // L4: reject malformed refs before hitting the DB.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) {
+ return notFoundResponse('Project not found')
+ }
+
+ // ── /upgrade/eligibility ───────────────────────────────────
+ if (subPath === '/upgrade/eligibility') {
+ if (method === 'GET') {
+ return Response.json(
+ {
+ eligible: false,
+ target_upgrade_versions: [],
+ potential_breaking_changes: [],
+ extension_dependent_objects: [],
+ },
+ { headers: corsHeaders },
+ )
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── /upgrade/status ────────────────────────────────────────
+ if (subPath === '/upgrade/status') {
+ if (method === 'GET') {
+ return Response.json(
+ {
+ progress: 'complete',
+ target_version: null,
+ target_version_is_latest: true,
+ initiated_at: null,
+ },
+ { headers: corsHeaders },
+ )
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── /upgrade (POST → 501) ──────────────────────────────────
+ if (subPath === '/upgrade') {
+ if (method === 'POST') {
+ return notSupportedResponse()
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── /types/typescript (pg-meta proxy) ──────────────────────
+ if (subPath === '/types/typescript') {
+ if (method === 'GET') {
+ let backend: ProjectBackend
+ try {
+ backend = await getProjectBackend(ref, pool)
+ } catch (err) {
+ if (err instanceof ProjectBackendNotProvisionedError) {
+ // M6: this one dispatcher INTENTIONALLY diverges from the
+ // canonical 501 `notProvisionedResponse`. Studio's "Generate
+ // types" button hits this path on every project load; a 501
+ // would surface as an error toast even though the user didn't
+ // ask to regenerate. Returning the empty-schema fallback keeps
+ // the UX quiet while preserving the contract — operators who
+ // need to know the backend is unprovisioned will see 501s from
+ // every other route.
+ return Response.json({ types: TYPES_FALLBACK }, { headers: corsHeaders })
+ }
+ throw err
+ }
+ return proxyTypescriptTypes(req, backend)
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── /readonly/temporary-disable ────────────────────────────
+ if (subPath === '/readonly/temporary-disable') {
+ if (method === 'POST') {
+ return Response.json({ success: true }, { headers: corsHeaders })
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── /actions (list) ────────────────────────────────────────
+ if (subPath === '/actions' || subPath === '/actions/') {
+ if (method === 'GET') {
+ return Response.json({ runs: [] }, { headers: corsHeaders })
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── /actions/{run_id}/logs ─────────────────────────────────
+ const runLogsMatch = subPath.match(/^\/actions\/([^/]+)\/logs\/?$/)
+ if (runLogsMatch) {
+ if (method === 'GET') {
+ return Response.json({ message: 'Run not found' }, { status: 404, headers: corsHeaders })
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── /actions/{run_id} ──────────────────────────────────────
+ const runMatch = subPath.match(/^\/actions\/([^/]+)\/?$/)
+ if (runMatch) {
+ if (method === 'GET') {
+ return Response.json({ message: 'Run not found' }, { status: 404, headers: corsHeaders })
+ }
+ return methodNotAllowedResponse()
+ }
+
+ return notFoundResponse()
+}
+
+// ── pg-meta proxy ────────────────────────────────────────────
+
+// Forwards `GET /v1/projects/{ref}/types/typescript?...` to pg-meta's
+// `/generators/typescript` endpoint on this project's backend (resolved via
+// `backend.pgMetaUrl`), preserving all query params (e.g. `included_schemas`,
+// `excluded_schemas`). Uses the shared `fetchProjectUrl` helper so
+// Authorization + apikey + JSON Content-Type are signed identically to
+// every other per-project outbound call in the dispatcher (L3). Any
+// failure — non-2xx, network error, timeout, or missing endpoint —
+// swallows to a static `{ types }` fallback so the Studio "Generate
+// types" button never surfaces a 5xx.
+async function proxyTypescriptTypes(req: Request, backend: ProjectBackend): Promise {
+ if (!backend.pgMetaUrl) {
+ return Response.json({ types: TYPES_FALLBACK }, { headers: corsHeaders })
+ }
+
+ const incoming = new URL(req.url)
+ let target: URL
+ try {
+ target = new URL(`${backend.pgMetaUrl.replace(/\/$/, '')}/generators/typescript`)
+ } catch (err) {
+ console.error('pg-meta URL construction failed:', err)
+ return Response.json({ types: TYPES_FALLBACK }, { headers: corsHeaders })
+ }
+ for (const [key, value] of incoming.searchParams.entries()) {
+ target.searchParams.set(key, value)
+ }
+
+ try {
+ const res = await fetchProjectUrl(backend, target.toString(), {
+ signal: AbortSignal.timeout(PG_META_TIMEOUT_MS),
+ })
+
+ if (!res.ok) {
+ const errorBody = await res.text().catch(() => '')
+ console.error(`pg-meta types proxy failed (${res.status}): ${errorBody}`)
+ return Response.json({ types: TYPES_FALLBACK }, { headers: corsHeaders })
+ }
+
+ const contentType = res.headers.get('content-type') ?? ''
+ if (contentType.includes('application/json')) {
+ const data = (await res.json().catch(() => null)) as { types?: unknown } | null
+ if (data && typeof data.types === 'string') {
+ return Response.json({ types: data.types }, { headers: corsHeaders })
+ }
+ return Response.json({ types: TYPES_FALLBACK }, { headers: corsHeaders })
+ }
+
+ const types = await res.text()
+ return Response.json({ types }, { headers: corsHeaders })
+ } catch (err) {
+ console.error('pg-meta types proxy error:', err)
+ return Response.json({ types: TYPES_FALLBACK }, { headers: corsHeaders })
+ }
+}
diff --git a/traffic-one/functions/routes/project-network.ts b/traffic-one/functions/routes/project-network.ts
new file mode 100644
index 0000000000000..923a48b161c43
--- /dev/null
+++ b/traffic-one/functions/routes/project-network.ts
@@ -0,0 +1,145 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import { getProjectByRef } from '../services/project.service.ts'
+import { assertValidRef } from '../utils/ref-validation.ts'
+
+// Wave 3 / Bundle K — Project network + read-replicas + privatelink.
+//
+// This handler covers sub-paths from two different Kong services:
+// • /api/v1/projects/{ref}/network-restrictions[/apply]
+// • /api/v1/projects/{ref}/network-bans[/retrieve]
+// • /api/v1/projects/{ref}/read-replicas/{setup,remove}
+// • /api/platform/projects/{ref}/privatelink/associations[/aws-account[/{id}]]
+//
+// The parent dispatches to `handleProjectNetwork` from both `handleProjects`
+// (for the /platform/... privatelink sub-paths) and `handleProjectHealth`
+// (for the /v1/... network-* and read-replicas sub-paths). The handler is
+// agnostic to which entry point is used — it inspects the sub-path only.
+//
+// Self-hosted stacks have no cloud network-layer controls, no replica topology
+// and no AWS PrivateLink, so every mutation replies 501 with the shared
+// `self_hosted_unsupported` reason code. Reads return shape-correct empty
+// responses so Studio's UI renders instead of crashing.
+
+const NETWORK_RESTRICTIONS_UNSUPPORTED_MESSAGE =
+ 'Applying network restrictions is not available in self-hosted deployments'
+const READ_REPLICAS_UNSUPPORTED_MESSAGE =
+ 'Read replicas are not available in self-hosted deployments'
+const PRIVATELINK_UNSUPPORTED_MESSAGE =
+ 'PrivateLink associations are not available in self-hosted deployments'
+
+function notSupportedResponse(message: string): Response {
+ return Response.json(
+ { code: 'self_hosted_unsupported', message },
+ { status: 501, headers: corsHeaders },
+ )
+}
+
+function notFoundResponse(message = 'Not Found'): Response {
+ return Response.json({ message }, { status: 404, headers: corsHeaders })
+}
+
+function methodNotAllowedResponse(): Response {
+ return Response.json({ message: 'Method not allowed' }, { status: 405, headers: corsHeaders })
+}
+
+export async function handleProjectNetwork(
+ _req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ _gotrueId: string,
+ _email: string,
+): Promise {
+ const refMatch = path.match(/^\/([^/]+)(\/.*)?$/)
+ if (!refMatch) {
+ return notFoundResponse()
+ }
+
+ const ref = refMatch[1]
+ const subPath = refMatch[2] || ''
+
+ // L4: reject malformed refs before hitting the DB.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) {
+ return notFoundResponse('Project not found')
+ }
+
+ // ── v1: network restrictions ────────────────────────────
+ if (subPath === '/network-restrictions') {
+ if (method === 'GET') {
+ return Response.json(
+ {
+ entries: { dbAllowedCidrs: [], dbAllowedCidrsV6: [] },
+ old_config: { dbAllowedCidrs: [], dbAllowedCidrsV6: [] },
+ new_config: { dbAllowedCidrs: [], dbAllowedCidrsV6: [] },
+ status: 'applied',
+ },
+ { headers: corsHeaders },
+ )
+ }
+ return methodNotAllowedResponse()
+ }
+
+ if (subPath === '/network-restrictions/apply') {
+ if (method === 'POST') {
+ return notSupportedResponse(NETWORK_RESTRICTIONS_UNSUPPORTED_MESSAGE)
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── v1: network bans ────────────────────────────────────
+ if (subPath === '/network-bans') {
+ if (method === 'DELETE') {
+ return Response.json({ success: true }, { headers: corsHeaders })
+ }
+ return methodNotAllowedResponse()
+ }
+
+ if (subPath === '/network-bans/retrieve') {
+ if (method === 'POST') {
+ return Response.json(
+ { banned_ipv4_addresses: [], banned_ipv6_addresses: [] },
+ { headers: corsHeaders },
+ )
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── v1: read replicas ───────────────────────────────────
+ if (subPath === '/read-replicas/setup' || subPath === '/read-replicas/remove') {
+ if (method === 'POST') {
+ return notSupportedResponse(READ_REPLICAS_UNSUPPORTED_MESSAGE)
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── Platform: privatelink associations ──────────────────
+ if (subPath === '/privatelink/associations') {
+ if (method === 'GET') {
+ return Response.json({ associations: [] }, { headers: corsHeaders })
+ }
+ return methodNotAllowedResponse()
+ }
+
+ if (subPath === '/privatelink/associations/aws-account') {
+ if (method === 'POST') {
+ return notSupportedResponse(PRIVATELINK_UNSUPPORTED_MESSAGE)
+ }
+ return methodNotAllowedResponse()
+ }
+
+ if (/^\/privatelink\/associations\/aws-account\/[^/]+$/.test(subPath)) {
+ if (method === 'DELETE') {
+ return notSupportedResponse(PRIVATELINK_UNSUPPORTED_MESSAGE)
+ }
+ return methodNotAllowedResponse()
+ }
+
+ return notFoundResponse()
+}
diff --git a/traffic-one/functions/routes/project-pg-meta.ts b/traffic-one/functions/routes/project-pg-meta.ts
new file mode 100644
index 0000000000000..9af192d4a6168
--- /dev/null
+++ b/traffic-one/functions/routes/project-pg-meta.ts
@@ -0,0 +1,410 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import {
+ type FetchLike,
+ fetchProjectUrl,
+ getProjectBackend,
+ type ProjectBackend,
+ ProjectBackendNotProvisionedError,
+} from '../services/project-backend.service.ts'
+import { getProjectByRef } from '../services/project.service.ts'
+import { MAX_BODY_PG_META, readBodyWithLimit } from '../utils/body-limits.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+import { notProvisionedResponse } from '../utils/project-backend-response.ts'
+import { assertValidRef } from '../utils/ref-validation.ts'
+
+// ─────────────────────────────────────────────────────────────────────────────
+//
+// Project-scoped pg-meta proxy (Phase 4).
+//
+// Replaces Studio's own `apps/studio/pages/api/platform/pg-meta/[ref]/*` Next
+// stubs, which all forward to a single shared `PG_META_URL` derived from env
+// vars on the Studio container. That design breaks the moment Studio needs to
+// talk to a project whose pg-meta lives on a different host (api-mode, where
+// `ApiProvisioner.provision()` returns per-project URLs).
+//
+// traffic-one's dispatcher now resolves the per-project backend via
+// `getProjectBackend(ref)` and forwards every pg-meta surface to
+// `${backend.pgMetaUrl}/` using that project's service_role key as
+// both `Authorization: Bearer …` and `apikey: …`.
+//
+// Routes (incoming path starts at `/{ref}` once index.ts strips the
+// `/api/platform/pg-meta` prefix; Kong uses `strip_path: false` so the full
+// incoming path reaches traffic-one intact):
+//
+// POST /{ref}/query -> POST {pgMetaUrl}/query (SQL runner; AUDITED)
+// GET /{ref}/tables -> GET {pgMetaUrl}/tables
+// GET /{ref}/triggers -> GET {pgMetaUrl}/triggers
+// GET /{ref}/types -> GET {pgMetaUrl}/types
+// GET /{ref}/policies -> GET {pgMetaUrl}/policies
+// GET /{ref}/extensions -> GET {pgMetaUrl}/extensions
+// GET /{ref}/foreign-tables -> GET {pgMetaUrl}/foreign-tables
+// GET /{ref}/materialized-views -> GET {pgMetaUrl}/materialized-views
+// GET /{ref}/views -> GET {pgMetaUrl}/views
+// GET /{ref}/column-privileges -> GET {pgMetaUrl}/column-privileges
+// GET /{ref}/publications -> GET {pgMetaUrl}/publications
+//
+// The `/query` surface is the only mutation path and the only one that emits
+// an audit row. We deliberately do NOT persist the full SQL text or even a
+// 512-char preview: the statement can contain literal secrets
+// (`ALTER ROLE ... PASSWORD 'foo'`, `INSERT INTO … VALUES ('')`),
+// so any textual preview turns the audit log into a passive credential
+// store. Instead we record:
+// - byte length of the raw SQL
+// - SHA-256 hex digest of the raw SQL (a stable fingerprint; operators can
+// re-hash a candidate statement to confirm it matches the row)
+// - `disable_statement_timeout` flag (harmless, auditing-relevant)
+// Operators with pg-meta's own query log still have the full statement;
+// this audit row only has to prove "user X ran *a* query of size Y at time
+// T, with fingerprint Z" — which is enough for forensics without the
+// secret-leakage risk. See M12.
+//
+// All surfaces are allow-listed; unknown sub-paths return 404 so traffic-one
+// stays crisp when Studio starts probing a surface we haven't wired yet.
+//
+// ─────────────────────────────────────────────────────────────────────────────
+
+const PG_META_TIMEOUT_MS = 30_000
+const ALLOWED_SURFACES = new Set([
+ 'tables',
+ 'triggers',
+ 'types',
+ 'policies',
+ 'extensions',
+ 'foreign-tables',
+ 'materialized-views',
+ 'views',
+ 'column-privileges',
+ 'publications',
+])
+
+// ── Response helpers ────────────────────────────────────────
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return Response.json(body, { status, headers: corsHeaders })
+}
+
+function methodNotAllowed(): Response {
+ return jsonResponse({ message: 'Method not allowed' }, 405)
+}
+
+function notFound(message = 'Not Found'): Response {
+ return jsonResponse({ message }, 404)
+}
+
+function badRequest(message: string): Response {
+ return jsonResponse({ message }, 400)
+}
+
+// M6: the local `notProvisioned(...)` used to hand-roll the 501 shape. The
+// helper now lives in `utils/project-backend-response.ts` so Studio sees
+// the exact same JSON body ({ message, code, missing }) regardless of
+// which dispatcher emitted it. See M6 in the plan.
+const notProvisioned = notProvisionedResponse
+
+function bubbleUpstreamError(status: number, body: string): Response {
+ // pg-meta returns { error: string } on failure. Mirror that shape when the
+ // body is non-JSON so Studio's error toaster doesn't explode on malformed
+ // JSON.parse.
+ try {
+ const parsed = JSON.parse(body)
+ return jsonResponse(parsed, status)
+ } catch {
+ return jsonResponse({ error: body || `pg-meta returned ${status}` }, status)
+ }
+}
+
+// ── Audit helpers ───────────────────────────────────────────
+
+interface AuditContext {
+ email: string
+ ip: string
+ method: string
+ route: string
+ organizationId?: number | null
+}
+
+// M12: the audit row stores a non-reversible fingerprint of the SQL (hex
+// SHA-256) plus byte length, never the statement text itself. See the
+// file-level comment above for the threat model.
+interface SqlFingerprint {
+ bytes: number
+ sha256: string
+ disable_statement_timeout?: boolean
+}
+
+async function hashSql(sql: string): Promise {
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(sql))
+ const bytes = new Uint8Array(digest)
+ let out = ''
+ for (const b of bytes) out += b.toString(16).padStart(2, '0')
+ return out
+}
+
+async function writeQueryAuditLog(
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ projectRef: string,
+ ctx: AuditContext,
+ sqlSummary: SqlFingerprint,
+ status: number,
+): Promise {
+ const connection = await pool.connect()
+ try {
+ await connection.queryObject`
+ INSERT INTO traffic.audit_logs (
+ id, organization_id, profile_id, action_name, action_metadata,
+ actor_id, actor_type, actor_metadata,
+ target_description, target_metadata, occurred_at
+ ) VALUES (
+ gen_random_uuid(), ${ctx.organizationId ?? null}, ${profileId},
+ ${'project.pg_meta.query'},
+ ${JSON.stringify([{ method: ctx.method, route: ctx.route, status }])}::jsonb,
+ ${gotrueId}, 'user',
+ ${JSON.stringify([{ email: ctx.email, ip: ctx.ip }])}::jsonb,
+ ${'project pg-meta ref: ' + projectRef},
+ ${JSON.stringify({ ref: projectRef, sql: sqlSummary })}::jsonb,
+ now()
+ )
+ `
+ } finally {
+ connection.release()
+ }
+}
+
+// ── Upstream call helpers ───────────────────────────────────
+
+// Build a URL on `{backend.pgMetaUrl}/` preserving any query-string
+// params from the incoming request. Returns null if the backend hasn't
+// surfaced a pg-meta URL (shouldn't happen post-resolver, but guards against
+// malformed provisioner payloads).
+function buildTargetUrl(backend: ProjectBackend, surface: string, incoming: URL): URL | null {
+ if (!backend.pgMetaUrl) return null
+ let target: URL
+ try {
+ target = new URL(`${backend.pgMetaUrl.replace(/\/$/, '')}/${surface}`)
+ } catch {
+ return null
+ }
+ for (const [k, v] of incoming.searchParams.entries()) {
+ target.searchParams.set(k, v)
+ }
+ return target
+}
+
+// Forward through headers we want pg-meta to see. We drop everything else so
+// Studio's `Authorization: Bearer ` doesn't leak past traffic-one.
+// `fetchProjectUrl` sets Authorization + apikey to the project service key.
+function forwardableHeaders(req: Request): Headers {
+ const out = new Headers()
+ const ct = req.headers.get('Content-Type')
+ if (ct) out.set('Content-Type', ct)
+ const appName = req.headers.get('x-pg-application-name')
+ if (appName) out.set('x-pg-application-name', appName)
+ const connEnc = req.headers.get('x-connection-encrypted')
+ // Per-project pg-meta already knows which database to hit (it was
+ // provisioned alongside the project DB). `x-connection-encrypted` only
+ // matters for the multi-tenant managed platform, where one pg-meta
+ // container routes to many DBs. We still forward it — harmless if the
+ // runtime ignores it, useful if an operator wires in a fleet pg-meta.
+ if (connEnc) out.set('x-connection-encrypted', connEnc)
+ return out
+}
+
+async function dispatchGet(
+ req: Request,
+ backend: ProjectBackend,
+ surface: string,
+ fetchImpl: FetchLike,
+): Promise {
+ const target = buildTargetUrl(backend, surface, new URL(req.url))
+ if (!target) {
+ return jsonResponse({ message: 'pg-meta URL is not configured for this project' }, 501)
+ }
+ try {
+ const res = await fetchProjectUrl(
+ backend,
+ target.toString(),
+ {
+ method: 'GET',
+ headers: forwardableHeaders(req),
+ signal: AbortSignal.timeout(PG_META_TIMEOUT_MS),
+ },
+ fetchImpl,
+ )
+ const body = await res.text()
+ if (!res.ok) return bubbleUpstreamError(res.status, body)
+ const contentType = res.headers.get('content-type') ?? 'application/json'
+ return new Response(body, {
+ status: res.status,
+ headers: { ...corsHeaders, 'Content-Type': contentType },
+ })
+ } catch (err) {
+ console.error(`pg-meta GET ${surface} failed:`, err)
+ return jsonResponse({ error: 'pg-meta dispatch failed' }, 502)
+ }
+}
+
+async function dispatchQuery(
+ req: Request,
+ backend: ProjectBackend,
+ pool: Pool,
+ projectRef: string,
+ profileId: number,
+ gotrueId: string,
+ ctx: AuditContext,
+ fetchImpl: FetchLike,
+): Promise {
+ const target = buildTargetUrl(backend, 'query', new URL(req.url))
+ if (!target) {
+ return jsonResponse({ message: 'pg-meta URL is not configured for this project' }, 501)
+ }
+
+ // M11: cap the /query body at `MAX_BODY_PG_META` (1 MiB). Studio's SQL
+ // editor realistically never ships a >1MB statement; anything bigger is
+ // either a pathological CSV insert (which should run via COPY, not
+ // HTTP-proxied SQL) or an attacker trying to pin an outbound pg-meta
+ // connection. We fail closed with a canonical 413 before touching the
+ // upstream and before auditing anything.
+ let rawBody: string
+ try {
+ rawBody = await readBodyWithLimit(req, MAX_BODY_PG_META)
+ } catch (tooLarge) {
+ if (tooLarge instanceof Response) return tooLarge
+ return badRequest('Invalid request body')
+ }
+ let parsedBody: { query?: unknown; disable_statement_timeout?: unknown }
+ try {
+ parsedBody = rawBody ? JSON.parse(rawBody) : {}
+ } catch {
+ return badRequest('Request body must be JSON')
+ }
+
+ if (typeof parsedBody.query !== 'string' || parsedBody.query.length === 0) {
+ return badRequest("'query' (non-empty string) is required")
+ }
+ const sql = parsedBody.query
+ const disableStmtTimeout = typeof parsedBody.disable_statement_timeout === 'boolean'
+ ? parsedBody.disable_statement_timeout
+ : undefined
+
+ // M12: no textual preview — hash instead. `bytes` + `sha256` together
+ // still give operators a stable identifier to correlate with pg-meta's
+ // own query log ("did user X run the same statement I see in the DB
+ // audit trail?") while leaking zero SQL text / literals to anyone who
+ // can read `traffic.audit_logs`.
+ const sqlSummary: SqlFingerprint = {
+ bytes: new TextEncoder().encode(sql).length,
+ sha256: await hashSql(sql),
+ ...(disableStmtTimeout !== undefined ? { disable_statement_timeout: disableStmtTimeout } : {}),
+ }
+
+ let upstreamStatus = 0
+ let upstreamBody = ''
+ try {
+ const res = await fetchProjectUrl(
+ backend,
+ target.toString(),
+ {
+ method: 'POST',
+ headers: forwardableHeaders(req),
+ body: rawBody,
+ signal: AbortSignal.timeout(PG_META_TIMEOUT_MS),
+ },
+ fetchImpl,
+ )
+ upstreamStatus = res.status
+ upstreamBody = await res.text()
+ } catch (err) {
+ console.error('pg-meta POST /query dispatch failed:', err)
+ await writeQueryAuditLog(pool, profileId, gotrueId, projectRef, ctx, sqlSummary, 502).catch(
+ (logErr) => console.warn('pg-meta audit log failed:', logErr),
+ )
+ return jsonResponse({ error: 'pg-meta dispatch failed' }, 502)
+ }
+
+ // Audit every query regardless of upstream success; the action_metadata
+ // captures the response status so we have an immutable trail of "who ran
+ // what, when, and did it succeed?".
+ await writeQueryAuditLog(
+ pool,
+ profileId,
+ gotrueId,
+ projectRef,
+ ctx,
+ sqlSummary,
+ upstreamStatus,
+ ).catch((logErr) => console.warn('pg-meta audit log failed:', logErr))
+
+ if (upstreamStatus >= 200 && upstreamStatus < 300) {
+ const contentType = 'application/json'
+ return new Response(upstreamBody, {
+ status: upstreamStatus,
+ headers: { ...corsHeaders, 'Content-Type': contentType },
+ })
+ }
+ return bubbleUpstreamError(upstreamStatus, upstreamBody)
+}
+
+// ── Handler ─────────────────────────────────────────────────
+
+export async function handleProjectPgMeta(
+ req: Request,
+ path: string, // e.g. "/{ref}/query" or "/{ref}/tables"
+ method: string,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+ // H4: injectable fetch so unit tests can assert outbound URL / method /
+ // headers without a live pg-meta. Defaults to global `fetch` in prod.
+ fetchImpl: FetchLike = fetch,
+): Promise {
+ const refMatch = path.match(/^\/([^/]+)(\/.*)$/)
+ if (!refMatch) return notFound()
+
+ const ref = refMatch[1]
+ const subPath = refMatch[2]
+
+ // L4: reject obviously-malformed refs before the DB round-trip.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) return notFound('Project not found')
+
+ const ctx: AuditContext = {
+ email,
+ ip: getClientIp(req),
+ method,
+ route: `/api/platform/pg-meta${path}`,
+ organizationId: project.organization_id,
+ }
+
+ let backend: ProjectBackend
+ try {
+ backend = await getProjectBackend(ref, pool)
+ } catch (err) {
+ if (err instanceof ProjectBackendNotProvisionedError) return notProvisioned(err)
+ throw err
+ }
+
+ // POST /{ref}/query — SQL runner (audited)
+ if (subPath === '/query' || subPath === '/query/') {
+ if (method !== 'POST') return methodNotAllowed()
+ return dispatchQuery(req, backend, pool, ref, profileId, gotrueId, ctx, fetchImpl)
+ }
+
+ // GET /{ref}/ — read-through proxy
+ const surfaceMatch = subPath.match(/^\/([^/]+)\/?$/)
+ if (surfaceMatch) {
+ const surface = surfaceMatch[1]
+ if (!ALLOWED_SURFACES.has(surface)) return notFound()
+ if (method !== 'GET' && method !== 'HEAD') return methodNotAllowed()
+ return dispatchGet(req, backend, surface, fetchImpl)
+ }
+
+ return notFound()
+}
diff --git a/traffic-one/functions/routes/projects.ts b/traffic-one/functions/routes/projects.ts
new file mode 100644
index 0000000000000..8863eca41a5a6
--- /dev/null
+++ b/traffic-one/functions/routes/projects.ts
@@ -0,0 +1,720 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import {
+ type FunctionEntry,
+ FUNCTIONS_DIR,
+ getRemoteFunction,
+ getRemoteFunctionBody,
+ listRemoteFunctions,
+ parseFunctionDir,
+} from '../services/edge-functions.service.ts'
+import {
+ getProjectBackend,
+ isSharedStack,
+ type ProjectBackend,
+ ProjectBackendNotProvisionedError,
+} from '../services/project-backend.service.ts'
+import {
+ createProject,
+ deleteProject,
+ getProjectByRef,
+ getProjectStatus,
+ listProjectsPaginated,
+ setProjectStatus,
+ transferProject,
+ transferProjectPreview,
+ updateProject,
+} from '../services/project.service.ts'
+import { ProvisionerNotConfiguredError } from '../services/provisioners/api.provisioner.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+import { notProvisionedResponse, resolveBackendOr501 } from '../utils/project-backend-response.ts'
+import { assertValidRef } from '../utils/ref-validation.ts'
+import { handleProjectBilling } from './billing.ts'
+import { handleProjectBranches } from './branches.ts'
+import { handleContent } from './content.ts'
+import { handleCustomHostname } from './custom-hostname.ts'
+import { handleEdgeFunctionMutations } from './edge-function-mutations.ts'
+import { handleJit } from './jit.ts'
+import { handleProjectAnalytics } from './project-analytics.ts'
+import { handleProjectApiKeys } from './project-api-keys.ts'
+import { handleProjectAuth } from './project-auth.ts'
+import { handleProjectConfig } from './project-config.ts'
+import { handleAvailableRegions, handleProjectDisk } from './project-disk.ts'
+import { handleProjectLifecycle } from './project-lifecycle.ts'
+import { handleProjectNetwork } from './project-network.ts'
+
+export async function handleProjects(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+): Promise {
+ const ip = getClientIp(req)
+ const auditContext = { email, ip, method, route: '/projects' + path }
+
+ // POST /projects — create project
+ if (method === 'POST' && path === '/') {
+ const body = await req.json()
+ if (!body.name || !body.organization_slug) {
+ return Response.json(
+ { message: 'name and organization_slug are required' },
+ { status: 400, headers: corsHeaders },
+ )
+ }
+ try {
+ const project = await createProject(pool, profileId, gotrueId, body, auditContext)
+ if (!project) {
+ return Response.json(
+ { message: 'Organization not found or not a member' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json(project, { status: 201, headers: corsHeaders })
+ } catch (err) {
+ // M4: when PROJECT_PROVISIONER=api but PROVISIONER_API_URL is missing,
+ // the ApiProvisioner now throws a structured `ProvisionerNotConfiguredError`
+ // (was: opaque 500). Surface that as 503 with a machine-readable code so
+ // Studio can toast the specific "operator has not configured provisioner"
+ // state rather than a generic "something went wrong".
+ if (err instanceof ProvisionerNotConfiguredError) {
+ return Response.json(
+ { code: err.code, message: err.message },
+ { status: 503, headers: corsHeaders },
+ )
+ }
+ throw err
+ }
+ }
+
+ // Delegate billing sub-paths before other matching. `handleProjectBilling`
+ // now gates every request on project ownership via `profileId`.
+ const billingMatch = path.match(/^\/([^/]+)(\/billing.*)$/)
+ if (billingMatch && pool) {
+ // L4: malformed ref never corresponds to a real project → 400.
+ const bad = assertValidRef(billingMatch[1])
+ if (bad) return bad
+ return handleProjectBilling(req, billingMatch[2], method, pool, billingMatch[1], profileId)
+ }
+
+ // ── Wave 3 dispatches ──────────────────────────────────────
+ // Non-project-scoped: /available-regions must fire BEFORE refOnlyMatch
+ // (otherwise "available-regions" gets treated as a project ref).
+ if (path === '/available-regions' || path === '/available-regions/') {
+ return handleAvailableRegions(req, method)
+ }
+
+ // Bundle E: POST /{ref}/api-keys/temporary → dynamic short-lived JWTs
+ if (/^\/[^/]+\/api-keys\/temporary\/?$/.test(path)) {
+ return handleProjectApiKeys(req, path, method, pool, profileId, gotrueId, email)
+ }
+
+ // Bundle I: project configuration + lint exceptions
+ // /{ref}/config/(postgrest|storage|realtime|pgbouncer[/status]|secrets[/update-status])
+ // /{ref}/settings/sensitivity
+ // /{ref}/db-password
+ // /{ref}/notifications/advisor/exceptions
+ if (
+ /^\/[^/]+\/config\/(postgrest|storage|realtime|pgbouncer(\/status)?|secrets(\/update-status)?)\/?$/
+ .test(
+ path,
+ ) ||
+ /^\/[^/]+\/settings\/sensitivity\/?$/.test(path) ||
+ /^\/[^/]+\/db-password\/?$/.test(path) ||
+ /^\/[^/]+\/notifications\/advisor\/exceptions\/?$/.test(path)
+ ) {
+ return handleProjectConfig(req, path, method, pool, profileId, gotrueId, email)
+ }
+
+ // Bundle J: disk + resize + restore versions
+ if (
+ /^\/[^/]+\/disk(\/util|\/custom-config)?\/?$/.test(path) ||
+ /^\/[^/]+\/resize\/?$/.test(path) ||
+ /^\/[^/]+\/restore\/versions\/?$/.test(path)
+ ) {
+ return handleProjectDisk(req, path, method, pool, profileId)
+ }
+
+ // Bundle F: analytics, infra-monitoring, log drains, REST/GraphQL introspection
+ if (
+ /^\/[^/]+\/infra-monitoring\/?$/.test(path) ||
+ /^\/[^/]+\/analytics\/endpoints\/[^/]+\/?$/.test(path) ||
+ /^\/[^/]+\/analytics\/log-drains(\/[^/]+)?\/?$/.test(path) ||
+ /^\/[^/]+\/api\/(rest|graphql)\/?$/.test(path)
+ ) {
+ return handleProjectAnalytics(req, path, method, pool, profileId, gotrueId, email)
+ }
+
+ // Bundle H: content persistence (SQL snippets + folders)
+ if (/^\/[^/]+\/content(\/.*)?$/.test(path)) {
+ return handleContent(req, path, method, pool, profileId, gotrueId, email)
+ }
+
+ // Bundle K: privatelink (platform surface — v1 network lives in handleProjectHealth)
+ if (/^\/[^/]+\/privatelink\/associations(\/aws-account(\/[^/]+)?)?\/?$/.test(path)) {
+ return handleProjectNetwork(req, path, method, pool, profileId, gotrueId, email)
+ }
+
+ // GET /projects — paginated list
+ if (method === 'GET' && path === '/') {
+ const url = new URL(req.url)
+ const limit = parseInt(url.searchParams.get('limit') || '100', 10)
+ const offset = parseInt(url.searchParams.get('offset') || '0', 10)
+ const result = await listProjectsPaginated(pool, profileId, limit, offset)
+ return Response.json(result, { headers: corsHeaders })
+ }
+
+ // GET /projects/{ref} — project detail (must be exact match, not sub-resource)
+ const refOnlyMatch = path.match(/^\/([^/]+)$/)
+ if (method === 'GET' && refOnlyMatch) {
+ const ref = refOnlyMatch[1]
+ // L4: malformed ref → 400, not 404 (can't possibly match a real row).
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) {
+ return Response.json({ message: 'Project not found' }, { status: 404, headers: corsHeaders })
+ }
+ return Response.json(project, { headers: corsHeaders })
+ }
+
+ // PATCH /projects/{ref} — update project
+ if (method === 'PATCH' && refOnlyMatch) {
+ const ref = refOnlyMatch[1]
+ // L4: malformed ref → 400.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+ const body = await req.json()
+ const result = await updateProject(
+ pool,
+ ref,
+ profileId,
+ { name: body.name },
+ gotrueId,
+ auditContext,
+ )
+ if (!result) {
+ return Response.json({ message: 'Project not found' }, { status: 404, headers: corsHeaders })
+ }
+ return Response.json(result, { headers: corsHeaders })
+ }
+
+ // DELETE /projects/{ref} — delete project
+ if (method === 'DELETE' && refOnlyMatch) {
+ const ref = refOnlyMatch[1]
+ // L4: malformed ref → 400.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+ const result = await deleteProject(pool, ref, profileId, gotrueId, auditContext)
+ if (!result) {
+ return Response.json({ message: 'Project not found' }, { status: 404, headers: corsHeaders })
+ }
+ return Response.json(result, { headers: corsHeaders })
+ }
+
+ // Sub-resource routes: /{ref}/subpath
+ const subMatch = path.match(/^\/([^/]+)(\/.+)$/)
+ if (subMatch) {
+ const ref = subMatch[1]
+ const subPath = subMatch[2]
+
+ // L4: every sub-resource branch below uses `ref` to authorize against
+ // `traffic.projects`. A malformed ref can never match a real row, so
+ // short-circuit before any further work.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+
+ // POST /{ref}/pause
+ if (method === 'POST' && subPath === '/pause') {
+ const result = await setProjectStatus(
+ pool,
+ ref,
+ profileId,
+ 'INACTIVE',
+ gotrueId,
+ auditContext,
+ )
+ if (!result) {
+ return Response.json(
+ { message: 'Project not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json(result, { headers: corsHeaders })
+ }
+
+ // POST /{ref}/restore
+ if (method === 'POST' && subPath === '/restore') {
+ const result = await setProjectStatus(
+ pool,
+ ref,
+ profileId,
+ 'ACTIVE_HEALTHY',
+ gotrueId,
+ auditContext,
+ )
+ if (!result) {
+ return Response.json(
+ { message: 'Project not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json(result, { headers: corsHeaders })
+ }
+
+ // POST /{ref}/restart — no-op
+ if (method === 'POST' && subPath === '/restart') {
+ return Response.json({ message: 'ok' }, { headers: corsHeaders })
+ }
+
+ // POST /{ref}/restart-services — no-op
+ if (method === 'POST' && subPath === '/restart-services') {
+ return Response.json({ message: 'ok' }, { headers: corsHeaders })
+ }
+
+ // POST /{ref}/transfer/preview
+ if (method === 'POST' && subPath === '/transfer/preview') {
+ const body = await req.json()
+ const result = await transferProjectPreview(
+ pool,
+ ref,
+ profileId,
+ body.target_organization_slug,
+ )
+ // H6: preview returns `valid: false` for non-members (404) and
+ // admin/owner-only forbiddens (403). Map explicitly so Studio sees the
+ // right status code and error toast.
+ if (result.valid === false && result.forbidden) {
+ return Response.json({ message: result.message }, { status: 403, headers: corsHeaders })
+ }
+ return Response.json(result, { headers: corsHeaders })
+ }
+
+ // POST /{ref}/transfer
+ if (method === 'POST' && subPath === '/transfer') {
+ const body = await req.json()
+ const result = await transferProject(
+ pool,
+ ref,
+ profileId,
+ body.target_organization_slug,
+ gotrueId,
+ auditContext,
+ )
+ // H6: only admins and owners (role_id >= 4) may trigger a transfer.
+ // Return 403 when the caller is a member without the required role;
+ // 400 is reserved for a missing source project / target org.
+ if (result.ok === false && result.forbidden) {
+ return Response.json(
+ { message: 'Only administrators and owners can transfer projects' },
+ { status: 403, headers: corsHeaders },
+ )
+ }
+ if (result.ok === false) {
+ return Response.json({ message: 'Transfer failed' }, { status: 400, headers: corsHeaders })
+ }
+ return Response.json(result.project, { headers: corsHeaders })
+ }
+
+ // GET-only sub-resources
+ if (method === 'GET') {
+ // GET /{ref}/status
+ if (subPath === '/status') {
+ const status = await getProjectStatus(pool, ref, profileId)
+ if (!status) {
+ return Response.json(
+ { message: 'Project not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json(status, { headers: corsHeaders })
+ }
+
+ // GET /{ref}/pause/status
+ if (subPath === '/pause/status') {
+ const status = await getProjectStatus(pool, ref, profileId)
+ if (!status) {
+ return Response.json(
+ { message: 'Project not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+ return Response.json(status, { headers: corsHeaders })
+ }
+
+ // GET /{ref}/service-versions — hardcoded
+ if (subPath === '/service-versions') {
+ return Response.json({}, { headers: corsHeaders })
+ }
+
+ // Static sub-resource stubs for surfaces not yet backed by real handlers.
+ // NOTE: /content, /config/(realtime|pgbouncer|storage), /analytics/log-drains,
+ // /notifications/advisor/exceptions, /branches, /secrets were removed — they
+ // are now handled by dedicated Wave 3 route handlers dispatched above.
+ const subResourceStubs: Record = {
+ '/databases': [
+ {
+ cloud_provider: 'AWS',
+ identifier: ref,
+ infra_compute_size: 'nano',
+ region: 'local',
+ status: 'ACTIVE_HEALTHY',
+ inserted_at: '2024-01-01T00:00:00Z',
+ read_replicas: [],
+ },
+ ],
+ '/databases-statuses': [],
+ '/load-balancers': [],
+ '/members': [],
+ '/run-lints': [],
+ '/config/network-bans': { banned_ipv4_addresses: [], banned_ipv6_addresses: [] },
+ '/integrations': [],
+ }
+
+ // Dynamic: /config/supavisor — return pooler configuration resolved
+ // from the per-project backend.
+ //
+ // H2: this used to read `POOLER_*` + `POSTGRES_DB` directly from the
+ // function's env without verifying the caller is a member of {ref}'s
+ // org. In per-project mode (api provisioner) that leaked the shared
+ // local-stack pooler coordinates to anyone who could guess a ref,
+ // AND it reported the wrong connection string for non-local projects
+ // (pooler is global → `supabase-pooler:6543` is only correct for
+ // Docker-local). We now:
+ // 1. Gate on `getProjectByRef` so non-members (or unknown refs)
+ // return a plain 404 — consistent with every other project
+ // handler and preventing cross-tenant enumeration.
+ // 2. Resolve the backend via `getProjectBackend` so the db name,
+ // host, and port all come from the project's own row / Vault
+ // / env-fallback chain rather than the platform-global
+ // `POSTGRES_DB`. The pooler hostname and transaction port stay
+ // on the `POOLER_*` env vars because Supavisor is currently
+ // shared across tenants in both local and api modes; when that
+ // changes we'll add `pooler_*` columns to `traffic.projects`.
+ if (subPath === '/config/supavisor') {
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) {
+ return Response.json(
+ { message: 'Project not found' },
+ { status: 404, headers: corsHeaders },
+ )
+ }
+
+ const backendResult = await resolveBackendOr501(pool, ref)
+ if (backendResult instanceof Response) return backendResult
+ const backend = backendResult
+
+ const tenantId = Deno.env.get('POOLER_TENANT_ID') || ref
+ const poolSize = parseInt(Deno.env.get('POOLER_DEFAULT_POOL_SIZE') || '20', 10)
+ const maxClientConn = parseInt(Deno.env.get('POOLER_MAX_CLIENT_CONN') || '100', 10)
+ const txPort = parseInt(Deno.env.get('POOLER_PROXY_PORT_TRANSACTION') || '6543', 10)
+
+ const supavisorConfig = [
+ {
+ connection_string:
+ `postgres://postgres.[${tenantId}]@supabase-pooler:${txPort}/${backend.dbName}`,
+ connectionString:
+ `postgres://postgres.[${tenantId}]@supabase-pooler:${txPort}/${backend.dbName}`,
+ database_type: 'PRIMARY',
+ db_host: 'supabase-pooler',
+ db_name: backend.dbName,
+ db_port: txPort,
+ db_user: `postgres.${tenantId}`,
+ default_pool_size: poolSize,
+ identifier: ref,
+ is_using_scram_auth: false,
+ max_client_conn: maxClientConn,
+ pool_mode: 'transaction',
+ },
+ ]
+ return Response.json(supavisorConfig, { headers: corsHeaders })
+ }
+
+ const stubData = subResourceStubs[subPath]
+ if (stubData !== undefined) {
+ return Response.json(stubData, { headers: corsHeaders })
+ }
+ }
+ }
+
+ // H4: previously returned `Response.json({})` which silently let Studio
+ // destructure `undefined` off unmatched paths (no error toast, no console
+ // error — just a broken UI). Returning an explicit 404 matches
+ // `handleProjectHealth` and makes misroutes visible.
+ return Response.json({ message: 'Not Found' }, { status: 404, headers: corsHeaders })
+}
+
+// Handler for /v1/projects/{ref}/* (routed separately via Kong)
+export async function handleProjectHealth(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ gotrueId: string,
+ email: string,
+): Promise {
+ // ── Wave 3 v1 dispatches ───────────────────────────────────
+ // Bundle E: api-keys CRUD + /api-keys/legacy + JWT signing keys
+ if (
+ /^\/[^/]+\/api-keys(\/.*)?$/.test(path) ||
+ /^\/[^/]+\/config\/auth\/signing-keys(\/.*)?$/.test(path)
+ ) {
+ return handleProjectApiKeys(req, path, method, pool, profileId, gotrueId, email)
+ }
+
+ // Bundle M: third-party auth + SSL enforcement + project secrets (Vault-backed)
+ if (
+ /^\/[^/]+\/config\/auth\/third-party-auth(\/[^/]+)?\/?$/.test(path) ||
+ /^\/[^/]+\/ssl-enforcement\/?$/.test(path) ||
+ /^\/[^/]+\/secrets\/?$/.test(path)
+ ) {
+ return handleProjectAuth(req, path, method, pool, profileId, gotrueId, email)
+ }
+
+ // Bundle K: network restrictions + bans + read-replicas (v1 surface)
+ if (
+ /^\/[^/]+\/network-restrictions(\/apply)?\/?$/.test(path) ||
+ /^\/[^/]+\/network-bans(\/retrieve)?\/?$/.test(path) ||
+ /^\/[^/]+\/read-replicas\/(setup|remove)\/?$/.test(path)
+ ) {
+ return handleProjectNetwork(req, path, method, pool, profileId, gotrueId, email)
+ }
+
+ // Bundle L: upgrade eligibility/status + TS types + readonly + actions
+ if (
+ /^\/[^/]+\/upgrade(\/eligibility|\/status)?\/?$/.test(path) ||
+ /^\/[^/]+\/types\/typescript\/?$/.test(path) ||
+ /^\/[^/]+\/readonly\/temporary-disable\/?$/.test(path) ||
+ /^\/[^/]+\/actions(\/[^/]+(\/logs)?)?\/?$/.test(path)
+ ) {
+ return handleProjectLifecycle(req, path, method, pool, profileId, gotrueId, email)
+ }
+
+ // Bundle Q: JIT access policies + grants
+ if (/^\/[^/]+\/jit-access\/?$/.test(path) || /^\/[^/]+\/database\/jit(\/.*)?$/.test(path)) {
+ return handleJit(req, path, method, pool, profileId, gotrueId, email)
+ }
+
+ // Bundle O: branches (replaces the old /{ref}/branches stub)
+ if (/^\/[^/]+\/branches\/?$/.test(path)) {
+ return handleProjectBranches(req, path, method, pool, profileId, gotrueId, email)
+ }
+
+ // Bundle O: custom hostnames
+ if (/^\/[^/]+\/custom-hostname(\/initialize|\/activate|\/reverify)?\/?$/.test(path)) {
+ return handleCustomHostname(req, path, method, pool, profileId, gotrueId, email)
+ }
+
+ // Bundle P: edge function mutations (POST /functions/deploy, PATCH/DELETE /functions/{slug}).
+ // GET paths fall through to the existing filesystem-backed handlers below.
+ if (
+ (method === 'POST' || method === 'PATCH' || method === 'DELETE') &&
+ /^\/[^/]+\/functions\/(deploy|[^/]+)\/?$/.test(path)
+ ) {
+ return handleEdgeFunctionMutations(req, path, method, pool, profileId, gotrueId, email)
+ }
+
+ // GET /{ref}/health
+ const healthMatch = path.match(/^\/([^/]+)\/health$/)
+ if (method === 'GET' && healthMatch) {
+ const ref = healthMatch[1]
+ // L4: malformed ref → 400.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+ const status = await getProjectStatus(pool, ref, profileId)
+ if (!status) {
+ return Response.json({ message: 'Project not found' }, { status: 404, headers: corsHeaders })
+ }
+
+ const healthy = status.status === 'ACTIVE_HEALTHY'
+ const svcStatus = healthy ? 'ACTIVE_HEALTHY' : 'UNHEALTHY'
+
+ return Response.json(
+ [
+ { name: 'auth', status: svcStatus },
+ { name: 'rest', status: svcStatus },
+ { name: 'realtime', status: svcStatus },
+ { name: 'storage', status: svcStatus },
+ { name: 'db', status: svcStatus },
+ ],
+ { headers: corsHeaders },
+ )
+ }
+
+ // /{ref}/branches is now handled by handleProjectBranches (Bundle O) above.
+ // /{ref}/api-keys is now handled by handleProjectApiKeys (Bundle E) above;
+ // the legacy env-derived anon/service_role list lives at /{ref}/api-keys/legacy.
+
+ // GET /{ref}/functions — list edge functions.
+ //
+ // Shared-stack mode (local Docker): read them off the filesystem mount.
+ // Per-project mode (api provisioner): proxy to the project's functions
+ // runtime via `${backend.functionsApiUrl}/_meta` using the per-project
+ // service key.
+ const functionsListMatch = path.match(/^\/([^/]+)\/functions\/?$/)
+ if (method === 'GET' && functionsListMatch) {
+ const functionsRef = functionsListMatch[1]
+ const resolved = await resolveFunctionsBackend(pool, functionsRef, profileId)
+ if (resolved instanceof Response) return resolved
+ return isSharedStack(resolved) ? listEdgeFunctions() : listRemoteFunctionsResponse(resolved)
+ }
+
+ // GET /{ref}/functions/{slug} — single function detail
+ const functionDetailMatch = path.match(/^\/([^/]+)\/functions\/([^/]+)\/?$/)
+ if (method === 'GET' && functionDetailMatch) {
+ const functionsRef = functionDetailMatch[1]
+ const slug = functionDetailMatch[2]
+ const resolved = await resolveFunctionsBackend(pool, functionsRef, profileId)
+ if (resolved instanceof Response) return resolved
+ return isSharedStack(resolved)
+ ? getEdgeFunctionBySlug(slug)
+ : getRemoteFunctionResponse(resolved, slug)
+ }
+
+ // GET /{ref}/functions/{slug}/body — function source code
+ const functionBodyMatch = path.match(/^\/([^/]+)\/functions\/([^/]+)\/body$/)
+ if (method === 'GET' && functionBodyMatch) {
+ const functionsRef = functionBodyMatch[1]
+ const slug = functionBodyMatch[2]
+ const resolved = await resolveFunctionsBackend(pool, functionsRef, profileId)
+ if (resolved instanceof Response) return resolved
+ return isSharedStack(resolved)
+ ? getEdgeFunctionBody(slug)
+ : getRemoteFunctionBodyResponse(resolved, slug)
+ }
+
+ return Response.json({ message: 'Not found' }, { status: 404, headers: corsHeaders })
+}
+
+// ── Edge Functions filesystem helpers ──────────────────────
+//
+// L4: the filesystem scanner (`parseFunctionDir`) and the runtime mount
+// path (`FUNCTIONS_DIR`) live in
+// `services/edge-functions.service.ts`. We previously carried a second
+// copy here and in routes/edge-function-mutations.ts; both are now
+// imported from the shared helper so the read and write paths agree on
+// entrypoint / metadata resolution.
+
+async function listEdgeFunctions(): Promise {
+ try {
+ const functions: FunctionEntry[] = []
+
+ for await (const entry of Deno.readDir(FUNCTIONS_DIR)) {
+ if (!entry.isDirectory || entry.name === 'main' || entry.name === 'traffic-one') continue
+
+ const func = await parseFunctionDir(entry.name)
+ if (func) functions.push(func)
+ }
+
+ return Response.json(functions, { headers: corsHeaders })
+ } catch (err) {
+ console.error('listEdgeFunctions error:', err)
+ return Response.json([], { headers: corsHeaders })
+ }
+}
+
+async function getEdgeFunctionBySlug(slug: string): Promise {
+ if (slug === 'main' || slug === 'traffic-one') {
+ return Response.json({ message: 'Function not found' }, { status: 404, headers: corsHeaders })
+ }
+
+ try {
+ const func = await parseFunctionDir(slug)
+ if (!func) {
+ return Response.json({ message: 'Function not found' }, { status: 404, headers: corsHeaders })
+ }
+ return Response.json(func, { headers: corsHeaders })
+ } catch {
+ return Response.json({ message: 'Function not found' }, { status: 404, headers: corsHeaders })
+ }
+}
+
+async function getEdgeFunctionBody(slug: string): Promise {
+ if (slug === 'main' || slug === 'traffic-one') {
+ return Response.json({ message: 'Function not found' }, { status: 404, headers: corsHeaders })
+ }
+
+ const dirPath = `${FUNCTIONS_DIR}/${slug}`
+ try {
+ const files: Array<{ name: string; content: string }> = []
+
+ for await (const entry of Deno.readDir(dirPath)) {
+ if (!entry.isFile) continue
+ const content = await Deno.readTextFile(`${dirPath}/${entry.name}`)
+ files.push({ name: entry.name, content })
+ }
+
+ return Response.json(files, { headers: corsHeaders })
+ } catch {
+ return Response.json({ message: 'Function not found' }, { status: 404, headers: corsHeaders })
+ }
+}
+
+// Resolve the functions backend for a ref, bubbling missing-provisioner
+// state up as a 501 response. Shared across the three GET handlers so the
+// dispatcher stays flat.
+//
+// C1: IDOR blocker. Every call site must pass `profileId` so we can verify
+// the caller is a member of the project's organization before we reach into
+// the per-project `endpoint` / `serviceKey`. Without this gate, any
+// authenticated user could enumerate/read edge-function metadata across
+// tenants by guessing refs. `getProjectByRef` returns `null` for non-members
+// and we translate that into a 404 (same shape as the rest of the project
+// routes, so we don't leak existence of unrelated refs).
+async function resolveFunctionsBackend(
+ pool: Pool,
+ ref: string,
+ profileId: number,
+): Promise {
+ // L4: reject malformed refs before any DB work. All three edge-function
+ // GET handlers (list / detail / body) funnel through here, so putting
+ // the check in one place is enough to cover `GET /{ref}/functions*`.
+ const bad = assertValidRef(ref)
+ if (bad) return bad
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) {
+ return Response.json({ message: 'Project not found' }, { status: 404, headers: corsHeaders })
+ }
+ try {
+ return await getProjectBackend(ref, pool)
+ } catch (err) {
+ if (err instanceof ProjectBackendNotProvisionedError) {
+ return notProvisionedResponse(err)
+ }
+ throw err
+ }
+}
+
+async function listRemoteFunctionsResponse(backend: ProjectBackend): Promise {
+ const functions = await listRemoteFunctions(backend)
+ return Response.json(functions, { headers: corsHeaders })
+}
+
+async function getRemoteFunctionResponse(backend: ProjectBackend, slug: string): Promise {
+ if (slug === 'main' || slug === 'traffic-one') {
+ return Response.json({ message: 'Function not found' }, { status: 404, headers: corsHeaders })
+ }
+ const entry = await getRemoteFunction(backend, slug)
+ if (!entry) {
+ return Response.json({ message: 'Function not found' }, { status: 404, headers: corsHeaders })
+ }
+ return Response.json(entry, { headers: corsHeaders })
+}
+
+async function getRemoteFunctionBodyResponse(
+ backend: ProjectBackend,
+ slug: string,
+): Promise {
+ if (slug === 'main' || slug === 'traffic-one') {
+ return Response.json({ message: 'Function not found' }, { status: 404, headers: corsHeaders })
+ }
+ const files = await getRemoteFunctionBody(backend, slug)
+ if (!files) {
+ return Response.json({ message: 'Function not found' }, { status: 404, headers: corsHeaders })
+ }
+ return Response.json(files, { headers: corsHeaders })
+}
diff --git a/traffic-one/functions/routes/replication.ts b/traffic-one/functions/routes/replication.ts
new file mode 100644
index 0000000000000..839d667f7ef92
--- /dev/null
+++ b/traffic-one/functions/routes/replication.ts
@@ -0,0 +1,226 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import { getProjectByRef } from '../services/project.service.ts'
+
+const REPLICATION_UNSUPPORTED_MESSAGE =
+ 'Logical replication is not available in self-hosted deployments'
+
+function notSupportedResponse(): Response {
+ return Response.json(
+ { code: 'self_hosted_unsupported', message: REPLICATION_UNSUPPORTED_MESSAGE },
+ { status: 501, headers: corsHeaders },
+ )
+}
+
+function notFoundResponse(message = 'Not Found'): Response {
+ return Response.json({ message }, { status: 404, headers: corsHeaders })
+}
+
+function methodNotAllowedResponse(): Response {
+ return Response.json({ message: 'Method not allowed' }, { status: 405, headers: corsHeaders })
+}
+
+function parsePipelineId(raw: string): number {
+ const parsed = Number.parseInt(raw, 10)
+ return Number.isFinite(parsed) ? parsed : 0
+}
+
+// ── Handler ────────────────────────────────────────────────
+
+export async function handleReplication(
+ _req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ profileId: number,
+ _gotrueId: string,
+ _email: string,
+): Promise {
+ const refMatch = path.match(/^\/([^/]+)(\/.*)?$/)
+ if (!refMatch) {
+ return notFoundResponse()
+ }
+
+ const ref = refMatch[1]
+ const subPath = refMatch[2] || ''
+
+ const project = await getProjectByRef(pool, ref, profileId)
+ if (!project) {
+ return notFoundResponse('Project not found')
+ }
+
+ // ── Destinations ───────────────────────────────────────
+ if (subPath === '/destinations') {
+ if (method === 'GET') {
+ return Response.json({ destinations: [] }, { headers: corsHeaders })
+ }
+ if (method === 'POST') return notSupportedResponse()
+ return methodNotAllowedResponse()
+ }
+
+ if (subPath === '/destinations/validate') {
+ if (method === 'POST') return notSupportedResponse()
+ return methodNotAllowedResponse()
+ }
+
+ const destByIdMatch = subPath.match(/^\/destinations\/([^/]+)$/)
+ if (destByIdMatch) {
+ if (method === 'GET') {
+ return notFoundResponse('Destination not found')
+ }
+ if (method === 'PATCH' || method === 'PUT' || method === 'DELETE') {
+ return notSupportedResponse()
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── Destinations-Pipelines ─────────────────────────────
+ if (subPath === '/destinations-pipelines') {
+ if (method === 'GET') {
+ return Response.json({ destinations_pipelines: [] }, { headers: corsHeaders })
+ }
+ if (method === 'POST') return notSupportedResponse()
+ return methodNotAllowedResponse()
+ }
+
+ const destPipelineMatch = subPath.match(/^\/destinations-pipelines\/([^/]+)\/([^/]+)$/)
+ if (destPipelineMatch) {
+ if (method === 'POST' || method === 'DELETE') {
+ return notSupportedResponse()
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── Tenants-Sources ────────────────────────────────────
+ if (subPath === '/tenants-sources') {
+ if (method === 'GET') {
+ return Response.json({ tenants_sources: [] }, { headers: corsHeaders })
+ }
+ if (method === 'POST') return notSupportedResponse()
+ return methodNotAllowedResponse()
+ }
+
+ // ── Pipelines ──────────────────────────────────────────
+ if (subPath === '/pipelines') {
+ if (method === 'GET') {
+ return Response.json({ pipelines: [] }, { headers: corsHeaders })
+ }
+ if (method === 'POST') return notSupportedResponse()
+ return methodNotAllowedResponse()
+ }
+
+ if (subPath === '/pipelines/validate') {
+ if (method === 'POST') return notSupportedResponse()
+ return methodNotAllowedResponse()
+ }
+
+ const pipelineStatusMatch = subPath.match(/^\/pipelines\/([^/]+)\/status$/)
+ if (pipelineStatusMatch) {
+ if (method === 'GET') {
+ return Response.json(
+ {
+ pipeline_id: parsePipelineId(pipelineStatusMatch[1]),
+ status: { name: 'stopped' },
+ },
+ { headers: corsHeaders },
+ )
+ }
+ return methodNotAllowedResponse()
+ }
+
+ const pipelineReplicationStatusMatch = subPath.match(/^\/pipelines\/([^/]+)\/replication-status$/)
+ if (pipelineReplicationStatusMatch) {
+ if (method === 'GET') {
+ return Response.json(
+ {
+ pipeline_id: parsePipelineId(pipelineReplicationStatusMatch[1]),
+ replication_slots: [],
+ table_statuses: [],
+ },
+ { headers: corsHeaders },
+ )
+ }
+ return methodNotAllowedResponse()
+ }
+
+ const pipelineVersionMatch = subPath.match(/^\/pipelines\/([^/]+)\/version$/)
+ if (pipelineVersionMatch) {
+ if (method === 'GET') {
+ return Response.json(
+ {
+ pipeline_id: parsePipelineId(pipelineVersionMatch[1]),
+ versions: [],
+ },
+ { headers: corsHeaders },
+ )
+ }
+ if (method === 'POST') return notSupportedResponse()
+ return methodNotAllowedResponse()
+ }
+
+ const pipelineActionMatch = subPath.match(/^\/pipelines\/([^/]+)\/(start|stop|rollback-tables)$/)
+ if (pipelineActionMatch) {
+ if (method === 'POST') return notSupportedResponse()
+ return methodNotAllowedResponse()
+ }
+
+ const pipelineByIdMatch = subPath.match(/^\/pipelines\/([^/]+)$/)
+ if (pipelineByIdMatch) {
+ if (method === 'GET') {
+ return notFoundResponse('Pipeline not found')
+ }
+ if (method === 'PATCH' || method === 'PUT' || method === 'DELETE') {
+ return notSupportedResponse()
+ }
+ return methodNotAllowedResponse()
+ }
+
+ // ── Sources ────────────────────────────────────────────
+ if (subPath === '/sources') {
+ if (method === 'GET') {
+ return Response.json({ sources: [] }, { headers: corsHeaders })
+ }
+ if (method === 'POST') return notSupportedResponse()
+ return methodNotAllowedResponse()
+ }
+
+ const sourceTablesMatch = subPath.match(/^\/sources\/([^/]+)\/tables$/)
+ if (sourceTablesMatch) {
+ if (method === 'GET') {
+ return Response.json({ tables: [] }, { headers: corsHeaders })
+ }
+ return methodNotAllowedResponse()
+ }
+
+ if (subPath.match(/^\/sources\/([^/]+)\/publications$/)) {
+ if (method === 'GET') {
+ return Response.json({ publications: [] }, { headers: corsHeaders })
+ }
+ if (method === 'POST') return notSupportedResponse()
+ return methodNotAllowedResponse()
+ }
+
+ if (subPath.match(/^\/sources\/([^/]+)\/publications\/([^/]+)$/)) {
+ if (method === 'GET') {
+ return notFoundResponse('Publication not found')
+ }
+ if (method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE') {
+ return notSupportedResponse()
+ }
+ return methodNotAllowedResponse()
+ }
+
+ const sourceByIdMatch = subPath.match(/^\/sources\/([^/]+)$/)
+ if (sourceByIdMatch) {
+ if (method === 'GET') {
+ return notFoundResponse('Source not found')
+ }
+ if (method === 'PATCH' || method === 'PUT' || method === 'DELETE') {
+ return notSupportedResponse()
+ }
+ return methodNotAllowedResponse()
+ }
+
+ return notFoundResponse()
+}
diff --git a/traffic-one/functions/routes/scoped-access-tokens.ts b/traffic-one/functions/routes/scoped-access-tokens.ts
new file mode 100644
index 0000000000000..3235b64e007ac
--- /dev/null
+++ b/traffic-one/functions/routes/scoped-access-tokens.ts
@@ -0,0 +1,57 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import { corsHeaders } from '../index.ts'
+import {
+ createScopedAccessToken,
+ deleteScopedAccessToken,
+ listScopedAccessTokens,
+} from '../services/access-token.service.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+
+export async function handleScopedAccessTokens(
+ req: Request,
+ path: string,
+ method: string,
+ pool: Pool,
+ gotrueId: string,
+ email: string,
+ profileId: number,
+): Promise {
+ const ip = getClientIp(req)
+ const auditContext = { email, ip, method, route: '/profile' + path }
+
+ if (method === 'GET' && path === '/scoped-access-tokens') {
+ const tokens = await listScopedAccessTokens(pool, profileId)
+ return Response.json(tokens, { headers: corsHeaders })
+ }
+
+ if (method === 'POST' && path === '/scoped-access-tokens') {
+ const body = await req.json().catch(() => ({}))
+ if (!body.name || !body.permissions) {
+ return Response.json(
+ { message: 'name and permissions are required' },
+ { status: 400, headers: corsHeaders },
+ )
+ }
+ const token = await createScopedAccessToken(pool, profileId, body, gotrueId, auditContext)
+ return Response.json(token, { status: 201, headers: corsHeaders })
+ }
+
+ const deleteMatch = path.match(/^\/scoped-access-tokens\/([a-f0-9-]+)$/i)
+ if (method === 'DELETE' && deleteMatch) {
+ const tokenId = deleteMatch[1]
+ const deleted = await deleteScopedAccessToken(pool, profileId, tokenId, gotrueId, auditContext)
+ if (!deleted) {
+ return Response.json({ message: 'Token not found' }, { status: 404, headers: corsHeaders })
+ }
+ return Response.json({ message: 'Token deleted' }, { headers: corsHeaders })
+ }
+
+ return Response.json(
+ { message: 'Method not allowed' },
+ {
+ status: 405,
+ headers: corsHeaders,
+ },
+ )
+}
diff --git a/traffic-one/functions/routes/update-email.ts b/traffic-one/functions/routes/update-email.ts
new file mode 100644
index 0000000000000..8fd5915a192da
--- /dev/null
+++ b/traffic-one/functions/routes/update-email.ts
@@ -0,0 +1,84 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+import { createClient } from 'npm:@supabase/supabase-js@2'
+
+import { corsHeaders } from '../index.ts'
+import { updatePrimaryEmail } from '../services/profile.service.ts'
+import { getClientIp } from '../utils/client-ip.ts'
+
+const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+
+export async function handleUpdateEmail(
+ req: Request,
+ method: string,
+ pool: Pool,
+ gotrueId: string,
+ email: string,
+ _profileId: number,
+): Promise {
+ if (method !== 'PUT') {
+ return Response.json(
+ { message: 'Method not allowed' },
+ {
+ status: 405,
+ headers: corsHeaders,
+ },
+ )
+ }
+
+ const body = await req.json().catch(() => ({}))
+ const newEmail: unknown = body?.newEmail
+
+ if (typeof newEmail !== 'string' || !EMAIL_REGEX.test(newEmail)) {
+ return Response.json(
+ { message: 'Invalid email' },
+ {
+ status: 400,
+ headers: corsHeaders,
+ },
+ )
+ }
+
+ const supabaseUrl = Deno.env.get('SUPABASE_URL')
+ const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ??
+ Deno.env.get('SUPABASE_SERVICE_KEY')
+
+ if (!supabaseUrl || !serviceKey) {
+ return Response.json(
+ { message: 'Server misconfigured: missing SUPABASE_SERVICE_ROLE_KEY' },
+ { status: 500, headers: corsHeaders },
+ )
+ }
+
+ const admin = createClient(supabaseUrl, serviceKey, {
+ auth: {
+ autoRefreshToken: false,
+ persistSession: false,
+ detectSessionInUrl: false,
+ },
+ })
+
+ const { error } = await admin.auth.admin.updateUserById(gotrueId, {
+ email: newEmail,
+ })
+
+ if (error) {
+ return Response.json(
+ { message: error.message },
+ {
+ status: error.status ?? 500,
+ headers: corsHeaders,
+ },
+ )
+ }
+
+ const ip = getClientIp(req)
+
+ const profile = await updatePrimaryEmail(pool, gotrueId, newEmail, {
+ email,
+ ip,
+ method,
+ route: '/update-email',
+ })
+
+ return Response.json(profile, { headers: corsHeaders })
+}
diff --git a/traffic-one/functions/services/access-token.service.ts b/traffic-one/functions/services/access-token.service.ts
new file mode 100644
index 0000000000000..024f360a4c902
--- /dev/null
+++ b/traffic-one/functions/services/access-token.service.ts
@@ -0,0 +1,310 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import type {
+ AccessToken,
+ CreateAccessTokenResponse,
+ CreateScopedAccessTokenResponse,
+ ScopedAccessToken,
+} from '../types/api.ts'
+
+async function hashToken(token: string): Promise {
+ const encoder = new TextEncoder()
+ const data = encoder.encode(token)
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data)
+ const hashArray = Array.from(new Uint8Array(hashBuffer))
+ return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
+}
+
+function generateToken(): string {
+ const bytes = new Uint8Array(32)
+ crypto.getRandomValues(bytes)
+ return Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('')
+}
+
+function tokenAlias(token: string): string {
+ return token.slice(0, 8) + '...' + token.slice(-4)
+}
+
+interface AccessTokenRow {
+ id: number
+ profile_id: number
+ name: string
+ token_hash: string
+ token_alias: string
+ scope: string | null
+ expires_at: string | null
+ last_used_at: string | null
+ created_at: string
+}
+
+interface ScopedTokenRow {
+ id: string
+ profile_id: number
+ name: string
+ token_hash: string
+ token_alias: string
+ permissions: string[]
+ organization_slugs: string[]
+ project_refs: string[]
+ expires_at: string | null
+ last_used_at: string | null
+ created_at: string
+}
+
+function rowToAccessToken(row: AccessTokenRow): AccessToken {
+ return {
+ id: row.id,
+ name: row.name,
+ token_alias: row.token_alias,
+ scope: (row.scope as AccessToken['scope']) ?? undefined,
+ expires_at: row.expires_at,
+ last_used_at: row.last_used_at,
+ created_at: row.created_at,
+ }
+}
+
+function rowToScopedToken(row: ScopedTokenRow): ScopedAccessToken {
+ return {
+ id: row.id,
+ name: row.name,
+ token_alias: row.token_alias,
+ permissions: row.permissions,
+ organization_slugs: row.organization_slugs?.length ? row.organization_slugs : undefined,
+ project_refs: row.project_refs?.length ? row.project_refs : undefined,
+ expires_at: row.expires_at,
+ last_used_at: row.last_used_at,
+ created_at: row.created_at,
+ }
+}
+
+export async function listAccessTokens(pool: Pool, profileId: number): Promise {
+ const connection = await pool.connect()
+ try {
+ const result = await connection.queryObject`
+ SELECT id, profile_id, name, token_hash, token_alias, scope, expires_at, last_used_at, created_at
+ FROM traffic.access_tokens WHERE profile_id = ${profileId}
+ ORDER BY created_at DESC
+ `
+ return result.rows.map(rowToAccessToken)
+ } finally {
+ connection.release()
+ }
+}
+
+export async function createAccessToken(
+ pool: Pool,
+ profileId: number,
+ name: string,
+ gotrueId: string,
+ auditContext?: { email: string; ip: string; method: string; route: string },
+): Promise {
+ const rawToken = generateToken()
+ const hash = await hashToken(rawToken)
+ const alias = tokenAlias(rawToken)
+
+ const connection = await pool.connect()
+ try {
+ const tx = connection.createTransaction('create_access_token')
+ await tx.begin()
+
+ const result = await tx.queryObject`
+ INSERT INTO traffic.access_tokens (profile_id, name, token_hash, token_alias)
+ VALUES (${profileId}, ${name}, ${hash}, ${alias})
+ RETURNING *
+ `
+
+ if (auditContext) {
+ await tx.queryObject`
+ INSERT INTO traffic.audit_logs (
+ id, profile_id, action_name, action_metadata,
+ actor_id, actor_type, actor_metadata,
+ target_description, target_metadata, occurred_at
+ ) VALUES (
+ gen_random_uuid(), ${profileId}, 'access_tokens.insert',
+ ${
+ JSON.stringify([{ method: auditContext.method, route: auditContext.route, status: 201 }])
+ }::jsonb,
+ ${gotrueId}, 'user',
+ ${JSON.stringify([{ email: auditContext.email, ip: auditContext.ip }])}::jsonb,
+ ${'access_tokens #' + result.rows[0].id}, '{}'::jsonb, now()
+ )
+ `
+ }
+
+ await tx.commit()
+ return { ...rowToAccessToken(result.rows[0]), token: rawToken }
+ } finally {
+ connection.release()
+ }
+}
+
+export async function deleteAccessToken(
+ pool: Pool,
+ profileId: number,
+ tokenId: number,
+ gotrueId: string,
+ auditContext?: { email: string; ip: string; method: string; route: string },
+): Promise {
+ const connection = await pool.connect()
+ try {
+ const tx = connection.createTransaction('delete_access_token')
+ await tx.begin()
+
+ const result = await tx.queryObject`
+ DELETE FROM traffic.access_tokens WHERE id = ${tokenId} AND profile_id = ${profileId}
+ `
+
+ if ((result.rowCount ?? 0) === 0) {
+ await tx.rollback()
+ return false
+ }
+
+ if (auditContext) {
+ await tx.queryObject`
+ INSERT INTO traffic.audit_logs (
+ id, profile_id, action_name, action_metadata,
+ actor_id, actor_type, actor_metadata,
+ target_description, target_metadata, occurred_at
+ ) VALUES (
+ gen_random_uuid(), ${profileId}, 'access_tokens.delete',
+ ${
+ JSON.stringify([{ method: auditContext.method, route: auditContext.route, status: 200 }])
+ }::jsonb,
+ ${gotrueId}, 'user',
+ ${JSON.stringify([{ email: auditContext.email, ip: auditContext.ip }])}::jsonb,
+ ${'access_tokens #' + tokenId}, '{}'::jsonb, now()
+ )
+ `
+ }
+
+ await tx.commit()
+ return true
+ } finally {
+ connection.release()
+ }
+}
+
+export async function listScopedAccessTokens(
+ pool: Pool,
+ profileId: number,
+): Promise {
+ const connection = await pool.connect()
+ try {
+ const result = await connection.queryObject`
+ SELECT * FROM traffic.scoped_access_tokens WHERE profile_id = ${profileId}
+ ORDER BY created_at DESC
+ `
+ return result.rows.map(rowToScopedToken)
+ } finally {
+ connection.release()
+ }
+}
+
+export async function createScopedAccessToken(
+ pool: Pool,
+ profileId: number,
+ body: {
+ name: string
+ permissions: string[]
+ organization_slugs?: string[]
+ project_refs?: string[]
+ expires_at?: string
+ },
+ gotrueId: string,
+ auditContext?: { email: string; ip: string; method: string; route: string },
+): Promise {
+ const rawToken = generateToken()
+ const hash = await hashToken(rawToken)
+ const alias = tokenAlias(rawToken)
+
+ const connection = await pool.connect()
+ try {
+ const tx = connection.createTransaction('create_scoped_token')
+ await tx.begin()
+
+ const expiresAt = body.expires_at ? new Date(body.expires_at).toISOString() : null
+
+ const result = await tx.queryObject`
+ INSERT INTO traffic.scoped_access_tokens (
+ profile_id, name, token_hash, token_alias, permissions,
+ organization_slugs, project_refs, expires_at
+ ) VALUES (
+ ${profileId}, ${body.name}, ${hash}, ${alias},
+ ${body.permissions}, ${body.organization_slugs ?? []},
+ ${body.project_refs ?? []}, ${expiresAt}
+ )
+ RETURNING *
+ `
+
+ if (auditContext) {
+ await tx.queryObject`
+ INSERT INTO traffic.audit_logs (
+ id, profile_id, action_name, action_metadata,
+ actor_id, actor_type, actor_metadata,
+ target_description, target_metadata, occurred_at
+ ) VALUES (
+ gen_random_uuid(), ${profileId}, 'scoped_access_tokens.insert',
+ ${
+ JSON.stringify([{ method: auditContext.method, route: auditContext.route, status: 201 }])
+ }::jsonb,
+ ${gotrueId}, 'user',
+ ${JSON.stringify([{ email: auditContext.email, ip: auditContext.ip }])}::jsonb,
+ ${'scoped_access_tokens #' + result.rows[0].id}, '{}'::jsonb, now()
+ )
+ `
+ }
+
+ await tx.commit()
+ return { ...rowToScopedToken(result.rows[0]), token: rawToken }
+ } finally {
+ connection.release()
+ }
+}
+
+export async function deleteScopedAccessToken(
+ pool: Pool,
+ profileId: number,
+ tokenId: string,
+ gotrueId: string,
+ auditContext?: { email: string; ip: string; method: string; route: string },
+): Promise {
+ const connection = await pool.connect()
+ try {
+ const tx = connection.createTransaction('delete_scoped_token')
+ await tx.begin()
+
+ const result = await tx.queryObject`
+ DELETE FROM traffic.scoped_access_tokens WHERE id = ${tokenId}::uuid AND profile_id = ${profileId}
+ `
+
+ if ((result.rowCount ?? 0) === 0) {
+ await tx.rollback()
+ return false
+ }
+
+ if (auditContext) {
+ await tx.queryObject`
+ INSERT INTO traffic.audit_logs (
+ id, profile_id, action_name, action_metadata,
+ actor_id, actor_type, actor_metadata,
+ target_description, target_metadata, occurred_at
+ ) VALUES (
+ gen_random_uuid(), ${profileId}, 'scoped_access_tokens.delete',
+ ${
+ JSON.stringify([{ method: auditContext.method, route: auditContext.route, status: 200 }])
+ }::jsonb,
+ ${gotrueId}, 'user',
+ ${JSON.stringify([{ email: auditContext.email, ip: auditContext.ip }])}::jsonb,
+ ${'scoped_access_tokens #' + tokenId}, '{}'::jsonb, now()
+ )
+ `
+ }
+
+ await tx.commit()
+ return true
+ } finally {
+ connection.release()
+ }
+}
diff --git a/traffic-one/functions/services/billing.service.ts b/traffic-one/functions/services/billing.service.ts
new file mode 100644
index 0000000000000..4223e02edb6e6
--- /dev/null
+++ b/traffic-one/functions/services/billing.service.ts
@@ -0,0 +1,686 @@
+import type { Pool } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'
+
+import type {
+ CustomerResponse,
+ GetSubscriptionResponse,
+ InvoiceResponse,
+ PaymentMethodResponse,
+ ProjectAddonsResponse,
+ SelectedAddon,
+ TaxIdResponse,
+} from '../types/billing.ts'
+
+// ── Row types ────────────────────────────────────────────
+
+interface SubscriptionRow {
+ id: string
+ organization_id: number
+ status: string | null
+ tier: string
+ plan_id: string
+ plan_name: string
+ billing_cycle_anchor: number
+ usage_billing_enabled: boolean
+ nano_enabled: boolean
+ current_period_start: string | null
+ current_period_end: string | null
+ stripe_subscription_id: string | null
+ stripe_customer_id: string | null
+}
+
+interface CustomerRow {
+ id: number
+ organization_id: number
+ stripe_customer_id: string | null
+ billing_name: string | null
+ city: string | null
+ country: string | null
+ line1: string | null
+ line2: string | null
+ postal_code: string | null
+ state: string | null
+}
+
+interface PaymentMethodRow {
+ id: string
+ type: string
+ card_brand: string | null
+ card_last4: string | null
+ card_exp_month: number | null
+ card_exp_year: number | null
+ is_default: boolean
+ stripe_payment_method_id: string | null
+}
+
+interface InvoiceRow {
+ id: string
+ number: string | null
+ status: string
+ amount_due: number
+ subtotal: number
+ period_start: string | null
+ period_end: string | null
+ invoice_pdf: string | null
+ stripe_invoice_id: string | null
+ subscription_id: string | null
+ created_at: string
+}
+
+interface TaxIdRow {
+ id: number
+ type: string
+ value: string
+ country: string | null
+ created_at: string
+}
+
+interface CreditRow {
+ balance: number
+}
+
+interface ProjectAddonRow {
+ id: number
+ project_ref: string
+ addon_type: string
+ addon_variant: string
+}
+
+// ── Row mappers ──────────────────────────────────────────
+
+function subscriptionToResponse(row: SubscriptionRow): GetSubscriptionResponse {
+ const periodStart = row.current_period_start
+ ? Math.floor(new Date(row.current_period_start).getTime() / 1000)
+ : 0
+ const periodEnd = row.current_period_end
+ ? Math.floor(new Date(row.current_period_end).getTime() / 1000)
+ : 0
+
+ return {
+ addons: [],
+ billing_cycle_anchor: Number(row.billing_cycle_anchor) || 0,
+ billing_via_partner: false,
+ current_period_end: periodEnd,
+ current_period_start: periodStart,
+ customer_balance: 0,
+ next_invoice_at: periodEnd,
+ payment_method_type: 'none',
+ plan: {
+ id: row.plan_id as GetSubscriptionResponse['plan']['id'],
+ name: row.plan_name,
+ },
+ project_addons: [],
+ scheduled_plan_change: null,
+ usage_billing_enabled: row.usage_billing_enabled ?? false,
+ }
+}
+
+function invoiceRowToResponse(row: InvoiceRow): InvoiceResponse {
+ return {
+ id: row.id,
+ number: row.number,
+ status: row.status,
+ amount_due: Number(row.amount_due),
+ subtotal: Number(row.subtotal),
+ period_start: row.period_start,
+ period_end: row.period_end,
+ invoice_pdf: row.invoice_pdf,
+ stripe_invoice_id: row.stripe_invoice_id,
+ subscription_id: row.subscription_id,
+ created_at: row.created_at,
+ }
+}
+
+function customerRowToResponse(row: CustomerRow): CustomerResponse {
+ return {
+ billing_name: row.billing_name,
+ city: row.city,
+ country: row.country,
+ line1: row.line1,
+ line2: row.line2,
+ postal_code: row.postal_code,
+ state: row.state,
+ }
+}
+
+function paymentMethodRowToResponse(row: PaymentMethodRow): PaymentMethodResponse {
+ return {
+ id: row.id,
+ type: row.type,
+ card_brand: row.card_brand,
+ card_last4: row.card_last4,
+ card_exp_month: row.card_exp_month,
+ card_exp_year: row.card_exp_year,
+ is_default: row.is_default,
+ }
+}
+
+// Wraps the latest tax_id row (or null) in the OpenAPI-shape
+// `{ tax_id: { country, type, value } | null }` that Studio consumes via
+// `components['schemas']['TaxIdResponse']`. We only surface a single tax ID
+// per org to match the upstream Supabase API semantics.
+function taxIdRowToResponse(row: TaxIdRow | null): TaxIdResponse {
+ if (!row) return { tax_id: null }
+ return {
+ tax_id: {
+ country: row.country ?? '',
+ type: row.type,
+ value: row.value,
+ },
+ }
+}
+
+// ── Subscription ─────────────────────────────────────────
+
+export async function getSubscription(pool: Pool, orgId: number): Promise {
+ const connection = await pool.connect()
+ try {
+ const result = await connection.queryObject`
+ SELECT * FROM traffic.subscriptions WHERE organization_id = ${orgId}
+ `
+ if (result.rows.length === 0) {
+ return subscriptionToResponse({
+ id: '',
+ organization_id: orgId,
+ status: 'active',
+ tier: 'tier_free',
+ plan_id: 'free',
+ plan_name: 'Free',
+ billing_cycle_anchor: 0,
+ usage_billing_enabled: false,
+ nano_enabled: true,
+ current_period_start: null,
+ current_period_end: null,
+ stripe_subscription_id: null,
+ stripe_customer_id: null,
+ })
+ }
+ return subscriptionToResponse(result.rows[0])
+ } finally {
+ connection.release()
+ }
+}
+
+export async function updateSubscription(
+ pool: Pool,
+ orgId: number,
+ planId: string,
+ planName: string,
+ tier: string,
+): Promise {
+ const connection = await pool.connect()
+ try {
+ const tx = connection.createTransaction('update_subscription')
+ await tx.begin()
+
+ const existing = await tx.queryObject<{ id: string }>`
+ SELECT id FROM traffic.subscriptions WHERE organization_id = ${orgId}
+ `
+
+ let result
+ if (existing.rows.length === 0) {
+ result = await tx.queryObject`
+ INSERT INTO traffic.subscriptions (organization_id, status, plan_id, plan_name, tier)
+ VALUES (${orgId}, 'active', ${planId}, ${planName}, ${tier})
+ RETURNING *
+ `
+ } else {
+ result = await tx.queryObject`
+ UPDATE traffic.subscriptions
+ SET plan_id = ${planId}, plan_name = ${planName}, tier = ${tier}
+ WHERE organization_id = ${orgId}
+ RETURNING *
+ `
+ }
+
+ await tx.commit()
+ return subscriptionToResponse(result.rows[0])
+ } finally {
+ connection.release()
+ }
+}
+
+export async function previewSubscriptionChange(
+ pool: Pool,
+ orgId: number,
+ _targetPlan: string,
+): Promise<{ amount_due: number; billing_preview: Record }> {
+ const connection = await pool.connect()
+ try {
+ const sub = await connection.queryObject`
+ SELECT * FROM traffic.subscriptions WHERE organization_id = ${orgId}
+ `
+ const _current = sub.rows[0] ?? null
+ return { amount_due: 0, billing_preview: {} }
+ } finally {
+ connection.release()
+ }
+}
+
+// ── Plans ────────────────────────────────────────────────
+
+export function getPlans(): Record[] {
+ return [
+ { id: 'free', name: 'Free', price: 0, description: 'Perfect for hobby projects', features: [] },
+ {
+ id: 'pro',
+ name: 'Pro',
+ price: 2500,
+ description: 'For production applications',
+ features: [],
+ },
+ { id: 'team', name: 'Team', price: 59900, description: 'For scaling teams', features: [] },
+ { id: 'enterprise', name: 'Enterprise', price: 0, description: 'Custom pricing', features: [] },
+ ]
+}
+
+// ── Invoices ─────────────────────────────────────────────
+
+export async function listInvoices(
+ pool: Pool,
+ orgId: number,
+ offset = 0,
+ limit = 10,
+): Promise {
+ const connection = await pool.connect()
+ try {
+ const result = await connection.queryObject`
+ SELECT * FROM traffic.invoices
+ WHERE organization_id = ${orgId}
+ ORDER BY created_at DESC
+ OFFSET ${offset} LIMIT ${limit}
+ `
+ return result.rows.map(invoiceRowToResponse)
+ } finally {
+ connection.release()
+ }
+}
+
+export async function countInvoices(pool: Pool, orgId: number): Promise {
+ const connection = await pool.connect()
+ try {
+ const result = await connection.queryObject<{ count: number }>`
+ SELECT COUNT(*)::int AS count FROM traffic.invoices WHERE organization_id = ${orgId}
+ `
+ return result.rows[0].count
+ } finally {
+ connection.release()
+ }
+}
+
+export async function getInvoice(
+ pool: Pool,
+ orgId: number,
+ invoiceId: string,
+): Promise {
+ const connection = await pool.connect()
+ try {
+ const result = await connection.queryObject`
+ SELECT * FROM traffic.invoices
+ WHERE id = ${invoiceId} AND organization_id = ${orgId}
+ `
+ if (result.rows.length === 0) return null
+ return invoiceRowToResponse(result.rows[0])
+ } finally {
+ connection.release()
+ }
+}
+
+export async function countOverdueInvoices(pool: Pool): Promise {
+ const connection = await pool.connect()
+ try {
+ const result = await connection.queryObject<{ count: number }>`
+ SELECT COUNT(*)::int AS count FROM traffic.invoices
+ WHERE status IN ('open', 'past_due', 'uncollectible')
+ `
+ return result.rows[0].count
+ } finally {
+ connection.release()
+ }
+}
+
+// ── Customer ─────────────────────────────────────────────
+
+export async function getCustomer(pool: Pool, orgId: number): Promise {
+ const connection = await pool.connect()
+ try {
+ const result = await connection.queryObject`
+ SELECT * FROM traffic.customers WHERE organization_id = ${orgId}
+ `
+ if (result.rows.length === 0) {
+ return {
+ billing_name: null,
+ city: null,
+ country: null,
+ line1: null,
+ line2: null,
+ postal_code: null,
+ state: null,
+ }
+ }
+ return customerRowToResponse(result.rows[0])
+ } finally {
+ connection.release()
+ }
+}
+
+export async function upsertCustomer(
+ pool: Pool,
+ orgId: number,
+ data: Partial,
+): Promise {
+ const connection = await pool.connect()
+ try {
+ const result = await connection.queryObject`
+ INSERT INTO traffic.customers (organization_id, billing_name, city, country, line1, line2, postal_code, state)
+ VALUES (
+ ${orgId},
+ ${data.billing_name ?? null},
+ ${data.city ?? null},
+ ${data.country ?? null},
+ ${data.line1 ?? null},
+ ${data.line2 ?? null},
+ ${data.postal_code ?? null},
+ ${data.state ?? null}
+ )
+ ON CONFLICT (organization_id) DO UPDATE SET
+ billing_name = COALESCE(EXCLUDED.billing_name, traffic.customers.billing_name),
+ city = COALESCE(EXCLUDED.city, traffic.customers.city),
+ country = COALESCE(EXCLUDED.country, traffic.customers.country),
+ line1 = COALESCE(EXCLUDED.line1, traffic.customers.line1),
+ line2 = COALESCE(EXCLUDED.line2, traffic.customers.line2),
+ postal_code = COALESCE(EXCLUDED.postal_code, traffic.customers.postal_code),
+ state = COALESCE(EXCLUDED.state, traffic.customers.state),
+ updated_at = now()
+ RETURNING *
+ `
+ return customerRowToResponse(result.rows[0])
+ } finally {
+ connection.release()
+ }
+}
+
+// ── Payment Methods ──────────────────────────────────────
+
+export async function listPaymentMethods(
+ pool: Pool,
+ orgId: number,
+): Promise {
+ const connection = await pool.connect()
+ try {
+ const result = await connection.queryObject`
+ SELECT * FROM traffic.payment_methods
+ WHERE organization_id = ${orgId}
+ ORDER BY created_at DESC
+ `
+ return result.rows.map(paymentMethodRowToResponse)
+ } finally {
+ connection.release()
+ }
+}
+
+export async function deletePaymentMethod(
+ pool: Pool,
+ orgId: number,
+ paymentMethodId: string,
+): Promise {
+ const connection = await pool.connect()
+ try {
+ const result = await connection.queryObject`
+ DELETE FROM traffic.payment_methods
+ WHERE id = ${paymentMethodId} AND organization_id = ${orgId}
+ `
+ return (result.rowCount ?? 0) > 0
+ } finally {
+ connection.release()
+ }
+}
+
+export async function setDefaultPaymentMethod(
+ pool: Pool,
+ orgId: number,
+ paymentMethodId: string,
+): Promise {
+ const connection = await pool.connect()
+ try {
+ const tx = connection.createTransaction('set_default_pm')
+ await tx.begin()
+
+ await tx.queryObject`
+ UPDATE traffic.payment_methods SET is_default = false
+ WHERE organization_id = ${orgId}
+ `
+ const result = await tx.queryObject`
+ UPDATE traffic.payment_methods SET is_default = true
+ WHERE id = ${paymentMethodId} AND organization_id = ${orgId}
+ `
+
+ await tx.commit()
+ return (result.rowCount ?? 0) > 0
+ } finally {
+ connection.release()
+ }
+}
+
+// ── Tax IDs ──────────────────────────────────────────────
+
+// Studio only renders at most one tax ID per org (see
+// `apps/studio/data/organizations/organization-tax-id-query.ts`), so we return
+// the newest row in the OpenAPI `{ tax_id: { country, type, value } | null }`
+// envelope. When no row exists we emit `{ tax_id: null }`.
+export async function listTaxIds(pool: Pool, orgId: number): Promise {
+ const connection = await pool.connect()
+ try {
+ const result = await connection.queryObject`
+ SELECT * FROM traffic.tax_ids
+ WHERE organization_id = ${orgId}
+ ORDER BY created_at DESC
+ LIMIT 1
+ `
+ return taxIdRowToResponse(result.rows[0] ?? null)
+ } finally {
+ connection.release()
+ }
+}
+
+export async function upsertTaxId(
+ pool: Pool,
+ orgId: number,
+ type: string,
+ value: string,
+ country: string | null,
+): Promise {
+ const connection = await pool.connect()
+ try {
+ const result = await connection.queryObject`
+ INSERT INTO traffic.tax_ids (organization_id, type, value, country)
+ VALUES (${orgId}, ${type}, ${value}, ${country})
+ RETURNING *
+ `
+ return taxIdRowToResponse(result.rows[0])
+ } finally {
+ connection.release()
+ }
+}
+
+export async function deleteTaxId(pool: Pool, orgId: number, taxIdId: number): Promise