A small delegation broker: verify a macaroon-style warrant, enforce a credit
hold, make (or refuse) one outbound call, record the attempt. v0 of the
service described in typednotes/typednotes's docs/services/broker.md and
docs/services/ledger.md. Built on linen.
See AGENTS.md for the module layout, test-running
instructions, and the full list of what v0 deliberately does not implement.
lake build
LIAISON_ROOT_KEY=$(openssl rand -hex 32) lake build LiaisonTests
LIAISON_ROOT_KEY=... DATABASE_URL=... SECRETS_HOST=... \
SECRETS_USERNAME=liaison SECRETS_PASSWORD=... \
GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... \
DROPBOX_CLIENT_ID=... DROPBOX_CLIENT_SECRET=... \
GITLAB_CLIENT_ID=... GITLAB_CLIENT_SECRET=... \
lake exe liaison
| Variable | Required | Notes |
|---|---|---|
LIAISON_ROOT_KEY |
yes | hex root key the warrant tag chain is verified with |
DATABASE_URL |
yes | Postgres URI (audit_log, ledger's credit_holds/credit_ledger) |
SECRETS_HOST |
yes | typednotes/secrets host |
SECRETS_PORT |
no | default 443 (80 with SECRETS_INSECURE=1) |
SECRETS_INSECURE |
no | 1 disables TLS to secrets (local dev only) |
SECRETS_USERNAME, SECRETS_PASSWORD |
one of these… | userpass login (POST /v1/auth/userpass/login); the token is cached and renewed when < 60 s remain, and once after a 403 |
SECRETS_TOKEN |
…or this | static vault token, used only when SECRETS_USERNAME is unset. Startup fails if neither is configured |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
no | needed to refresh google_oauth credentials; without them a due refresh is credential_unavailable |
DROPBOX_CLIENT_ID, DROPBOX_CLIENT_SECRET |
no | the same, for dropbox_oauth (the app's Dropbox app) |
GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET |
no | the same, for gitlab_oauth (the app's gitlab.com OAuth application) |
LIAISON_PORT |
no | default 8080 |
GET /_health→200 ok(liveness only).POST /v0/egress— the one egress chokepoint (the wire format is inLiaison/Server.lean's header; the cross-service contract istypednotes/typednotes'sdocs/connections.md§5). Thecallobject:
On success the response is 200 with {"status", "headers", "body": <hex>}
relaying whatever the provider answered. Every attempt writes exactly one
audit_log row whose outcome is ok or the denial's error code:
error |
HTTP | When |
|---|---|---|
malformed_warrant |
400 | unparsable body, bad method, account not a/b of [A-Za-z0-9_-] or its last segment ≠ resource |
tag_invalid, capability_denied, resource_denied, wrong_run, expired, budget_exceeded |
403 | warrant checks |
budget_unavailable |
402 | no hold could be placed (or the hold lifecycle's Postgres calls failed) |
header_denied |
400 | a caller header is authorization, proxy-authorization, x-api-key, host, content-length, cookie, transfer-encoding, connection, any x-amz-*, a header the credential sets, or malformed |
url_denied |
403 | URL not base_url, under base_url + "/", or base_url + "?"; or unparsable, with userinfo, a fragment or a dot segment; or, for azure_sas, a query key that is a SAS parameter |
credential_unavailable |
502 | no credential, unknown kind, malformed credential, vault failure, OAuth refresh failed/impossible |
upstream_failed |
502 | the provider could not be reached |
inference_not_implemented |
501 | {"kind": "inference"} (stub) |
Read from secret/data/thirdparty/{provider}/{account}; the data object is
one of (every value a JSON string; optional headers: static headers added to
every call):
kind |
Fields | Authentication |
|---|---|---|
bearer |
base_url, token |
Authorization: Bearer {token} |
header |
base_url, header, token |
{header}: {token} |
google_oauth |
base_url, access_token, refresh_token, expires_at (Unix s) |
Authorization: Bearer {access_token}; refreshed at https://oauth2.googleapis.com/token when expires_at - 60 ≤ now (liaison's wall clock), then written back to the vault (best-effort) |
dropbox_oauth |
as google_oauth |
the same, refreshed at https://api.dropboxapi.com/oauth2/token |
gitlab_oauth |
as google_oauth |
the same, refreshed at https://gitlab.com/oauth/token; GitLab rotates the refresh token on every refresh, so a failed write-back means reconnecting |
s3 |
base_url, region, access_key_id, secret_access_key |
AWS SigV4, service s3, payload hash = SHA-256 of the body, signed headers host;x-amz-content-sha256;x-amz-date |
azure_sas |
base_url, sas (a query string of SAS parameters only, with sv and sig) |
the SAS appended to the call's query; the caller's query may not use any SAS parameter name (url_denied) |
liaison owns one table, audit_log (sql/0001_audit_log.sql), and writes
ledger's credit_holds/credit_ledger. It never migrates at startup: in
production typednotes-infra reads sql/*.sql at the release tag and
applies it as a declared migration history before the container rolls out
(the container also waits for ledger's history, whose tables it writes). For a local database, apply the
app's, then ledger's, then sql/*.sql here, in that order.
Local image builds use podman, not docker:
podman build -t liaison .
podman run --rm -p 8080:8080 \
-e LIAISON_ROOT_KEY=... -e DATABASE_URL=... \
-e SECRETS_HOST=... -e SECRETS_USERNAME=liaison -e SECRETS_PASSWORD=... \
liaison
The GitHub Actions publish workflow (.github/workflows/docker-publish.yml)
runs on GitHub-hosted runners and uses the standard docker/*-action steps —
that is unrelated to local dev tooling and unaffected by the above.