From 67bf66291c26a7cf9d5f233f75c6165fdffe48b3 Mon Sep 17 00:00:00 2001 From: Fabio Wakim Trentini Date: Wed, 13 May 2026 14:25:21 -0300 Subject: [PATCH 1/6] docs(integrations): post-probe PLAN revision + DORA API probe script (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live probes against a real Datadog tenant closed the high-impact unknowns that the original plan left open. Adds §9 to docs/PLAN-datadog.md with the schema corrections that came out of those probes — most notably: - Deployment events carry change_failure, recovery_time_sec, and remediation directly, so CFR and per-deploy MTTR both live on the deployment table, not on the failures table. - Failures have no commit attribution; field types are arrays (service, env, team) and triggering_commit_sha should be dropped from §3. - Pagination has no cursor mechanism — time-slicing with an inclusive boundary is the only path; idempotency via (provider, provider_event_id) makes the boundary overlap a no-op. - All 500 sampled deploys are source: "apm_deployments"; commits[] doesn't truncate even at number_of_commits=85; change_failure is tri-state (true/false/null). scripts/datadog_dora_probe.py is the throwaway exploration tool that produced these findings. Stdlib-only, three modes: single-page list, --dump for offline inspection, and --paginate-test for the cursor probe. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/PLAN-datadog.md | 366 ++++++++++++++++++++++++++++++++++ scripts/datadog_dora_probe.py | 332 ++++++++++++++++++++++++++++++ 2 files changed, 698 insertions(+) create mode 100755 scripts/datadog_dora_probe.py diff --git a/docs/PLAN-datadog.md b/docs/PLAN-datadog.md index ed5143d..9457984 100644 --- a/docs/PLAN-datadog.md +++ b/docs/PLAN-datadog.md @@ -401,3 +401,369 @@ working keys to: exact spelling has changed between API versions before). - Decide whether `query` should default to `*` or to `env:production` when the customer hasn't customized it. + +--- + +## 9. Revision (2026-05-13) — post-probe findings + +Live probe against a real production tenant via +`scripts/datadog_dora_probe.py`. Both endpoints returned 200 with +`from`/`to` as ISO 8601 strings (numeric epoch is rejected with HTTP +400 `"error decoding attribute \"from\": invalid type number"`). Five +deployments and five failures inspected end-to-end. + +### 9.1 What we got right + +- `POST /api/v2/dora/deployments` and `POST /api/v2/dora/failures` + exist and return the event-level data we need (§1 row 1 stands). +- The customer is emitting both flows: `source: "apm_deployments"` + on deploys (auto-detected by Datadog APM Deployment Tracking) and + `source: "api"` on failures (pushed manually by the customer, names + like `"RIO-978 | Pedidos sendo processados sem cobrança"` show they + register them as post-mortem from their incident workflow). We're + reading what they already have, not asking them to instrument + anything new. +- DORA event retention is 2 years; the 30-day initial backfill default + in §7 #1 stays well inside that envelope. + +### 9.2 What needs to change in §3 schema + +**Deployments — fields are nested and richer than modeled.** Top-level +`repository_url` and `commit_sha` don't exist. The shape is: + +```jsonc +{ + "type": "dora_deployment", + "id": "43vkaZNgiso", + "attributes": { + "git": { "commit_sha": "", "repository_id": "github.com//" }, + "commits": [{ "sha", "timestamp", "author": { "email", "canonical_email", "is_bot" }, + "message", "html_url", "change_lead_time", "time_to_deploy" }, …], + "pull_requests": [{ "created_at", "merged_at", "is_fully_automated" }, …], + "service": "search-microfrontend", + "env": "staging", // free-form string, customers don't normalize + "version": "0.14.7", + "team": "busca", + "change_failure": false, // TRI-STATE: true | false | null (null = pending evaluation) + "deployment_type": "standard", + "source": "apm_deployments", // 500/500 sampled deploys had this value + "started_at", "finished_at", "duration", "created_at", + "number_of_commits": 2, + "number_of_pull_requests": 1, + "averaged_metrics", "custom", + // The two fields below appear ONLY when change_failure == true (probed across 36 events): + "recovery_time_sec": 4890, // time-to-recovery for THIS deploy + "remediation": { "id": "X9RMqDwK-4c", "type": "rollback" } + } +} +``` + +Two findings from the 2026-05-13 follow-up probe matter for the schema: + +- **`change_failure` is tri-state (`true | false | null`).** ~2.4% of + sampled deploys carried `null` — likely "still inside Datadog's + evaluation window, no verdict yet". Aggregation must treat `null` + distinctly from `false`. CFR denominator should exclude `null` + events or surface them as a "pending" bucket in the dashboard. +- **`env` is free-form text.** Real values observed across 500 + deploys: `staging` (333), `live` (147), and the long tail + `stg`, `eval`, `taken`, `local`, `none`, `dev`, `test`. The + customer does not normalize. Do **not** model `env` as an enum; + any normalization happens at display time, not at ingestion. + +Revised `external_deployments` columns (replaces §3 version): + +```sql +create table external_deployments ( + id uuid primary key default gen_random_uuid(), + organization_id uuid not null references organizations(id) on delete cascade, + provider integration_provider not null, + provider_event_id text not null, -- DD's event id (e.g. "43vkaZNgiso") + repository_id uuid references repositories(id) on delete set null, + dd_repository_id text, -- DD slug, e.g. "github.com/rocketbus/foo" + service text, + env text, + team text, + version text, + commit_sha text, -- flattened from attributes.git.commit_sha + change_failure boolean, -- TRI-STATE: nullable on purpose; null = pending eval + deployment_type text, -- "standard", etc. + source text, -- "apm_deployments" in 100% of probed events + started_at timestamptz not null, + finished_at timestamptz, + duration_seconds integer, + number_of_commits integer, + number_of_pull_requests integer, + recovery_time_sec integer, -- present only when change_failure=true + remediation_type text, -- "rollback" observed; present only when change_failure=true + remediation_id text, -- DD's id for the remediation event + raw jsonb not null, + fetched_at timestamptz not null default now(), + unique (provider, provider_event_id) +); +``` + +**New table `external_deployment_commits`.** Each deploy carries per-commit +lead-time data we should not throw into `raw`-only — it's the join key for +the AI-vs-human CFR correlation. + +```sql +create table external_deployment_commits ( + deployment_id uuid not null references external_deployments(id) on delete cascade, + commit_sha text not null, + commit_timestamp timestamptz, + author_email text, + author_canonical_email text, + is_bot boolean, + change_lead_time integer, -- seconds + time_to_deploy integer, -- seconds + primary key (deployment_id, commit_sha) +); +create index external_deployment_commits_sha_idx on external_deployment_commits(commit_sha); +``` + +**Pull-request linking is unreliable.** Probed deploy returned a +`pull_requests[]` with `created_at: "0001-01-01T00:00:00Z"` (zero +value). Don't model a PR table for v1; if we need PR↔deploy linkage, +join through Iris's own PR data via `commit_sha`. + +**Failures — service/env/team are arrays, no commit attribution.** + +```jsonc +{ + "type": "dora_failure", + "id": "ab038562-17f5-4001-84b4-3748eec8b077", + "attributes": { + "service": ["platform-pricing-low-fare"], // array + "env": ["live"], // array + "team": ["pricing"], // array + "name": "RIO-978 | Pedidos sendo processados sem cobrança", + "severity": "Normal" | "High" | "Urgent", // textual + "started_at", "finished_at", "created_at", + "time_to_restore": 520167, // seconds + "source": "api", + "internal": {}, // empty in all 5 samples — skip + "custom": { "language": ["jvm"], "backstage.io/...": [...] } + } +} +``` + +Revised `external_incidents` columns (replaces §3 version): + +```sql +create table external_incidents ( + id uuid primary key default gen_random_uuid(), + organization_id uuid not null references organizations(id) on delete cascade, + provider integration_provider not null, + provider_event_id text not null, + service text[], -- DD returns arrays + env text[], + team text[], + name text, -- "RIO-978 | ..." + severity text, -- "Normal" | "High" | "Urgent" | … + started_at timestamptz not null, + finished_at timestamptz, + time_to_restore_seconds integer, + source text, -- "api" in observed data + raw jsonb not null, + fetched_at timestamptz not null default now(), + unique (provider, provider_event_id) +); +create index external_incidents_org_started_idx + on external_incidents(organization_id, started_at desc); +create index external_incidents_service_gin + on external_incidents using gin (service); +``` + +Dropped fields from the §3 version: `triggering_commit_sha`, +`repository_id` (failures don't carry either), the +`repository_id`-keyed index. + +### 9.3 Source-of-truth shift: both CFR and MTTR live on deployments + +The §3/§4 plan assumed CFR comes from incidents and MTTR comes from +incidents. The follow-up probe found that **both signals are +carried on the deployment event itself**: + +- `attributes.change_failure: bool` — the CFR flag. +- `attributes.recovery_time_sec: int` — per-deploy time-to-recovery, + present whenever `change_failure == true`. Observed range across + 36 events: 420s → 969610s (~11 days, an outlier; median is in the + low thousands of seconds). +- `attributes.remediation: { id, type }` — how the failure was + resolved. Only `"rollback"` observed so far; other documented + types include `"hotfix"` and `"forward_fix"`. + +That makes the deployment table the per-event source for **both** +CFR and MTTR. The failures table still matters for MTTR computed +*at the incident level* (which is what canonical DORA reports), but +the per-deploy lens is what powers the AI-vs-human correlation. + +Concrete impact: + +- **§4 PR 4.** `iris/analysis/dora_real.py` computes: + - **CFR** = count(`change_failure = true`) / count(`change_failure + in (true, false)`) — `null` deploys are excluded from the + denominator (pending Datadog's evaluation). + - **MTTR (per-deploy)** = mean/p50/p90 of `recovery_time_sec` + over failed deploys. This is the metric we surface alongside + AI-vs-human correlation. + - **MTTR (incident-level)** = mean/p50/p90 of + `external_incidents.time_to_restore_seconds`. This is the + canonical DORA-reporting number that goes on the dashboard + DORA card. +- **AI-vs-human CFR correlation (§7 #4).** Clean join now: + `external_deployment_commits.commit_sha` ↔ Iris's + `commit_origin.commit_sha`, filtered by + `external_deployments.change_failure = true`. No need to traverse + through `external_incidents` at all for this correlation. +- **Rollback rate as a new derived metric.** With + `remediation.type` per deploy, we can compute + `rollback_rate = count(remediation_type = 'rollback') / count(change_failure = true)` + and split it by code origin. This wasn't in the original PRD but + is a free byproduct of the schema and worth surfacing in PR 5. +- **Incident → deploy attribution** (would be needed to answer "which + deploy caused this incident?") is not available from Datadog and + has to be inferred: same `service`, `incident.started_at` ≥ + `deployment.finished_at`, narrowest matching window. Park this + behind a separate decision — not needed for the v1 dashboard. + +### 9.4 Repository matching uses the DD slug, not a URL + +`attributes.git.repository_id` is the string +`"github.com/rocketbus/search-microfrontend"` — host + path, no +scheme, no `.git`. Iris's `repositories` table stores `remote_url` +(varies in shape across customers). The §1 #5 auto-match +proposal stands but the lookup is a **normalize-both-sides** problem: + +- Normalize DD slug: lowercase, strip leading scheme/`www`, strip + trailing `.git` — but DD already gives a normalized form. +- Normalize `repositories.remote_url`: parse host + path, drop + scheme/`.git`/`www`, lowercase. +- Compare normalized strings. + +Store the raw DD slug in `external_deployments.dd_repository_id` so +the join is debuggable when it fails. + +### 9.5 Pagination — resolved: time-slicing only (probed 2026-05-13) + +The mini-probe (`scripts/datadog_dora_probe.py --paginate-test`) tested +six hypotheses against both endpoints with `limit: 2` in a 90-day window +known to contain ≥ 3 events on each side. Result: + +| Hypothesis | Deployments | Failures | +|---|---|---| +| `attributes.cursor = ` | replay (overlap 2/2, new=0) | replay | +| `attributes.next_token = ` | replay | replay | +| `attributes.page.after = ` | replay | replay | +| `attributes.page.cursor = ` | replay | replay | +| `attributes.page.offset = N` + `attributes.page.limit` | replay | replay | +| **`to = `** (time-slice) | **advanced**, overlap 1/2, new=1 | **advanced**, overlap 1/2, new=1 | + +**The DORA v2 list endpoints have no cursor mechanism.** The API +silently ignores unknown body params and returns the same first page +verbatim. Time-slicing is the only way to paginate. + +**Boundary is inclusive on `to`.** When the next request shrinks the +window to `to = last_event.started_at`, the boundary event itself is +returned again. The `unique (provider, provider_event_id)` constraint +on `external_deployments` / `external_incidents` makes the duplicate +upsert a no-op — no code-side dedup needed. + +**Concrete sync algorithm for slice 3:** + +``` +to_ts = now (or org's last_sync_at - lookback overlap) +from_ts = max(last_sync_at, now - default_backfill_window) +loop: + events = POST .../{endpoint} { from: from_ts, to: to_ts, query, limit: MAX } + if len(events) == 0: break + upsert events (idempotent via provider_event_id) + if len(events) < MAX: break # got everything in the window + to_ts = min(e.attributes.started_at for e in events) + # boundary event will reappear next iteration; upsert is a no-op +``` + +**Edge case to guard against:** if `len(events) == MAX` AND every +event in the page shares the same `started_at` (sub-second +co-occurrence), `to_ts` doesn't actually shrink and the loop spins. +Probability is low on real workloads (we're already at ~5 deploys/week +on a real tenant), but slice 3 should add a defensive "if to_ts +unchanged after upsert, decrement by 1 ms" guard. + +**`limit` ceiling.** Need to confirm with one more probe. Datadog +docs typically allow up to 1000 per page; safer to pick 100 for v1 to +keep responses fast and avoid hitting per-request size limits. + +### 9.6 Adjustments to §4 PR breakdown + +No slice splits or reorders, but content changes: + +- **PR 3 (ingestion).** Add `external_deployment_commits` to the + migration set. Persist the four extra columns introduced in §9.2 + (`recovery_time_sec`, `remediation_type`, `remediation_id`, + `number_of_pull_requests`) — they all live on the deployment event, + so no extra round-trips. Sync loop normalizes DD slug against + `repositories.remote_url` per §9.4. Pagination uses time-slicing + (§9.5, decision closed). Treat `change_failure` as nullable. +- **PR 4 (engine).** Two MTTR paths computed in parallel: + per-deploy (`recovery_time_sec`) and per-incident + (`time_to_restore_seconds`). CFR is `change_failure = true` over + non-null deploys. Add a derived `rollback_rate` metric from + `remediation_type = 'rollback'`. Surface a "pending" bucket for + null `change_failure` so users see what Datadog hasn't evaluated + yet. Estimated LOC ≈ ~450 (up from 350 in §4) to cover the + extra metric and the tri-state aggregation. +- **PR 5 (dashboard).** "CFR by code origin" correlation joins + `external_deployment_commits` ↔ `commit_origin`, filtered by + `external_deployments.change_failure = true`. Threshold of 10 + failed deploys in the window before showing the card (revised down + from "10 incidents" since CFR is now per-deploy). Add a second + free correlation: **rollback rate by code origin** — same join + filtered on `remediation_type = 'rollback'`. Strong AI-impact + signal if there's a delta. + +### 9.7 Resolved open questions from §7 + +- **#1 backfill window:** stays at 30 days. Validated against the + 2-year retention envelope. +- **#3 dev keys:** resolved — we have working keys; PR 3 can be built + and tested end-to-end. +- **#4 correlation:** confirmed "CFR by code origin" via the + deployment-commits join. Drop the variant that went through the + failures table. + +Still open: §7 #2 (cron schedule), §7 #5 (Stage 3 opening confirmation). + +### 9.8 New open question + +- **Customer's failure-emission discipline.** All 5 sampled failures + have `source: "api"` and look like post-mortem registrations from + RIO ticket lifecycle. If the customer stops registering them + (process drift, person leaving, etc.), MTTR data dries up silently. + The dashboard should surface "X days since last incident registered" + on the integration detail page, so silent decay is visible. Add to + PR 5 scope. + +### 9.9 Follow-up probe (2026-05-13) — risk closures + +Four additional probes ran the same day to close the highest-impact +unknowns that the first probe left open. Result: + +| Risk | Closure | Evidence | +|---|---|---| +| Does any deploy actually carry `change_failure: true`? | **Closed.** | 36 events with `change_failure=true` in the 90-day window. CFR-via-deploy is real, not theoretical. | +| Does `attributes.commits[]` get truncated on large deploys? | **Closed.** | 500 deploys inspected with `number_of_commits` up to 85; zero mismatches between `number_of_commits` and `len(commits[])`. No truncation. | +| What's the `limit` ceiling? | **Closed at ≥ 500.** | `limit: 500` returned 500 events without error. Slice 3 should still cap at 100 for response latency, but the ceiling isn't a constraint. | +| Are all deploys `source: "apm_deployments"` or does the shape vary? | **Closed.** | 500/500 sampled events have `source = "apm_deployments"`. A targeted query for `NOT source:apm_deployments` over 90 days returned zero events. | + +The follow-up probe also produced two schema additions (already +incorporated in §9.2): `recovery_time_sec`, `remediation` (with +`type`/`id`), and `number_of_pull_requests` — plus the tri-state +note on `change_failure`. These weren't in the original schema +guess, but they enable a per-deploy MTTR computation and a new +rollback-rate metric (§9.3, §9.6). + +**Confidence to proceed: high.** Open risks (failure-push +discipline, production rate limits) are operational and don't +require more API exploration. diff --git a/scripts/datadog_dora_probe.py b/scripts/datadog_dora_probe.py new file mode 100755 index 0000000..477fe94 --- /dev/null +++ b/scripts/datadog_dora_probe.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +"""Probe Datadog DORA Metrics API v2 read endpoints. + +Exploration script for issue #15. We need real responses to confirm the +hypotheses in docs/PLAN-datadog.md §8 before slice 3 (ingestion) ships: + + - response envelope shape on POST /api/v2/dora/deployments + - response envelope shape on POST /api/v2/dora/failures + - pagination cursor field name (next_token? cursor? meta.page.after?) + - default `query` behavior (`*` vs `env:production`) + - which fields actually appear on real events (repository_url, commit_sha, ...) + +Stdlib only — no new dependency for a throwaway probe. + +Usage: + DD_API_KEY=... DD_APP_KEY=... DD_SITE=datadoghq.com \\ + python scripts/datadog_dora_probe.py --dump out/dora-probe + +Exit codes: + 0 — both endpoints returned 2xx + 1 — at least one endpoint returned >= 400 + 2 — missing credentials in env +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request +from datetime import datetime, timedelta, timezone +from pathlib import Path + +DEFAULT_SITE = "datadoghq.com" +DEFAULT_DAYS = 7 +DEFAULT_LIMIT = 50 + +# Datadog's UI labels sites as US1/US3/EU/etc. but only US3, US5, AP1 carry +# a region prefix in the host. US1 and EU are bare. Normalize the common +# mislabel so users can paste the UI value directly. +SITE_ALIASES = { + "us1.datadoghq.com": "datadoghq.com", + "eu1.datadoghq.eu": "datadoghq.eu", + "eu.datadoghq.com": "datadoghq.eu", +} + + +def env_or_die(key: str) -> str: + val = os.environ.get(key) + if not val: + print(f"error: {key} not set in environment", file=sys.stderr) + sys.exit(2) + return val + + +def call( + site: str, path: str, body: dict, api_key: str, app_key: str +) -> tuple[int, dict | None, str]: + url = f"https://api.{site}{path}" + req = urllib.request.Request( + url, + data=json.dumps(body).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "DD-API-KEY": api_key, + "DD-APPLICATION-KEY": app_key, + "User-Agent": "iris-dora-probe/0.1", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + raw = resp.read().decode("utf-8") + status = resp.status + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + status = exc.code + except urllib.error.URLError as exc: + print(f"error: network failure calling {url}: {exc}", file=sys.stderr) + return 0, None, "" + + try: + return status, json.loads(raw), raw + except json.JSONDecodeError: + return status, None, raw + + +def build_body(from_iso: str, to_iso: str, query: str, req_type: str, limit: int) -> dict: + # Datadog DORA v2 rejects numeric `from`/`to`; it wants ISO 8601 strings. + return { + "data": { + "type": req_type, + "attributes": { + "from": from_iso, + "to": to_iso, + "query": query, + "limit": limit, + }, + } + } + + +def paginate_test( + site: str, + api_key: str, + app_key: str, + name: str, + path: str, + req_type: str, + from_iso: str, + to_iso: str, + query: str, + page_size: int, + dump_dir: Path | None, +) -> bool: + """Fetch page 1 then try N pagination hypotheses. Print what advances. + + "Advances" means: returned a non-empty page whose first event id differs + from page 1's first event id (the cheap detector for "the API ignored + our cursor and replayed page 1"). For time-slicing we additionally + check that the first event's `started_at` is older than page 1's last. + """ + print(f"\n=== pagination probe: {name} (limit={page_size}) ===") + base_attrs = {"from": from_iso, "to": to_iso, "query": query, "limit": page_size} + body1 = {"data": {"type": req_type, "attributes": base_attrs}} + status, parsed, raw = call(site, path, body1, api_key, app_key) + if status != 200 or not parsed: + print(f" page 1 failed: HTTP {status}") + return False + events1 = parsed.get("data") or [] + if len(events1) < page_size: + print( + f" only {len(events1)} events in window — increase --days to " + f"force overflow (need ≥ {page_size + 1})" + ) + return False + + page1_first_id = events1[0].get("id") + page1_last_event = events1[-1] + page1_last_id = page1_last_event.get("id") + page1_last_started = (page1_last_event.get("attributes") or {}).get("started_at") + page1_ids = {e.get("id") for e in events1} + + print( + f" page 1: {len(events1)} events first_id={page1_first_id} " + f"last_id={page1_last_id} last_started_at={page1_last_started}" + ) + if dump_dir is not None: + (dump_dir / f"{name}_page1.json").write_text(raw) + + candidates: list[tuple[str, dict]] = [ + ("attributes.cursor", {"cursor": page1_last_id}), + ("attributes.next_token", {"next_token": page1_last_id}), + ("attributes.page.after", {"page": {"after": page1_last_id}}), + ("attributes.page.cursor", {"page": {"cursor": page1_last_id}}), + ("attributes.page.offset", {"page": {"offset": page_size, "limit": page_size}}), + ("time-slice (to=last_started_at)", {"to": page1_last_started}), + ] + + print(" --- candidates ---") + advanced: list[str] = [] + for label, extra in candidates: + attrs = {**base_attrs, **extra} + body = {"data": {"type": req_type, "attributes": attrs}} + status, parsed, raw = call(site, path, body, api_key, app_key) + if status == 0: + print(f" ✗ {label}: network error") + continue + if status >= 400: + errors = (parsed or {}).get("errors") or [{}] + detail = errors[0].get("detail", f"HTTP {status}") + print(f" ✗ {label}: HTTP {status} {detail}") + continue + events = (parsed or {}).get("data") or [] + if not events: + print(f" ⚠ {label}: HTTP {status}, 0 events") + continue + new_first_id = events[0].get("id") + new_first_started = (events[0].get("attributes") or {}).get("started_at") + new_ids = {e.get("id") for e in events} + overlap = len(new_ids & page1_ids) + new_count = len(new_ids - page1_ids) + replayed = new_ids == page1_ids + did_advance = new_count > 0 + marker = "✓" if did_advance else ("=" if replayed else "?") + print( + f" {marker} {label}: HTTP {status}, {len(events)} events, " + f"first_id={new_first_id}, first_started_at={new_first_started}, " + f"overlap={overlap}/{len(events)} new={new_count}" + ) + if did_advance: + advanced.append(label) + if dump_dir is not None: + safe = ( + label.replace(" ", "_").replace("=", "").replace("(", "") + .replace(")", "").replace(".", "_") + ) + (dump_dir / f"{name}_{safe}.json").write_text(raw) + + if advanced: + print(f"\n ✓ candidates that advanced: {advanced}") + else: + print("\n ✗ no candidate advanced — must fall back to time-slicing in slice 3") + return bool(advanced) + + +def summarize(name: str, status: int, parsed: dict | None, raw: str) -> None: + print(f"\n=== {name} → HTTP {status} ===") + if parsed is None: + print("(non-JSON body, first 500 chars)") + print(raw[:500]) + return + + if status >= 400: + errors = parsed.get("errors") + if errors: + print(f"errors: {json.dumps(errors, indent=2)}") + else: + print(json.dumps(parsed, indent=2)[:500]) + return + + data = parsed.get("data") + if isinstance(data, list): + print(f"events returned: {len(data)}") + if data and isinstance(data[0], dict): + first = data[0] + attrs = first.get("attributes") or {} + print(f"first event id: {first.get('id')}") + print(f"first event attribute keys: {sorted(attrs.keys())}") + else: + print(f"unexpected `data` shape: {type(data).__name__}") + + meta = parsed.get("meta") + if meta: + print(f"meta keys: {sorted(meta.keys())}") + for k in ("page", "pagination", "next_token", "cursor", "next_cursor", "after"): + if k in meta: + print(f" pagination hint → meta.{k} = {meta[k]}") + links = parsed.get("links") + if links: + print(f"links: {json.dumps(links, indent=2)}") + + +def main() -> int: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument( + "--days", type=int, default=DEFAULT_DAYS, + help=f"how many days back to query (default: {DEFAULT_DAYS})", + ) + parser.add_argument( + "--query", default="*", + help="DORA filter query (default: '*' — all events)", + ) + parser.add_argument( + "--limit", type=int, default=DEFAULT_LIMIT, + help=f"page size (default: {DEFAULT_LIMIT})", + ) + parser.add_argument( + "--endpoint", choices=["deployments", "failures", "both"], default="both", + ) + parser.add_argument( + "--dump", metavar="DIR", + help="if set, save full raw JSON responses to this directory", + ) + parser.add_argument( + "--paginate-test", action="store_true", + help="fetch page 1, then try several pagination hypotheses to see which one advances", + ) + args = parser.parse_args() + + api_key = env_or_die("DD_API_KEY") + app_key = env_or_die("DD_APP_KEY") + raw_site = os.environ.get("DD_SITE", DEFAULT_SITE) + site = SITE_ALIASES.get(raw_site, raw_site) + if site != raw_site: + print(f"note: DD_SITE={raw_site!r} normalized to {site!r}") + + now = datetime.now(timezone.utc).replace(microsecond=0) + from_iso = (now - timedelta(days=args.days)).isoformat().replace("+00:00", "Z") + to_iso = now.isoformat().replace("+00:00", "Z") + + print(f"site: {site}") + print(f"window: {from_iso} → {to_iso}") + print(f"query: {args.query!r} limit: {args.limit}") + + targets: list[tuple[str, str, str]] = [] + if args.endpoint in ("deployments", "both"): + targets.append( + ("deployments", "/api/v2/dora/deployments", "dora_deployments_list_request") + ) + if args.endpoint in ("failures", "both"): + targets.append( + ("failures", "/api/v2/dora/failures", "dora_failures_list_request") + ) + + dump_dir: Path | None = None + if args.dump: + dump_dir = Path(args.dump) + dump_dir.mkdir(parents=True, exist_ok=True) + + overall_ok = True + for name, path, req_type in targets: + if args.paginate_test: + ok = paginate_test( + site, api_key, app_key, name, path, req_type, + from_iso, to_iso, args.query, args.limit, dump_dir, + ) + if not ok: + overall_ok = False + continue + body = build_body(from_iso, to_iso, args.query, req_type, args.limit) + status, parsed, raw = call(site, path, body, api_key, app_key) + if status == 0: + overall_ok = False + continue # network failure already reported by call() + summarize(name, status, parsed, raw) + if dump_dir is not None and raw: + out = dump_dir / f"{name}.json" + out.write_text(raw) + print(f" raw saved → {out}") + if status >= 400: + overall_ok = False + + return 0 if overall_ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) From cad618270670c6fcba348796f90d3f718c51d05b Mon Sep 17 00:00:00 2001 From: Fabio Wakim Trentini Date: Wed, 13 May 2026 14:25:40 -0300 Subject: [PATCH 2/6] =?UTF-8?q?feat(integrations):=20slice=202=20=E2=80=94?= =?UTF-8?q?=20DB=20+=20encryption=20+=20connect=20flow=20for=20Datadog=20(?= =?UTF-8?q?#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second slice of the Datadog integration per docs/PLAN-datadog.md §4 PR 2. Wires the "Coming soon" stub from slice 1 into a working connect flow against Datadog's DORA Metrics API v2. What lands: - Migration 014_org_integrations.sql with the multi-provider table, pgcrypto extension, and two SECURITY INVOKER RPCs (encrypt_credentials / decrypt_credentials) revoked from anon and authenticated roles so only the service-role path can touch them. - platform/lib/encryption.ts wraps the RPCs in typed helpers and masks secrets for display. - platform/lib/integrations/datadog/client.ts validates credentials with a 1-hour, limit=1 ping to POST /api/v2/dora/deployments. Handles the six supported Datadog sites and normalizes the common us1.datadoghq.com mislabel to the actual host. - /api/organizations/[organizationId]/integrations/[provider] route with GET (status + masked credential), POST (validate → encrypt → upsert), DELETE (mark disconnected, NULL out credentials_encrypted, preserve historical row per the issue's AC). - Datadog detail page now renders a real form (or the connected view) instead of the "Coming soon" card. The catalog page reads status from org_integrations. - INTEGRATIONS_ENCRYPTION_KEY documented in env.example with the openssl-based generation recipe. - Translations under settings.integrations.datadog.* in en-US and pt-BR. Slice 2 does not call Datadog except at validation time. Slice 3 will add the cron-driven sync that populates external_deployments and external_incidents. Co-Authored-By: Claude Opus 4.7 (1M context) --- platform/env.example | 8 + platform/lib/encryption.ts | 88 +++++ platform/lib/integrations/datadog/client.ts | 136 ++++++++ platform/lib/translations.ts | 66 ++++ .../settings/integrations/[provider]/page.tsx | 66 +++- .../[tenant]/settings/integrations/page.tsx | 110 ++++--- .../integrations/[provider]/route.ts | 283 ++++++++++++++++ .../integrations/datadog-connect-form.tsx | 309 ++++++++++++++++++ .../migrations/014_org_integrations.sql | 59 ++++ 9 files changed, 1068 insertions(+), 57 deletions(-) create mode 100644 platform/lib/encryption.ts create mode 100644 platform/lib/integrations/datadog/client.ts create mode 100644 platform/src/app/api/organizations/[organizationId]/integrations/[provider]/route.ts create mode 100644 platform/src/components/integrations/datadog-connect-form.tsx create mode 100644 platform/supabase/migrations/014_org_integrations.sql diff --git a/platform/env.example b/platform/env.example index 228c19b..af5877e 100644 --- a/platform/env.example +++ b/platform/env.example @@ -40,6 +40,14 @@ NEXT_PUBLIC_DEFAULT_LOGO_URL=/logo.svg # Off by default. Turn on if your deployment needs manual login alongside OAuth. # FEATURES__PASSWORD_AUTH=false +# --- Integrations (required to connect external systems like Datadog) --- +# Master key used to symmetrically encrypt provider credentials at rest in +# the org_integrations table (pgcrypto pgp_sym_encrypt). Generate with: +# openssl rand -base64 32 +# Rotating this key invalidates every stored credential — re-encrypt with +# a one-shot script before changing it in production. +INTEGRATIONS_ENCRYPTION_KEY= + # --- Resend (required for transactional emails) --- # Get an API key at https://resend.com/api-keys RESEND_API_KEY=re_... diff --git a/platform/lib/encryption.ts b/platform/lib/encryption.ts new file mode 100644 index 0000000..214a357 --- /dev/null +++ b/platform/lib/encryption.ts @@ -0,0 +1,88 @@ +/** + * Credential encryption helpers for org_integrations. + * + * Uses Postgres pgcrypto pgp_sym_encrypt/pgp_sym_decrypt with a master key + * stored in INTEGRATIONS_ENCRYPTION_KEY. The key is read here, but every + * encrypt/decrypt call goes through Supabase so the bytes-on-wire never + * contain plaintext keys outside the request path. + * + * Generate the master key with: `openssl rand -base64 32`. + * + * The functions return base64 strings for transport convenience; the + * column itself stores BYTEA so callers writing back to the table should + * convert via the bytea hex form or pass through pgp_sym_encrypt directly. + */ + +import { supabaseAdmin } from "@/lib/supabase"; + +function getMasterKey(): string { + const key = process.env.INTEGRATIONS_ENCRYPTION_KEY; + if (!key) { + throw new Error( + "INTEGRATIONS_ENCRYPTION_KEY is not configured. Set it in the environment to enable integrations.", + ); + } + return key; +} + +/** + * Encrypt a JSON-serializable payload and return a base64 string of the + * pgp_sym_encrypt output. The result is a BYTEA when stored back in + * Postgres — use `decode(, 'base64')` in SQL or pass the base64 + * string through a parameterized BYTEA column. + */ +export async function encryptCredentials(payload: object): Promise { + const key = getMasterKey(); + const plaintext = JSON.stringify(payload); + + const { data, error } = await supabaseAdmin.rpc("encrypt_credentials", { + plaintext, + master_key: key, + }); + + if (error || typeof data !== "string") { + throw new Error( + `Failed to encrypt credentials: ${error?.message ?? "unknown error"}`, + ); + } + + return data; +} + +/** + * Decrypt a base64 string produced by `encryptCredentials` back to its + * original object payload. Throws if the master key has changed or the + * payload is corrupted. + */ +export async function decryptCredentials>( + encrypted: string, +): Promise { + const key = getMasterKey(); + + const { data, error } = await supabaseAdmin.rpc("decrypt_credentials", { + encrypted, + master_key: key, + }); + + if (error || typeof data !== "string") { + throw new Error( + `Failed to decrypt credentials: ${error?.message ?? "unknown error"}`, + ); + } + + try { + return JSON.parse(data) as T; + } catch { + throw new Error("Decrypted credentials payload is not valid JSON"); + } +} + +/** + * Mask a secret for display in the UI. Keeps the first 4 and last 4 chars + * so the user can tell different keys apart without leaking the body. + */ +export function maskSecret(secret: string): string { + if (!secret) return ""; + if (secret.length <= 10) return "****"; + return `${secret.slice(0, 4)}…${secret.slice(-4)}`; +} diff --git a/platform/lib/integrations/datadog/client.ts b/platform/lib/integrations/datadog/client.ts new file mode 100644 index 0000000..bae6f44 --- /dev/null +++ b/platform/lib/integrations/datadog/client.ts @@ -0,0 +1,136 @@ +/** + * Datadog API client for the integration's connect flow. + * + * Only the bits slice 2 needs land here: credential validation against + * the DORA Metrics v2 read endpoints. Slice 3 will extend this module + * with the actual sync calls (deployments / failures pagination). + * + * See docs/PLAN-datadog.md §9 for the request shape and probe findings. + */ + +export type DatadogSite = + | "datadoghq.com" + | "us3.datadoghq.com" + | "us5.datadoghq.com" + | "datadoghq.eu" + | "ap1.datadoghq.com" + | "ddog-gov.com"; + +export const SUPPORTED_SITES: DatadogSite[] = [ + "datadoghq.com", + "us3.datadoghq.com", + "us5.datadoghq.com", + "datadoghq.eu", + "ap1.datadoghq.com", + "ddog-gov.com", +]; + +// Datadog's UI labels regions as US1/US3/EU/etc., but US1 and EU don't +// carry a region prefix in the API hostname. Accept the UI value and +// normalize to the actual site identifier. +const SITE_ALIASES: Record = { + "us1.datadoghq.com": "datadoghq.com", + "eu1.datadoghq.eu": "datadoghq.eu", + "eu.datadoghq.com": "datadoghq.eu", +}; + +export function normalizeSite(raw: string): DatadogSite { + const trimmed = raw.trim().toLowerCase(); + if (trimmed in SITE_ALIASES) return SITE_ALIASES[trimmed]; + if (SUPPORTED_SITES.includes(trimmed as DatadogSite)) { + return trimmed as DatadogSite; + } + throw new Error(`Unsupported Datadog site: ${raw}`); +} + +export interface DatadogCredentials { + apiKey: string; + appKey: string; + site: DatadogSite; +} + +export interface ValidationResult { + ok: boolean; + /** HTTP status from Datadog; 0 if the request never left the function. */ + status: number; + /** When ok=false, the human-readable detail from Datadog's `errors[]`. */ + errorDetail?: string; +} + +/** + * Ping Datadog with a cheap DORA deployments list (1-hour window, limit 1) + * to confirm the credentials authenticate and carry `dora_metrics_read`. + * + * Returns ok=true on HTTP 200; otherwise surfaces Datadog's error detail + * verbatim so the UI can show it without translation. + */ +export async function validateCredentials( + creds: DatadogCredentials, +): Promise { + const now = new Date(); + const to = stripMillis(now); + const from = stripMillis(new Date(now.getTime() - 60 * 60 * 1000)); + + const url = `https://api.${creds.site}/api/v2/dora/deployments`; + const body = { + data: { + type: "dora_deployments_list_request", + attributes: { + from, + to, + query: "*", + limit: 1, + }, + }, + }; + + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "DD-API-KEY": creds.apiKey, + "DD-APPLICATION-KEY": creds.appKey, + }, + body: JSON.stringify(body), + // Connect flow runs server-side; cancel if Datadog is slow. + signal: AbortSignal.timeout(15_000), + }); + } catch (err) { + return { + ok: false, + status: 0, + errorDetail: + err instanceof Error ? err.message : "Network error contacting Datadog", + }; + } + + if (response.ok) { + return { ok: true, status: response.status }; + } + + let errorDetail: string | undefined; + try { + const payload = (await response.json()) as { + errors?: Array<{ detail?: string; title?: string }>; + }; + const first = payload.errors?.[0]; + errorDetail = first?.detail ?? first?.title; + } catch { + errorDetail = undefined; + } + + return { + ok: false, + status: response.status, + errorDetail: errorDetail ?? `Datadog returned HTTP ${response.status}`, + }; +} + +function stripMillis(d: Date): string { + // ISO 8601 with seconds precision and a literal Z. Datadog rejects numeric + // epoch on these endpoints; it also rejects offsets like +00:00 in some + // sites. The Z form is the safest documented shape. + return d.toISOString().replace(/\.\d{3}Z$/, "Z"); +} diff --git a/platform/lib/translations.ts b/platform/lib/translations.ts index b7607f1..c686a45 100644 --- a/platform/lib/translations.ts +++ b/platform/lib/translations.ts @@ -745,6 +745,7 @@ export const translations = { notConnected: "Not connected", connected: "Connected", error: "Error", + disconnected: "Disconnected", }, providers: { datadog: { @@ -761,6 +762,38 @@ export const translations = { comingSoonDescription: "Connection flow, credentials, and sync configuration land in upcoming releases.", }, + datadog: { + connectTitle: "Connect Datadog", + connectDescription: + "Provide a Datadog API key and Application key with the dora_metrics_read scope. We'll validate them against your tenant before saving.", + connectButton: "Connect Datadog", + connectSuccess: "Datadog connected successfully.", + connectError: "Failed to connect Datadog.", + connectedTitle: "Datadog is connected", + connectedDescription: + "Iris uses these credentials to sync deploys and incidents on a daily cadence.", + disconnectButton: "Disconnect", + disconnectSuccess: + "Datadog disconnected. Historical data is preserved.", + disconnectError: "Failed to disconnect Datadog.", + fields: { + apiKey: "API Key", + appKey: "Application Key", + appKeyHint: + "The Application Key carries the user's permissions. Create one scoped to dora_metrics_read in Datadog's Application Keys settings.", + site: "Datadog Site", + lastSyncAt: "Last sync", + connectedAt: "Connected at", + neverSynced: "Never synced yet", + }, + disconnectDialog: { + title: "Disconnect Datadog?", + description: + "The credentials will be removed and the daily sync will stop. Deployments and incidents already ingested will remain in Iris. You can reconnect any time.", + cancel: "Cancel", + confirm: "Yes, disconnect", + }, + }, }, dangerZone: "Danger Zone", deleteOrganizationDescription: @@ -1972,6 +2005,7 @@ export const translations = { notConnected: "Não conectada", connected: "Conectada", error: "Erro", + disconnected: "Desconectada", }, providers: { datadog: { @@ -1988,6 +2022,38 @@ export const translations = { comingSoonDescription: "Fluxo de conexão, credenciais e configuração de sincronização chegam nas próximas releases.", }, + datadog: { + connectTitle: "Conectar Datadog", + connectDescription: + "Forneça uma API Key e uma Application Key do Datadog com o escopo dora_metrics_read. Validamos junto ao seu tenant antes de salvar.", + connectButton: "Conectar Datadog", + connectSuccess: "Datadog conectado com sucesso.", + connectError: "Falha ao conectar o Datadog.", + connectedTitle: "Datadog está conectado", + connectedDescription: + "O Iris usa essas credenciais para sincronizar deploys e incidentes diariamente.", + disconnectButton: "Desconectar", + disconnectSuccess: + "Datadog desconectado. Os dados históricos foram preservados.", + disconnectError: "Falha ao desconectar o Datadog.", + fields: { + apiKey: "API Key", + appKey: "Application Key", + appKeyHint: + "A Application Key carrega as permissões do usuário. Crie uma com escopo dora_metrics_read em Application Keys nas configurações do Datadog.", + site: "Site do Datadog", + lastSyncAt: "Última sincronização", + connectedAt: "Conectada em", + neverSynced: "Ainda não sincronizada", + }, + disconnectDialog: { + title: "Desconectar o Datadog?", + description: + "As credenciais serão removidas e a sincronização diária será interrompida. Os deploys e incidentes já ingeridos permanecem no Iris. Você pode reconectar a qualquer momento.", + cancel: "Cancelar", + confirm: "Sim, desconectar", + }, + }, }, dangerZone: "Zona de Perigo", deleteOrganizationDescription: diff --git a/platform/src/app/[tenant]/settings/integrations/[provider]/page.tsx b/platform/src/app/[tenant]/settings/integrations/[provider]/page.tsx index 7540a14..5b80ff1 100644 --- a/platform/src/app/[tenant]/settings/integrations/[provider]/page.tsx +++ b/platform/src/app/[tenant]/settings/integrations/[provider]/page.tsx @@ -4,6 +4,10 @@ import { notFound, redirect } from "next/navigation"; import { ArrowLeft } from "lucide-react"; import { getServerSession } from "next-auth/next"; +import { + DatadogConnectForm, + type DatadogIntegrationStatus, +} from "@/components/integrations/datadog-connect-form"; import { Card, CardContent, @@ -51,6 +55,34 @@ export default async function IntegrationProviderPage({ redirect(`/${tenant}/dashboard`); } + let initial: DatadogIntegrationStatus = { status: "not_connected" }; + if (provider === "datadog") { + const { data } = await supabaseAdmin + .from("org_integrations") + .select( + "status, config, last_sync_at, last_error, created_at, updated_at", + ) + .eq("organization_id", org.id) + .eq("provider", "datadog") + .maybeSingle(); + + if (data) { + const config = (data.config ?? {}) as { + site?: string; + apiKeyMask?: string; + }; + initial = { + status: data.status as "active" | "error" | "disconnected", + site: config.site ?? null, + apiKeyMask: config.apiKeyMask ?? null, + lastSyncAt: data.last_sync_at, + lastError: data.last_error, + createdAt: data.created_at, + updatedAt: data.updated_at, + }; + } + } + return (
- - - - {t("settings.integrations.detail.comingSoonTitle")} - - - {t("settings.integrations.detail.comingSoonDescription")} - - - -

- {t(`settings.integrations.providers.${provider}.detail`)} -

-
-
+ {provider === "datadog" ? ( + + ) : ( + + + + {t("settings.integrations.detail.comingSoonTitle")} + + + {t("settings.integrations.detail.comingSoonDescription")} + + + +

+ {t(`settings.integrations.providers.${provider}.detail`)} +

+
+
+ )} ); } diff --git a/platform/src/app/[tenant]/settings/integrations/page.tsx b/platform/src/app/[tenant]/settings/integrations/page.tsx index a7fb462..bb95e39 100644 --- a/platform/src/app/[tenant]/settings/integrations/page.tsx +++ b/platform/src/app/[tenant]/settings/integrations/page.tsx @@ -21,16 +21,27 @@ interface IntegrationsPageProps { } /** - * Provider catalog. Slice 1 ships only Datadog as "Coming soon"; later - * slices wire it to a real status from `org_integrations`. New providers - * get added here without touching the page layout. + * Provider catalog. Slice 2 wired the Datadog status to org_integrations; + * new providers get added here without touching the page layout. */ -const PROVIDERS = [ - { - id: "datadog" as const, - statusKey: "notConnected" as const, - }, -]; +const PROVIDERS = [{ id: "datadog" as const }]; + +type ProviderStatus = "active" | "error" | "disconnected" | "not_connected"; + +function statusKey( + status: ProviderStatus, +): "notConnected" | "connected" | "error" | "disconnected" { + switch (status) { + case "active": + return "connected"; + case "error": + return "error"; + case "disconnected": + return "disconnected"; + default: + return "notConnected"; + } +} export default async function IntegrationsPage({ params, @@ -60,6 +71,16 @@ export default async function IntegrationsPage({ redirect(`/${tenant}/dashboard`); } + const { data: integrations } = await supabaseAdmin + .from("org_integrations") + .select("provider, status") + .eq("organization_id", org.id); + + const statusByProvider = new Map(); + for (const row of integrations ?? []) { + statusByProvider.set(row.provider, row.status as ProviderStatus); + } + return (
@@ -72,39 +93,44 @@ export default async function IntegrationsPage({
- {PROVIDERS.map((provider) => ( - - - -
- - {t(`settings.integrations.providers.${provider.id}.name`)} - - - {t( - `settings.integrations.providers.${provider.id}.description`, - )} - -
-
- - {t(`settings.integrations.status.${provider.statusKey}`)} - - -
-
- -

- {t(`settings.integrations.providers.${provider.id}.detail`)} -

-
-
- - ))} + {PROVIDERS.map((provider) => { + const sKey = statusKey( + statusByProvider.get(provider.id) ?? "not_connected", + ); + return ( + + + +
+ + {t(`settings.integrations.providers.${provider.id}.name`)} + + + {t( + `settings.integrations.providers.${provider.id}.description`, + )} + +
+
+ + {t(`settings.integrations.status.${sKey}`)} + + +
+
+ +

+ {t(`settings.integrations.providers.${provider.id}.detail`)} +

+
+
+ + ); + })}

diff --git a/platform/src/app/api/organizations/[organizationId]/integrations/[provider]/route.ts b/platform/src/app/api/organizations/[organizationId]/integrations/[provider]/route.ts new file mode 100644 index 0000000..ee92e95 --- /dev/null +++ b/platform/src/app/api/organizations/[organizationId]/integrations/[provider]/route.ts @@ -0,0 +1,283 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { getServerSession } from "next-auth/next"; + +import { authOptions } from "@/lib/auth"; +import { logger } from "@/lib/debug"; +import { encryptCredentials, maskSecret } from "@/lib/encryption"; +import { + SUPPORTED_SITES, + normalizeSite, + validateCredentials, + type DatadogSite, +} from "@/lib/integrations/datadog/client"; +import { canManageMembers } from "@/lib/permissions"; +import { supabaseAdmin } from "@/lib/supabase"; + +const SUPPORTED_PROVIDERS = new Set(["datadog"]); + +interface RouteContext { + params: Promise<{ organizationId: string; provider: string }>; +} + +async function authorize( + request: NextRequest, + organizationId: string, +): Promise<{ userId: string } | NextResponse> { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + const { data: membership, error } = await supabaseAdmin + .from("organization_members") + .select("role") + .eq("user_id", session.user.id) + .eq("organization_id", organizationId) + .single(); + + if (error || !membership) { + return NextResponse.json( + { message: "You are not a member of this organization" }, + { status: 403 }, + ); + } + + if (!canManageMembers(membership.role)) { + return NextResponse.json( + { + message: + "You do not have permission to manage integrations for this organization", + }, + { status: 403 }, + ); + } + + return { userId: session.user.id }; +} + +export async function GET(request: NextRequest, { params }: RouteContext) { + try { + const { organizationId, provider } = await params; + if (!SUPPORTED_PROVIDERS.has(provider)) { + return NextResponse.json( + { message: "Unknown provider" }, + { status: 404 }, + ); + } + + const auth = await authorize(request, organizationId); + if (auth instanceof NextResponse) return auth; + + const { data, error } = await supabaseAdmin + .from("org_integrations") + .select( + "id, status, config, last_sync_at, last_error, created_at, updated_at", + ) + .eq("organization_id", organizationId) + .eq("provider", provider) + .maybeSingle(); + + if (error) { + logger.error("integration GET failed", { error: error.message }); + return NextResponse.json( + { message: "Failed to load integration status" }, + { status: 500 }, + ); + } + + if (!data) { + return NextResponse.json({ status: "not_connected" }); + } + + const config = (data.config ?? {}) as { + site?: string; + apiKeyMask?: string; + }; + return NextResponse.json({ + status: data.status, + site: config.site ?? null, + apiKeyMask: config.apiKeyMask ?? null, + lastSyncAt: data.last_sync_at, + lastError: data.last_error, + createdAt: data.created_at, + updatedAt: data.updated_at, + }); + } catch (err) { + logger.error("integration GET threw", { + error: err instanceof Error ? err.message : String(err), + }); + return NextResponse.json( + { message: "Internal server error" }, + { status: 500 }, + ); + } +} + +export async function POST(request: NextRequest, { params }: RouteContext) { + try { + const { organizationId, provider } = await params; + if (provider !== "datadog") { + return NextResponse.json( + { message: "Provider not supported yet" }, + { status: 404 }, + ); + } + + const auth = await authorize(request, organizationId); + if (auth instanceof NextResponse) return auth; + + const body = (await request.json()) as { + apiKey?: string; + appKey?: string; + site?: string; + }; + + if (!body.apiKey || !body.appKey || !body.site) { + return NextResponse.json( + { message: "apiKey, appKey, and site are required" }, + { status: 400 }, + ); + } + + let site: DatadogSite; + try { + site = normalizeSite(body.site); + } catch (err) { + return NextResponse.json( + { + message: + err instanceof Error + ? err.message + : `Unsupported site. Use one of: ${SUPPORTED_SITES.join(", ")}`, + }, + { status: 400 }, + ); + } + + const validation = await validateCredentials({ + apiKey: body.apiKey.trim(), + appKey: body.appKey.trim(), + site, + }); + + if (!validation.ok) { + return NextResponse.json( + { + message: + validation.errorDetail ?? + "Datadog rejected the credentials. Verify the API key, application key, and site.", + datadogStatus: validation.status, + }, + { status: 400 }, + ); + } + + let encrypted: string; + try { + encrypted = await encryptCredentials({ + apiKey: body.apiKey.trim(), + appKey: body.appKey.trim(), + site, + }); + } catch (err) { + logger.error("encrypt failed", { + error: err instanceof Error ? err.message : String(err), + }); + return NextResponse.json( + { + message: + "Server is missing INTEGRATIONS_ENCRYPTION_KEY. Contact your administrator.", + }, + { status: 500 }, + ); + } + + const config = { + site, + apiKeyMask: maskSecret(body.apiKey.trim()), + }; + + const { error: upsertError } = await supabaseAdmin + .from("org_integrations") + .upsert( + { + organization_id: organizationId, + provider: "datadog", + status: "active", + credentials_encrypted: encrypted, + config, + last_error: null, + }, + { onConflict: "organization_id,provider" }, + ); + + if (upsertError) { + logger.error("integration upsert failed", { error: upsertError.message }); + return NextResponse.json( + { message: "Failed to save integration" }, + { status: 500 }, + ); + } + + return NextResponse.json({ + status: "active", + site, + apiKeyMask: config.apiKeyMask, + }); + } catch (err) { + logger.error("integration POST threw", { + error: err instanceof Error ? err.message : String(err), + }); + return NextResponse.json( + { message: "Internal server error" }, + { status: 500 }, + ); + } +} + +export async function DELETE(request: NextRequest, { params }: RouteContext) { + try { + const { organizationId, provider } = await params; + if (!SUPPORTED_PROVIDERS.has(provider)) { + return NextResponse.json( + { message: "Unknown provider" }, + { status: 404 }, + ); + } + + const auth = await authorize(request, organizationId); + if (auth instanceof NextResponse) return auth; + + // Mark disconnected but preserve historical data (per #15 AC). + // Wipe the credential bytes so a future re-connect requires a fresh + // ping; keep config so the UI can show "previously connected". + const { error } = await supabaseAdmin + .from("org_integrations") + .update({ + status: "disconnected", + credentials_encrypted: null, + last_error: null, + }) + .eq("organization_id", organizationId) + .eq("provider", provider); + + if (error) { + logger.error("integration DELETE failed", { error: error.message }); + return NextResponse.json( + { message: "Failed to disconnect integration" }, + { status: 500 }, + ); + } + + return NextResponse.json({ status: "disconnected" }); + } catch (err) { + logger.error("integration DELETE threw", { + error: err instanceof Error ? err.message : String(err), + }); + return NextResponse.json( + { message: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/platform/src/components/integrations/datadog-connect-form.tsx b/platform/src/components/integrations/datadog-connect-form.tsx new file mode 100644 index 0000000..5109e5c --- /dev/null +++ b/platform/src/components/integrations/datadog-connect-form.tsx @@ -0,0 +1,309 @@ +"use client"; + +import { useState } from "react"; + +import { useRouter } from "next/navigation"; + +import { Loader2, ShieldCheck, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useTranslation } from "@/hooks/useTranslation"; + +const SITES: Array<{ value: string; label: string }> = [ + { value: "datadoghq.com", label: "US1 (datadoghq.com)" }, + { value: "us3.datadoghq.com", label: "US3 (us3.datadoghq.com)" }, + { value: "us5.datadoghq.com", label: "US5 (us5.datadoghq.com)" }, + { value: "datadoghq.eu", label: "EU (datadoghq.eu)" }, + { value: "ap1.datadoghq.com", label: "AP1 (ap1.datadoghq.com)" }, + { value: "ddog-gov.com", label: "US1-FED (ddog-gov.com)" }, +]; + +export type DatadogIntegrationStatus = + | { status: "not_connected" } + | { + status: "active" | "error" | "disconnected"; + site: string | null; + apiKeyMask: string | null; + lastSyncAt: string | null; + lastError: string | null; + createdAt: string; + updatedAt: string; + }; + +interface Props { + organizationId: string; + initial: DatadogIntegrationStatus; +} + +export function DatadogConnectForm({ organizationId, initial }: Props) { + const router = useRouter(); + const { t } = useTranslation(); + const [state, setState] = useState(initial); + const [apiKey, setApiKey] = useState(""); + const [appKey, setAppKey] = useState(""); + const [site, setSite] = useState("datadoghq.com"); + const [submitting, setSubmitting] = useState(false); + const [disconnecting, setDisconnecting] = useState(false); + + const isConnected = state.status === "active"; + + async function handleConnect(e: React.FormEvent) { + e.preventDefault(); + if (!apiKey || !appKey || !site) return; + + setSubmitting(true); + try { + const res = await fetch( + `/api/organizations/${organizationId}/integrations/datadog`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey, appKey, site }), + }, + ); + const body = await res.json(); + if (!res.ok) { + toast.error( + body.message ?? t("settings.integrations.datadog.connectError"), + ); + return; + } + toast.success(t("settings.integrations.datadog.connectSuccess")); + setApiKey(""); + setAppKey(""); + setState({ + status: "active", + site: body.site, + apiKeyMask: body.apiKeyMask, + lastSyncAt: null, + lastError: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + router.refresh(); + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : t("settings.integrations.datadog.connectError"), + ); + } finally { + setSubmitting(false); + } + } + + async function handleDisconnect() { + setDisconnecting(true); + try { + const res = await fetch( + `/api/organizations/${organizationId}/integrations/datadog`, + { method: "DELETE" }, + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + toast.error( + body.message ?? t("settings.integrations.datadog.disconnectError"), + ); + return; + } + toast.success(t("settings.integrations.datadog.disconnectSuccess")); + setState({ status: "not_connected" }); + router.refresh(); + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : t("settings.integrations.datadog.disconnectError"), + ); + } finally { + setDisconnecting(false); + } + } + + if (isConnected) { + return ( + + + + + {t("settings.integrations.datadog.connectedTitle")} + + + {t("settings.integrations.datadog.connectedDescription")} + + + +

+
+
+ {t("settings.integrations.datadog.fields.site")} +
+
{state.site}
+
+
+
+ {t("settings.integrations.datadog.fields.apiKey")} +
+
{state.apiKeyMask ?? "—"}
+
+
+
+ {t("settings.integrations.datadog.fields.lastSyncAt")} +
+
+ {state.lastSyncAt + ? new Date(state.lastSyncAt).toLocaleString() + : t("settings.integrations.datadog.fields.neverSynced")} +
+
+
+
+ {t("settings.integrations.datadog.fields.connectedAt")} +
+
{new Date(state.createdAt).toLocaleString()}
+
+
+ + {state.lastError && ( +

+ {state.lastError} +

+ )} + + + + + + + + + {t("settings.integrations.datadog.disconnectDialog.title")} + + + {t( + "settings.integrations.datadog.disconnectDialog.description", + )} + + + + + {t("settings.integrations.datadog.disconnectDialog.cancel")} + + + {disconnecting && ( + + )} + {t("settings.integrations.datadog.disconnectDialog.confirm")} + + + + + + + ); + } + + return ( + + + {t("settings.integrations.datadog.connectTitle")} + + {t("settings.integrations.datadog.connectDescription")} + + + +
+
+ + setApiKey(e.target.value)} + placeholder="dd-api-…" + required + /> +
+ +
+ + setAppKey(e.target.value)} + placeholder="dd-app-…" + required + /> +

+ {t("settings.integrations.datadog.fields.appKeyHint")} +

+
+ +
+ + +
+ + +
+
+
+ ); +} diff --git a/platform/supabase/migrations/014_org_integrations.sql b/platform/supabase/migrations/014_org_integrations.sql new file mode 100644 index 0000000..12fbd7f --- /dev/null +++ b/platform/supabase/migrations/014_org_integrations.sql @@ -0,0 +1,59 @@ +-- 014_org_integrations.sql +-- Slice 2 of the Datadog integration (#15). Adds the multi-provider +-- integration table. Credentials are stored encrypted via pgcrypto's +-- pgp_sym_encrypt using a server-side master key (INTEGRATIONS_ENCRYPTION_KEY). + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TYPE integration_provider AS ENUM ('datadog'); +CREATE TYPE integration_status AS ENUM ('active', 'error', 'disconnected'); + +CREATE TABLE org_integrations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + provider integration_provider NOT NULL, + status integration_status NOT NULL DEFAULT 'active', + -- NULL when status = 'disconnected'. Otherwise a pgp_sym_encrypt-produced + -- bytea of the JSON credential payload (e.g. {"api_key", "app_key", "site"}). + credentials_encrypted BYTEA, + config JSONB NOT NULL DEFAULT '{}'::jsonb, + last_sync_at TIMESTAMPTZ, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (organization_id, provider) +); + +CREATE INDEX idx_org_integrations_org ON org_integrations(organization_id); +CREATE INDEX idx_org_integrations_status_active ON org_integrations(status) + WHERE status = 'active'; + +CREATE TRIGGER update_org_integrations_updated_at BEFORE UPDATE ON org_integrations + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Helper RPCs so the application layer never sees raw bytea or the master +-- key on the wire. Both functions are SECURITY INVOKER (default) and take +-- the master key as a parameter — the key lives in app env, not in the DB. +-- The application is responsible for passing INTEGRATIONS_ENCRYPTION_KEY. + +CREATE OR REPLACE FUNCTION encrypt_credentials(plaintext TEXT, master_key TEXT) +RETURNS TEXT +LANGUAGE sql +VOLATILE +AS $$ + SELECT encode(pgp_sym_encrypt(plaintext, master_key), 'base64'); +$$; + +CREATE OR REPLACE FUNCTION decrypt_credentials(encrypted TEXT, master_key TEXT) +RETURNS TEXT +LANGUAGE sql +VOLATILE +AS $$ + SELECT pgp_sym_decrypt(decode(encrypted, 'base64'), master_key); +$$; + +-- Restrict the RPC surface to service-role only. Application code uses +-- supabaseAdmin (service role) for all integration writes; client-side +-- code should never reach these. +REVOKE ALL ON FUNCTION encrypt_credentials(TEXT, TEXT) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION decrypt_credentials(TEXT, TEXT) FROM PUBLIC, anon, authenticated; From 294ad5045f35d60f90dee0a04ea53f6a9c82ef38 Mon Sep 17 00:00:00 2001 From: Fabio Wakim Trentini Date: Wed, 13 May 2026 14:25:49 -0300 Subject: [PATCH 3/6] chore: add husky hooks for AI attribution and auto-push, gitignore tweaks - .husky/prepare-commit-msg detects AI agent env vars and appends a Co-Authored-By line before the commit is created. Safer than a post-commit + amend flow. - .husky/post-commit triggers the Iris auto-push (analyze + push to the platform) once per day in the background. - .gitignore adds .vercel/ for Vercel CLI local state. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + .husky/post-commit | 57 +++++++++++++++++++++++++++ .husky/prepare-commit-msg | 82 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100755 .husky/post-commit create mode 100755 .husky/prepare-commit-msg diff --git a/.gitignore b/.gitignore index 3a05aaa..d034904 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ platform/coverage/ # Supabase CLI per-machine cache (project refs, pooler URLs, version files) supabase/.temp/ +.vercel diff --git a/.husky/post-commit b/.husky/post-commit new file mode 100755 index 0000000..c982c62 --- /dev/null +++ b/.husky/post-commit @@ -0,0 +1,57 @@ +#!/bin/sh + +# >>> iris-push-start >>> +#!/bin/sh +# Iris auto-push: runs analysis and pushes to platform once per day. +# Runs in background to not block the commit. + +IRIS_DIR="$HOME/.iris" +STAMP_FILE="$IRIS_DIR/.last_push_$(basename "$(git rev-parse --show-toplevel)" 2>/dev/null | tr '/' '_')" +TODAY=$(date +%Y-%m-%d) + +# Check if already pushed today +if [ -f "$STAMP_FILE" ]; then + LAST_PUSH=$(cat "$STAMP_FILE" 2>/dev/null) + if [ "$LAST_PUSH" = "$TODAY" ]; then + exit 0 + fi +fi + +# Check if iris is available and authenticated +IRIS_BIN="" +for candidate in "$IRIS_DIR/bin/iris" "$IRIS_DIR/venv/bin/iris" "$(command -v iris 2>/dev/null)"; do + if [ -x "$candidate" ]; then + IRIS_BIN="$candidate" + break + fi +done + +if [ -z "$IRIS_BIN" ]; then + exit 0 +fi + +# Check auth config exists +if [ ! -f "$IRIS_DIR/config.json" ]; then + exit 0 +fi + +# Check token is configured +TOKEN=$(grep -o '"token"' "$IRIS_DIR/config.json" 2>/dev/null) +if [ -z "$TOKEN" ]; then + exit 0 +fi + +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +if [ -z "$REPO_ROOT" ]; then + exit 0 +fi + +# Run in background so we don't block the commit +( + mkdir -p "$IRIS_DIR" + "$IRIS_BIN" "$REPO_ROOT" --push --quiet 2>/dev/null && echo "$TODAY" > "$STAMP_FILE" +) & + +exit 0 + +# <<< iris-push-end <<< diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg new file mode 100755 index 0000000..58bae6d --- /dev/null +++ b/.husky/prepare-commit-msg @@ -0,0 +1,82 @@ +#!/bin/sh + +# >>> iris-hook-start >>> +#!/bin/sh +# Iris AI Attribution Hook (prepare-commit-msg) +# +# Detects AI agent environment variables and appends a Co-Authored-By +# tag to the commit message BEFORE the commit is created. +# +# This is safer than post-commit + amend because: +# - No history rewriting (commit is born with the correct message) +# - No hash changes after creation +# - No GPG signature invalidation +# - No double CI triggers +# - If this hook fails, the commit proceeds without the tag (exit 0) +# +# Installed via: iris hook install + +# Arguments from git: +# $1 = path to the commit message file +# $2 = source of the message (message, template, merge, squash, commit) +# $3 = commit hash (only for amend) +COMMIT_MSG_FILE="$1" +COMMIT_SOURCE="${2:-}" + +# Skip on merge, squash, and amend — these already have their messages +case "$COMMIT_SOURCE" in + merge|squash|commit) exit 0 ;; +esac + +# --- Detect AI agent --- +# All detection is via environment variables. No subprocess calls. + +AGENT_NAME="" +AGENT_EMAIL="" + +# Domain for synthetic Co-Author emails. Override with IRIS_AGENT_EMAIL_DOMAIN. +# Default is "iris.invalid" (RFC 6761 reserved TLD — guaranteed never routable). +AGENT_EMAIL_DOMAIN="${IRIS_AGENT_EMAIL_DOMAIN:-iris.invalid}" + +# 1. Vercel standard ($AI_AGENT) +if [ -n "$AI_AGENT" ]; then + AGENT_NAME="$AI_AGENT" + AGENT_EMAIL="$(printf '%s' "$AI_AGENT" | tr '[:upper:] ' '[:lower:]-')@${AGENT_EMAIL_DOMAIN}" + +# 2. Claude Code +elif [ -n "$CLAUDE_CODE" ]; then + AGENT_NAME="Claude Code" + AGENT_EMAIL="claude-code@${AGENT_EMAIL_DOMAIN}" + +# 3. Cursor +elif [ -n "$CURSOR_SESSION" ] || [ -n "$CURSOR_TRACE_ID" ]; then + AGENT_NAME="Cursor" + AGENT_EMAIL="cursor@${AGENT_EMAIL_DOMAIN}" + +# 4. Windsurf +elif [ -n "$WINDSURF_SESSION" ]; then + AGENT_NAME="Windsurf" + AGENT_EMAIL="windsurf@${AGENT_EMAIL_DOMAIN}" + +# 5. No agent detected — exit cleanly +else + exit 0 +fi + +# --- Check if attribution already present in the message --- +# Read the current message file (may be a template or empty). +# Match by tool name (local part) so this works regardless of email domain +# — including legacy trailers from older domains. + +if grep -qi "Co-Authored-By:.*\(claude-code\|cursor\|windsurf\|copilot\|anthropic\|codeium\|tabnine\|amazon-q\|gemini\)" "$COMMIT_MSG_FILE" 2>/dev/null; then + exit 0 +fi + +# --- Append Co-Authored-By to the message file --- +# This is a simple file append. No git commands, no side effects. + +printf '\nCo-Authored-By: %s <%s>\n' "$AGENT_NAME" "$AGENT_EMAIL" >> "$COMMIT_MSG_FILE" + +exit 0 + +# <<< iris-hook-end <<< From 6a4f3d4feaeb39f2ff60d72f2d2912794f60bc86 Mon Sep 17 00:00:00 2001 From: Fabio Wakim Trentini Date: Wed, 13 May 2026 14:25:54 -0300 Subject: [PATCH 4/6] chore(supabase): refresh local CLI state files Updates the cached project ref, pooler URL, and runtime version stamps written by the Supabase CLI on link/status. No functional impact. Co-Authored-By: Claude Opus 4.7 (1M context) --- platform/supabase/.temp/cli-latest | 2 +- platform/supabase/.temp/gotrue-version | 2 +- platform/supabase/.temp/linked-project.json | 6 ++++++ platform/supabase/.temp/pooler-url | 2 +- platform/supabase/.temp/postgres-version | 2 +- platform/supabase/.temp/project-ref | 2 +- platform/supabase/.temp/storage-migration | 2 +- platform/supabase/.temp/storage-version | 2 +- 8 files changed, 13 insertions(+), 7 deletions(-) create mode 100644 platform/supabase/.temp/linked-project.json diff --git a/platform/supabase/.temp/cli-latest b/platform/supabase/.temp/cli-latest index 0455888..a333a1d 100644 --- a/platform/supabase/.temp/cli-latest +++ b/platform/supabase/.temp/cli-latest @@ -1 +1 @@ -v2.90.0 \ No newline at end of file +v2.98.2 \ No newline at end of file diff --git a/platform/supabase/.temp/gotrue-version b/platform/supabase/.temp/gotrue-version index 5bbfd4d..04aa78d 100644 --- a/platform/supabase/.temp/gotrue-version +++ b/platform/supabase/.temp/gotrue-version @@ -1 +1 @@ -v2.188.1 \ No newline at end of file +v2.189.0 \ No newline at end of file diff --git a/platform/supabase/.temp/linked-project.json b/platform/supabase/.temp/linked-project.json new file mode 100644 index 0000000..88fe9b4 --- /dev/null +++ b/platform/supabase/.temp/linked-project.json @@ -0,0 +1,6 @@ +{ + "ref": "bpikflogxzqzwbibhocw", + "name": "clickbus-iris", + "organization_id": "iscyjyqcztfiwasehnsi", + "organization_slug": "iscyjyqcztfiwasehnsi" +} diff --git a/platform/supabase/.temp/pooler-url b/platform/supabase/.temp/pooler-url index ca3dd8a..879e5c7 100644 --- a/platform/supabase/.temp/pooler-url +++ b/platform/supabase/.temp/pooler-url @@ -1 +1 @@ -postgresql://postgres.yjijvnmtdfrcybworimd@aws-1-sa-east-1.pooler.supabase.com:5432/postgres \ No newline at end of file +postgresql://postgres.bpikflogxzqzwbibhocw@aws-1-sa-east-1.pooler.supabase.com:5432/postgres \ No newline at end of file diff --git a/platform/supabase/.temp/postgres-version b/platform/supabase/.temp/postgres-version index fc1481f..926f5fa 100644 --- a/platform/supabase/.temp/postgres-version +++ b/platform/supabase/.temp/postgres-version @@ -1 +1 @@ -17.6.1.104 \ No newline at end of file +17.6.1.121 \ No newline at end of file diff --git a/platform/supabase/.temp/project-ref b/platform/supabase/.temp/project-ref index dd25332..807843d 100644 --- a/platform/supabase/.temp/project-ref +++ b/platform/supabase/.temp/project-ref @@ -1 +1 @@ -yjijvnmtdfrcybworimd \ No newline at end of file +bpikflogxzqzwbibhocw \ No newline at end of file diff --git a/platform/supabase/.temp/storage-migration b/platform/supabase/.temp/storage-migration index 2494908..d5c3834 100644 --- a/platform/supabase/.temp/storage-migration +++ b/platform/supabase/.temp/storage-migration @@ -1 +1 @@ -operation-ergonomics \ No newline at end of file +optimize-existing-functions-again \ No newline at end of file diff --git a/platform/supabase/.temp/storage-version b/platform/supabase/.temp/storage-version index 14186a2..8daf6bb 100644 --- a/platform/supabase/.temp/storage-version +++ b/platform/supabase/.temp/storage-version @@ -1 +1 @@ -v1.48.20 \ No newline at end of file +v1.58.1 \ No newline at end of file From c7ab03519ee2d84ac5879cacd49ac279fb2fa61c Mon Sep 17 00:00:00 2001 From: Fabio Wakim Trentini Date: Wed, 13 May 2026 16:47:36 -0300 Subject: [PATCH 5/6] fix(integrations): schema-qualify pgcrypto calls in credential RPCs (#15) Supabase installs pgcrypto into the `extensions` schema, not `public`, so unqualified `pgp_sym_encrypt` / `pgp_sym_decrypt` fail with SQLSTATE 42883 when the migration runs against a Supabase project. Qualifies both calls as `extensions.pgp_sym_encrypt` and `extensions.pgp_sym_decrypt`. `encode` / `decode` are built-ins in `pg_catalog` and don't need qualification. Co-Authored-By: Claude Opus 4.7 (1M context) --- platform/supabase/migrations/014_org_integrations.sql | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/platform/supabase/migrations/014_org_integrations.sql b/platform/supabase/migrations/014_org_integrations.sql index 12fbd7f..eb52044 100644 --- a/platform/supabase/migrations/014_org_integrations.sql +++ b/platform/supabase/migrations/014_org_integrations.sql @@ -35,13 +35,17 @@ CREATE TRIGGER update_org_integrations_updated_at BEFORE UPDATE ON org_integrati -- key on the wire. Both functions are SECURITY INVOKER (default) and take -- the master key as a parameter — the key lives in app env, not in the DB. -- The application is responsible for passing INTEGRATIONS_ENCRYPTION_KEY. +-- +-- Note on pgcrypto: Supabase installs pgcrypto into the `extensions` +-- schema (not `public`), so we schema-qualify the calls. `encode`/`decode` +-- are built-ins in `pg_catalog` and don't need qualification. CREATE OR REPLACE FUNCTION encrypt_credentials(plaintext TEXT, master_key TEXT) RETURNS TEXT LANGUAGE sql VOLATILE AS $$ - SELECT encode(pgp_sym_encrypt(plaintext, master_key), 'base64'); + SELECT encode(extensions.pgp_sym_encrypt(plaintext, master_key), 'base64'); $$; CREATE OR REPLACE FUNCTION decrypt_credentials(encrypted TEXT, master_key TEXT) @@ -49,7 +53,7 @@ RETURNS TEXT LANGUAGE sql VOLATILE AS $$ - SELECT pgp_sym_decrypt(decode(encrypted, 'base64'), master_key); + SELECT extensions.pgp_sym_decrypt(decode(encrypted, 'base64'), master_key); $$; -- Restrict the RPC surface to service-role only. Application code uses From d6a02814a10bb06491e90693f8b1e23842fde50e Mon Sep 17 00:00:00 2001 From: Fabio Wakim Trentini Date: Wed, 13 May 2026 16:56:34 -0300 Subject: [PATCH 6/6] chore(supabase): untrack local CLI cache and broaden gitignore The supabase/.temp/ pattern only matched the repo root; the actual cache lives at platform/supabase/.temp/ and was getting committed on every CLI status/link. Switch to **/supabase/.temp/ and remove the tracked files. Co-Authored-By: Claude Opus 4.7 (1M context) --- platform/supabase/.temp/cli-latest | 1 - platform/supabase/.temp/gotrue-version | 1 - platform/supabase/.temp/linked-project.json | 6 ------ platform/supabase/.temp/pooler-url | 1 - platform/supabase/.temp/postgres-version | 1 - platform/supabase/.temp/project-ref | 1 - platform/supabase/.temp/rest-version | 1 - platform/supabase/.temp/storage-migration | 1 - platform/supabase/.temp/storage-version | 1 - 9 files changed, 14 deletions(-) delete mode 100644 platform/supabase/.temp/cli-latest delete mode 100644 platform/supabase/.temp/gotrue-version delete mode 100644 platform/supabase/.temp/linked-project.json delete mode 100644 platform/supabase/.temp/pooler-url delete mode 100644 platform/supabase/.temp/postgres-version delete mode 100644 platform/supabase/.temp/project-ref delete mode 100644 platform/supabase/.temp/rest-version delete mode 100644 platform/supabase/.temp/storage-migration delete mode 100644 platform/supabase/.temp/storage-version diff --git a/platform/supabase/.temp/cli-latest b/platform/supabase/.temp/cli-latest deleted file mode 100644 index a333a1d..0000000 --- a/platform/supabase/.temp/cli-latest +++ /dev/null @@ -1 +0,0 @@ -v2.98.2 \ No newline at end of file diff --git a/platform/supabase/.temp/gotrue-version b/platform/supabase/.temp/gotrue-version deleted file mode 100644 index 04aa78d..0000000 --- a/platform/supabase/.temp/gotrue-version +++ /dev/null @@ -1 +0,0 @@ -v2.189.0 \ No newline at end of file diff --git a/platform/supabase/.temp/linked-project.json b/platform/supabase/.temp/linked-project.json deleted file mode 100644 index 88fe9b4..0000000 --- a/platform/supabase/.temp/linked-project.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "ref": "bpikflogxzqzwbibhocw", - "name": "clickbus-iris", - "organization_id": "iscyjyqcztfiwasehnsi", - "organization_slug": "iscyjyqcztfiwasehnsi" -} diff --git a/platform/supabase/.temp/pooler-url b/platform/supabase/.temp/pooler-url deleted file mode 100644 index 879e5c7..0000000 --- a/platform/supabase/.temp/pooler-url +++ /dev/null @@ -1 +0,0 @@ -postgresql://postgres.bpikflogxzqzwbibhocw@aws-1-sa-east-1.pooler.supabase.com:5432/postgres \ No newline at end of file diff --git a/platform/supabase/.temp/postgres-version b/platform/supabase/.temp/postgres-version deleted file mode 100644 index 926f5fa..0000000 --- a/platform/supabase/.temp/postgres-version +++ /dev/null @@ -1 +0,0 @@ -17.6.1.121 \ No newline at end of file diff --git a/platform/supabase/.temp/project-ref b/platform/supabase/.temp/project-ref deleted file mode 100644 index 807843d..0000000 --- a/platform/supabase/.temp/project-ref +++ /dev/null @@ -1 +0,0 @@ -bpikflogxzqzwbibhocw \ No newline at end of file diff --git a/platform/supabase/.temp/rest-version b/platform/supabase/.temp/rest-version deleted file mode 100644 index 908947a..0000000 --- a/platform/supabase/.temp/rest-version +++ /dev/null @@ -1 +0,0 @@ -v14.5 \ No newline at end of file diff --git a/platform/supabase/.temp/storage-migration b/platform/supabase/.temp/storage-migration deleted file mode 100644 index d5c3834..0000000 --- a/platform/supabase/.temp/storage-migration +++ /dev/null @@ -1 +0,0 @@ -optimize-existing-functions-again \ No newline at end of file diff --git a/platform/supabase/.temp/storage-version b/platform/supabase/.temp/storage-version deleted file mode 100644 index 8daf6bb..0000000 --- a/platform/supabase/.temp/storage-version +++ /dev/null @@ -1 +0,0 @@ -v1.58.1 \ No newline at end of file