# PermitPro API reference

All server-side logic runs as Supabase Edge Functions under
`https://<project>.supabase.co/functions/v1/<name>` and is consumed by

- the web app (`supabase.functions.invoke` or `fetch`), and
- the MCP server (`/functions/v1/mcp`), which exposes the same logic as tools.

Everything below shares one transport contract so both consumers can branch on
the same fields. The shared implementation lives in
`supabase/functions/_shared/api.ts`.

---

## 1. Transport contract

**Method**: `POST` only (plus `OPTIONS` for CORS). Any other verb returns
`bad_request`.

**Request**: JSON body, validated with zod *before* any upstream call is made.

**Success response** — HTTP 200:

```json
{ "ok": true, "request_id": "b1c1…", "…payload": "…" }
```

**Error response**:

```json
{
  "ok": false,
  "request_id": "b1c1…",
  "error_code": "validation_failed",
  "error": "Ugyldige felter i forespørgslen",
  "details": { "fieldErrors": { "x": ["Expected number"] } }
}
```

`request_id` is echoed from the `x-request-id` header when supplied, otherwise
generated. It appears in the function logs on both the success and failure log
line, so any user-reported failure can be traced to one request.

### Error codes

| `error_code` | HTTP | Meaning | Client action |
| --- | --- | --- | --- |
| `bad_request` | 400 | Wrong method or unparsable JSON | Fix the call |
| `validation_failed` | 422 | Body failed schema validation, see `details` | Fix the input |
| `unauthorized` | 401 | Missing/expired bearer token | Re-authenticate |
| `forbidden` | 403 | Authenticated but not allowed (incl. AI credits exhausted) | Surface message |
| `not_found` | 404 | Resource does not exist | Surface message |
| `rate_limited` | 429 | Per-IP quota hit; `Retry-After: 60` | Back off, then retry |
| `upstream_unavailable` | 502 | External register (Datafordeler, Plandata, AI) failed | Retry later |
| `not_configured` | 500 | A required secret is missing | Operator action |
| `internal_error` | 500 | Unexpected failure | Retry / report `request_id` |

Only `rate_limited` and `upstream_unavailable` are worth retrying.

### Partial results

Lookups that aggregate several registers never fail as a whole when one source
is down. They return `ok: true` with `partial: true` (and `failedLayers` /
`errors` where useful), so the UI renders what is known and labels the gap.

### Auth

| Caller | Header |
| --- | --- |
| Web app (signed in) | `Authorization: Bearer <supabase access token>` |
| MCP client | OAuth token issued by Supabase auth, forwarded per-tool |
| Function → function | `Authorization: Bearer <service role key>` (internal only) |

Authenticated functions accept both user tokens and the service-role token; the
handler knows which via `caller.isServiceRole`. Database reads that must respect
RLS use the caller's token, never the service role.

---

## 2. Endpoints

### `lookup-plandata` — public

Plan rules for a point.

| | |
| --- | --- |
| Auth | none (`verify_jwt = false`) |
| Upstream | Plandata.dk GeoServer WFS |
| Timeout | 12 s per layer, layers run in parallel |

Request:

```json
{ "x": 724490.0, "y": 6182240.0 }
```

`x`/`y` are EPSG:25832 coordinates (what DAWA returns with `srid=25832`).

Response payload:

| Field | Type | Notes |
| --- | --- | --- |
| `lokalplaner` | array | Adopted local plans: `planNavn`, `planNr`, `maxBygPct`, `maxEtager`, `maxHoejde`, `minGrund`, `dokLink`, … |
| `lokalplanForslag` | array | Proposed plans incl. `hoeringFrist` |
| `kommuneplanrammer` | array | Municipal plan frames |
| `zonestatus` | string \| null | `byzone`, `landzone`, `sommerhusområde` |
| `beskyttelser` | array | e.g. `{ "type": "naturbeskyttelsesomraade" }` |
| `partial`, `failedLayers` | bool, string[] | Set when a WFS layer failed |
| `plans`, `count` | | Deprecated aliases for `lokalplaner` |

Numeric plan limits are coerced to numbers or `null` — never strings.

---

### `df-lookup` — authenticated

BBR buildings + Matriklen parcel data for an address.

| | |
| --- | --- |
| Auth | user JWT or service role |
| Upstream | `graphql.datafordeler.dk` (BBR v1, MAT v1) |
| Cache | `df_cache`, 24 h TTL, 30 day retention, key `property:<id>` |

Request:

```json
{ "kind": "property", "adgangsadresse_id": "0a3f507c-0624-32b8-e044-0003ba298018" }
```

Response payload: `bygninger[]` (deduped, largest footprint first), `matrikel`
(incl. `registreretAreal`, `matrikelnummer`, `ejerlavsnavn`), `bfe`, `ejere`
(always empty — Ejerfortegnelsen needs OAuth + IP allowlisting), `cached`,
`partial`, `errors[]`.

BBR access depends on the register permissions on our Datafordeler
IT-system. When BBR is denied the call still succeeds with `partial: true`.

---

### `public-address-check` — public, rate limited

The grounded front-page check. One call, everything a homeowner needs.

| | |
| --- | --- |
| Auth | none |
| Rate limit | 20 requests / 60 min per hashed IP (`public_lookup_log`) |
| Composes | `lookup-plandata` + `df-lookup` + `municipalities` table |

Request:

```json
{
  "adgangsadresse_id": "0a3f…",
  "address": "Strandvejen 100, 2900 Hellerup",
  "kommune": "Gentofte",
  "x": 724490.0,
  "y": 6182240.0
}
```

Response payload: `zonestatus`, `lokalplan`, `ramme`, `maxBygPct`,
`maxEtager`, `maxHoejde`, `bbr`, `grundAreal`, `bebyggelsesprocent`,
`notes[]` (Danish plain-language findings, e.g. built percentage over the
limit → dispensation likely), `municipality` (processing time + fee), and
`partial`.

Raw IPs are never stored — only a salted SHA-256 hash.

---

### `knowledge-search` — authenticated

Cited vector search over the scraped corpus.

Request:

```json
{ "query": "Hvor lang er sagsbehandlingstiden?", "municipality_id": null, "k": 6 }
```

Embeddings: `openai/text-embedding-3-small` via the Lovable AI Gateway;
retrieval: `match_document_chunks` RPC over `document_chunks` (pgvector/HNSW).
Gateway `429` maps to `rate_limited`, `402` to `forbidden`. Response payload:
`results[]` (chunk text, source URL, title, similarity) and `count`.

---

### Other functions

| Function | Auth | Purpose |
| --- | --- | --- |
| `ai-assistant` | user | Chat assistant with tool access to the functions above |
| `generate-application` | user | Drafts Danish application text for a case |
| `lint-document` | user | AI vision review of uploaded drawings |
| `scrape-municipality` | admin | Firecrawl scrape of one kommune |
| `batch-scrape-municipalities` | admin | Orchestrates all 98 kommuner |
| `extract-facts` | admin | Turns scraped pages into `municipality_facts` |
| `embed-documents` | admin | Embeds `source_documents` into `document_chunks` |
| `gsc-verify-site` | admin | Google Search Console verification + sitemap |

---

## 3. MCP server

Endpoint `/functions/v1/mcp`, OAuth-protected (Supabase issuer, audience
`authenticated`). Definition: `src/lib/mcp/index.ts`; manifest:
`.lovable/mcp/manifest.json`.

| Tool | Read/Write | Backed by |
| --- | --- | --- |
| `lookup_address` | read | DAWA (`api.dataforsyningen.dk`) |
| `check_address` | read | `public-address-check` |
| `get_municipality` | read | `municipalities` + `municipality_facts` |
| `search_knowledge` | read | `knowledge-search` |
| `list_applications` | read | `applications` (RLS as caller) |
| `get_application` | read | application + checklist + documents |
| `create_application` | write | inserts a draft case |
| `update_application` | write | updates the caller's own case |

Conventions every tool follows:

- Input is a zod schema with a `.describe()` on each field — the description is
  what the model sees, so it states units and formats (EPSG:25832, uuid).
- Output is `{ content: [{ type: "text", text: JSON }], structuredContent }`
  via the `toolOk` helper; failures use `toolError` and set `isError: true`.
- Tools that touch user data call `ctx.isAuthenticated()` first and query
  through `supabaseForUser(ctx)` so Postgres RLS — not the tool — is the
  authority on access.
- Tools that wrap an edge function use `callFunction` (`src/lib/mcp/functions.ts`),
  which forwards the caller's token and unwraps the envelope above.

### Adding a tool

1. Create `src/lib/mcp/tools/<name>.ts` with `defineTool`, a described zod
   schema, and `annotations` (`readOnlyHint`, `destructiveHint`, `openWorldHint`).
2. Return `toolOk(payload)` / `toolError(message)`.
3. Register it in `src/lib/mcp/index.ts`.
4. Document it in the table above.

### Adding an endpoint

1. Create `supabase/functions/<name>/index.ts` and export `serveApi({ name,
   schema, auth, handler })` from `../_shared/api.ts`.
2. Throw `ApiError(code, message, details)` for expected failures — the wrapper
   maps the code to the right HTTP status and logs it.
3. Return a plain object; the wrapper adds `ok` and `request_id`.
4. Document the request, payload and failure modes here.
