Compare commits
31 Commits
feat/v1.1.
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cbb6ca427 | ||
|
|
3cfb795206 | ||
|
|
a7d3dad129 | ||
|
|
2ea47eb05a | ||
|
|
b35585195b | ||
|
|
fffcdf6169 | ||
|
|
02335a8132 | ||
|
|
1f78937dd2 | ||
|
|
8f2d2bc721 | ||
|
|
2d11090d1a | ||
|
|
dc2e4fa01f | ||
|
|
64ad978a89 | ||
|
|
f5a3f92484 | ||
|
|
b1dddb9cb9 | ||
|
|
fcbcc576a2 | ||
|
|
d064681c49 | ||
|
|
9492c18d0e | ||
|
|
4595db7a7a | ||
|
|
834c787ee1 | ||
|
|
6e132b6ee0 | ||
|
|
03d03ea6e7 | ||
|
|
6080fc67f6 | ||
|
|
10b5f655d5 | ||
|
|
6f17259e06 | ||
|
|
3715778f56 | ||
|
|
3dbead426f | ||
|
|
10f76d29ca | ||
|
|
610fd4ffa2 | ||
|
|
66b41bb978 | ||
|
|
c6211a73b9 | ||
|
|
84833d3e4e |
72
.github/workflows/ci.yml
vendored
Normal file
72
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Matches what docker-compose produces locally; the schema-snapshot
|
||||
# guardrail and any other DB-backed tests run against this service.
|
||||
DATABASE_URL: postgres://picloud:picloud@localhost:5432/picloud
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
name: Rust — fmt, clippy, test
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_USER: picloud
|
||||
POSTGRES_PASSWORD: picloud
|
||||
POSTGRES_DB: picloud
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U picloud"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# rust-toolchain.toml pins the channel; this action honors it.
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
# Runs the whole workspace, including the schema-snapshot guardrail
|
||||
# (it picks up DATABASE_URL from the env above and the postgres
|
||||
# service; without a DB it would skip cleanly).
|
||||
- name: Test
|
||||
run: cargo test --workspace
|
||||
|
||||
dashboard:
|
||||
name: Dashboard — check
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: dashboard
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: dashboard/package-lock.json
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
- name: Svelte check
|
||||
run: npm run check
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,6 +22,9 @@ Cargo.lock.bak
|
||||
# Local config overrides
|
||||
config.local.toml
|
||||
/data
|
||||
# Files-root blob storage created when integration tests run build_app
|
||||
# from the picloud crate dir (PICLOUD_FILES_ROOT default ./data).
|
||||
/crates/picloud/data
|
||||
/postgres-data
|
||||
|
||||
# Dashboard
|
||||
|
||||
459
CHANGELOG.md
459
CHANGELOG.md
@@ -1,5 +1,464 @@
|
||||
# PiCloud Changelog
|
||||
|
||||
## v1.1.7 — Configuration & Email (unreleased)
|
||||
|
||||
The operational-config layer: **encrypted per-app secrets**, **outbound
|
||||
email**, and an **inbound email trigger** — plus the long-missing
|
||||
**dead-letter handler wiring** and **at-rest encryption of the realtime
|
||||
signing key**. All at-rest encryption uses a single process master key
|
||||
(AES-256-GCM); key rotation is deferred to v1.2.
|
||||
|
||||
### Added — Encryption infrastructure
|
||||
|
||||
- **Process master key** from `PICLOUD_SECRET_KEY` (base64 of exactly 32
|
||||
bytes). REQUIRED at startup — an unset or malformed key is fatal.
|
||||
Generate one with `openssl rand -base64 32`. A deterministic in-memory
|
||||
dev key is used ONLY when `PICLOUD_SECRET_KEY` is unset AND
|
||||
`PICLOUD_DEV_MODE=true` (with a prominent startup warning); there is no
|
||||
quiet unencrypted mode.
|
||||
- **`picloud_shared::crypto`** — `encrypt`/`decrypt` envelope:
|
||||
`Aes256Gcm`, 96-bit CSPRNG nonce, 128-bit auth tag appended to the
|
||||
ciphertext (RustCrypto `Aead` layout). Both ciphertext and nonce are
|
||||
stored.
|
||||
- **Key rotation is out of scope.** Changing `PICLOUD_SECRET_KEY` between
|
||||
deploys renders all existing ciphertext undecryptable. v1.2+ adds
|
||||
key-version columns + a re-encryption pass.
|
||||
|
||||
### Added — Encrypted per-app secrets
|
||||
|
||||
- **`secrets::{get,set,delete,list}(name)`** SDK — collection-less,
|
||||
per-app. `set` accepts a String/Map/Array (JSON-encoded then encrypted);
|
||||
`get` returns the same Rhai type back; missing → `()`. 64 KB plaintext
|
||||
cap (`PICLOUD_SECRET_MAX_VALUE_BYTES`). `migrations/0023_secrets.sql`.
|
||||
- **Admin API** `GET/POST/DELETE /api/v1/admin/apps/{id}/secrets` — list
|
||||
returns names + `updated_at` only, **never values**.
|
||||
- **Dashboard Secrets tab** — list names + last-modified, create/update
|
||||
(masked value with a confirm-gated reveal), delete with confirm.
|
||||
- `Capability::AppSecretsRead`/`Write` (→ `script:read` / `script:write`).
|
||||
No new Scope variants (seven-scope commitment). Secret writes
|
||||
deliberately do **not** emit trigger events.
|
||||
|
||||
### Added — Outbound email
|
||||
|
||||
- **`email::send` / `email::send_html`** SDK over an SMTP relay
|
||||
(`lettre`). Config from `PICLOUD_SMTP_HOST/PORT/USER/PASSWORD/TLS/
|
||||
TIMEOUT_SECS`; if HOST/USER/PASSWORD aren't all set the service runs in
|
||||
**disabled mode** (every send throws `NotConfigured`, warned at
|
||||
startup). Required `to`/`from`/`subject` + one of `text`/`html`;
|
||||
RFC 5322-ish address validation; 25 MB per-message cap
|
||||
(`PICLOUD_EMAIL_MAX_MESSAGE_BYTES`); `reply_to` defaults to `from`.
|
||||
Per-call connection (pooling deferred to v1.2); per-app `from`
|
||||
validation / SPF / DKIM are the operator's SMTP-relay concern.
|
||||
- `Capability::AppEmailSend` (→ `script:write`).
|
||||
|
||||
### Added — Inbound email (`email:receive` trigger)
|
||||
|
||||
- **Webhook receiver** `POST /api/v1/email-inbound/{app_id}/{trigger_id}`
|
||||
— a provider (Mailgun / Postmark / SendGrid / SES) POSTs the generic
|
||||
JSON shape `{from,to[],cc[],subject,text,html,message_id}`; the
|
||||
receiver verifies the optional HMAC signature, normalizes to
|
||||
`TriggerEvent::Email`, and enqueues an outbox row. 202 accepted, 401
|
||||
bad/missing signature, 404 missing/wrong-kind/cross-app, 422 malformed.
|
||||
Handlers see `ctx.event.email`. `migrations/0024_email_triggers.sql`.
|
||||
- **Admin** `POST /api/v1/admin/apps/{id}/triggers/email` +
|
||||
dashboard form (with the webhook URL + expected payload). The HMAC
|
||||
`inbound_secret` is stored **encrypted** via the master key (deviation
|
||||
from the original plaintext design — see HANDBACK §7).
|
||||
- Provider-specific payload unmarshallers + inbound attachments → v1.2.
|
||||
Native SMTP listener → v1.3+.
|
||||
|
||||
### Security/correctness fix (retroactive) — dead_letter handlers
|
||||
|
||||
The `dead_letter` trigger kind has been registerable since v1.1.1 but,
|
||||
due to missing dispatcher wiring (`list_matching_dead_letter` had no
|
||||
production caller), handlers have **never fired**. Any deploy running
|
||||
v1.1.1 through v1.1.6 with `dead_letter` triggers configured has had
|
||||
silently non-functional handlers. v1.1.7 fixes the wiring; existing
|
||||
`dead_letters` rows remain (no migration needed) but only NEW
|
||||
dead-letter events (post-v1.1.7) trigger handlers. To process older
|
||||
rows, use the existing admin replay surface to re-enqueue them.
|
||||
|
||||
### Changed — Realtime signing key encrypted at rest (two-phase)
|
||||
|
||||
`app_secrets.realtime_signing_key` was stored as 32 plaintext bytes. It
|
||||
is now encrypted with the master key. `migrations/0025_encrypt_realtime_keys.sql`
|
||||
adds NULL-able encrypted columns and drops `NOT NULL` on the plaintext
|
||||
column; a startup task encrypts pre-existing rows; the read path prefers
|
||||
the encrypted columns and falls back to plaintext during the compat
|
||||
window. **v1.1.8 will drop the plaintext `realtime_signing_key`
|
||||
column** — operators should upgrade through v1.1.7 (which performs the
|
||||
encryption) before v1.1.8.
|
||||
|
||||
### Notes
|
||||
|
||||
- **New deps:** `aes-gcm` (RustCrypto AEAD), `lettre` (SMTP).
|
||||
- **New env vars:** `PICLOUD_SECRET_KEY` (required), `PICLOUD_DEV_MODE`,
|
||||
`PICLOUD_SECRET_MAX_VALUE_BYTES`, `PICLOUD_SMTP_HOST/PORT/USER/PASSWORD/
|
||||
TLS/TIMEOUT_SECS`, `PICLOUD_EMAIL_MAX_MESSAGE_BYTES`.
|
||||
- **SDK schema** 1.7 → 1.8; **dashboard** 0.12.0 → 0.13.0.
|
||||
|
||||
## v1.1.6 — Realtime Channels & Client Library (unreleased)
|
||||
|
||||
The first **external realtime surface** and the first **frontend
|
||||
library**, co-shipped per the §5/§6 design-notes decisions. Browser
|
||||
clients can subscribe over SSE to per-app pub/sub topics that have been
|
||||
explicitly externalized; everything else stays internal-only. The
|
||||
`@picloud/client` TypeScript package wraps typed HTTP, SSE, auth, and
|
||||
React/Svelte hooks. Plus three v1.1.5 follow-ups.
|
||||
|
||||
### Added — Realtime
|
||||
|
||||
- **`topics` registry** (`migrations/0021_topics.sql`) — pub/sub topics
|
||||
are internal-only by default; a `topics` row with
|
||||
`external_subscribable = true` opts one into external SSE subscription.
|
||||
`auth_mode` is `'public'` or `'token'`.
|
||||
- **Topic admin endpoints** under `/api/v1/admin/apps/{id}/topics` —
|
||||
`POST` (register), `GET` (list), `PATCH /{name}` (flip
|
||||
external/auth_mode — its own audited surface), `DELETE /{name}`
|
||||
(unregister + disconnect live subscribers). Gated by the new
|
||||
`Capability::AppTopicManage` → `app:admin` scope (no new scope; the
|
||||
seven-scope commitment holds).
|
||||
- **SSE endpoint `GET /realtime/topics/{topic}`** — data-plane surface
|
||||
(deliberately not under `/api/`). Resolves `Host` → app, authorizes
|
||||
via the `RealtimeAuthority` (404 for missing/internal topics, 401 for
|
||||
bad/absent tokens), then streams `data: {topic,message,published_at}`
|
||||
events with a configurable heartbeat (`PICLOUD_REALTIME_HEARTBEAT_SEC`,
|
||||
default 30). Token via `Authorization: Bearer` or `?token=`.
|
||||
- **`RealtimeBroadcaster` + `RealtimeEvent` + `RealtimeAuthority`**
|
||||
traits (`picloud-shared`); in-process `InProcessBroadcaster`
|
||||
(`tokio::sync::broadcast`, per-channel capacity
|
||||
`PICLOUD_REALTIME_BROADCAST_CAPACITY` default 64, periodic empty-channel
|
||||
GC) and the DB-backed `RealtimeAuthorityImpl` (orchestrator-core /
|
||||
manager-core respectively). The publish path now also fans out to
|
||||
in-process SSE subscribers, best-effort, after the durable outbox
|
||||
fan-out commits — a broadcast failure never fails the publish.
|
||||
- **`pubsub::subscriber_token(topics, ttl)`** Rhai SDK (SDK schema
|
||||
1.6 → 1.7) — mints an HMAC-SHA256 subscriber token (URL-safe
|
||||
`payload.signature`) scoped to externally-subscribable topics.
|
||||
Requires an authenticated principal + the pub/sub publish capability.
|
||||
TTL clamped to `[10s, 24h]` (default 1h), env-overridable via
|
||||
`PICLOUD_SUBSCRIBER_TOKEN_TTL_{MIN,MAX,DEFAULT}_SEC`. Per-app signing
|
||||
keys persist in the new `app_secrets` table
|
||||
(`migrations/0022_app_secrets.sql`), created lazily on first mint. No
|
||||
per-token revocation (rotation invalidates wholesale; short TTL is the
|
||||
safety mechanism).
|
||||
- **Dashboard Topics tab** — register/list/edit/delete topics with a
|
||||
prominent external/internal badge, auth-mode radio (conditional on
|
||||
external), and a confirmation when flipping a topic external.
|
||||
|
||||
### Added — `@picloud/client` (TypeScript, v1.0.0)
|
||||
|
||||
- New top-level package `clients/typescript/` (tsup dual ESM+CJS +
|
||||
`.d.ts`, vitest). Typed HTTP via `endpoint<Req,Res>(path).get()/.post()`
|
||||
with auth-token injection and structured errors; SSE `subscribe(topic,
|
||||
cb, {token, onTokenExpired})` with exponential-backoff reconnect,
|
||||
401 token-refresh, and `Last-Event-ID` resume; `auth.login/logout/token`
|
||||
over dev-defined endpoints; React (`useTopic`/`useEndpoint` +
|
||||
`PicloudProvider`) and Svelte (`topicStore`/`endpointStore`) subpath
|
||||
exports. Optional zod/valibot runtime validation via a `{ parse }`
|
||||
adapter (no hard dep). Hybrid model: no direct service access from the
|
||||
browser.
|
||||
|
||||
### Changed / Fixed — v1.1.5 follow-ups
|
||||
|
||||
- **Empty blobs accepted** — `NewFile::validate` / `FileUpdate::validate`
|
||||
no longer reject zero-length `data`; empty files are a valid stored
|
||||
state (sentinels, placeholders). Non-breaking.
|
||||
- **Orphan `*.tmp.*` sweeper** — a startup tokio task
|
||||
(`spawn_files_orphan_sweep`) walks the files root every
|
||||
`PICLOUD_FILES_ORPHAN_SWEEP_INTERVAL_SEC` (default 6h) and unlinks temp
|
||||
blobs older than `PICLOUD_FILES_ORPHAN_TMP_TTL_SEC` (default 1h). No DB
|
||||
cross-check (that full reconciler is v1.3+).
|
||||
- **Dispatcher end-to-end tests** — `crates/picloud/tests/dispatcher_e2e.rs`,
|
||||
one per trigger kind (kv/docs/cron/files/pubsub/dead_letter),
|
||||
DATABASE_URL-gated (skip cleanly when unset).
|
||||
|
||||
### Notes
|
||||
|
||||
- New deps: `hmac` (token signing, picloud-shared), `tokio-stream` (SSE
|
||||
body stream, orchestrator-core).
|
||||
- New env vars: `PICLOUD_REALTIME_HEARTBEAT_SEC`,
|
||||
`PICLOUD_REALTIME_BROADCAST_CAPACITY`,
|
||||
`PICLOUD_SUBSCRIBER_TOKEN_TTL_{MIN,MAX,DEFAULT}_SEC`,
|
||||
`PICLOUD_FILES_ORPHAN_SWEEP_INTERVAL_SEC`,
|
||||
`PICLOUD_FILES_ORPHAN_TMP_TTL_SEC`.
|
||||
|
||||
## v1.1.5 — Files & Pub/Sub (unreleased)
|
||||
|
||||
Two stateful services + two trigger kinds. **`files::*`** is
|
||||
filesystem-backed blob storage (atomic writes, path-sharded layout,
|
||||
single-pass SHA-256 with checksum-verified reads); the metadata row
|
||||
lives in Postgres, the bytes on disk. **`pubsub::publish_durable`** is
|
||||
durable pub/sub through the universal outbox, fanning out one delivery
|
||||
row per matching subscriber **at publish time** inside a single
|
||||
transaction. Both ride the v1.1.1 trigger framework as the fifth and
|
||||
sixth concrete kinds via the established Layout-E extension pattern.
|
||||
|
||||
### Added
|
||||
|
||||
- **`files::collection(name).{create,head,get,update,delete,list}`** —
|
||||
blob storage SDK. `create`/`update` take a Rhai `Blob`; `get` returns
|
||||
a `Blob` (or `()` if missing); `head`/`list` return metadata maps
|
||||
(`id, name, content_type, size, checksum, created_at, updated_at`).
|
||||
`create`/`update`/`delete` throw on failure; `get`/`head` return `()`
|
||||
for a missing file; `delete` returns a was-present bool. Missing
|
||||
required field on `create` throws naming the field.
|
||||
- **Atomic writes** — temp file → fsync → rename → fsync parent dir →
|
||||
DB row, so a crash never leaves a readable half-written file. SHA-256
|
||||
is computed in a single pass during the write; `get` re-verifies it
|
||||
and surfaces `FilesError::Corrupted` (logged with the path, never
|
||||
auto-deleted) on a mismatch. Shard dirs are created `0o700`.
|
||||
- **`files:*` trigger kind** — `ctx.event.files` carries the metadata
|
||||
only (never the bytes; a handler that wants them calls
|
||||
`files::collection(c).get(id)`). `prev` is `()` on create, the prior
|
||||
metadata on update, the deleted metadata on delete.
|
||||
- **`pubsub::publish_durable(topic, message)`** — durable publish.
|
||||
Message is any JSON-serializable Rhai value; Blobs encode as base64
|
||||
(at any nesting depth). No matching subscriber → the publish succeeds
|
||||
silently with zero outbox rows.
|
||||
- **`pubsub:*` trigger kind** — topic patterns are exact, `<prefix>.*`,
|
||||
or `*`; mid-pattern wildcards are rejected at trigger creation.
|
||||
`ctx.event.pubsub` carries `topic`, `message`, `published_at`.
|
||||
- **`FilesService` + `PubsubService` traits** (`picloud-shared`) +
|
||||
`FsFilesRepo`/`FilesServiceImpl` and `PostgresPubsubRepo`/
|
||||
`PubsubServiceImpl` (manager-core). Wired into the `Services` bundle
|
||||
as `files` and `pubsub`.
|
||||
- **Capabilities** `AppFilesRead`/`AppFilesWrite` → `script:read`/
|
||||
`script:write`, `AppPubsubPublish` → `script:write`. No new `Scope`
|
||||
variant — the seven-scope commitment holds. Script-as-gate: skipped
|
||||
when the script runs unauthenticated.
|
||||
- **Admin files API** (`GET`/`DELETE /apps/{id}/files`) + dashboard
|
||||
Files view per app; **Pub/Sub trigger form** on the Triggers tab.
|
||||
- **CI** — first `.github/workflows/ci.yml` (Postgres service, fmt +
|
||||
clippy + `cargo test --workspace`); the schema-snapshot guardrail now
|
||||
runs instead of being `#[ignore]`'d.
|
||||
|
||||
### Changed
|
||||
|
||||
- Workspace version: 1.1.4 → 1.1.5
|
||||
- Rhai SDK version: 1.5 → 1.6
|
||||
- Dashboard version: 0.10.0 → 0.11.0
|
||||
- `schema_snapshot` test: no longer `#[ignore]`'d — runs against
|
||||
`DATABASE_URL` when set, skips cleanly when absent.
|
||||
|
||||
### Migrations
|
||||
|
||||
- 0018_files.sql — `files` metadata table (bytes live on disk).
|
||||
- 0019_files_triggers.sql — widen kind/source_kind CHECKs + add
|
||||
`files_trigger_details`.
|
||||
- 0020_pubsub_triggers.sql — widen kind/source_kind CHECKs + add
|
||||
`pubsub_trigger_details` + partial index.
|
||||
|
||||
### New environment variables
|
||||
|
||||
- `PICLOUD_FILES_ROOT` (default `./data`)
|
||||
- `PICLOUD_FILES_MAX_FILE_SIZE_BYTES` (default 100 MB)
|
||||
|
||||
## v1.1.4 — Outbound HTTP & Cron triggers (unreleased)
|
||||
|
||||
Two surfaces. **`http::*`** lets Rhai scripts make outbound HTTP
|
||||
requests (Slack webhooks, Stripe, third-party REST) fronted by an SSRF
|
||||
deny-list applied to the *resolved IP* (DNS-rebinding defense), with
|
||||
scheme/port restrictions, request/response body caps, and a layered
|
||||
timeout. **Cron triggers** add the fourth concrete kind on the v1.1.1
|
||||
trigger framework: a scheduler task enqueues due triggers into the same
|
||||
universal outbox the dispatcher already drains.
|
||||
|
||||
### Added
|
||||
|
||||
- **`http::{get,post,put,patch,delete,head,post_form,request}`** — outbound
|
||||
HTTP SDK. Body and options are separate positional args
|
||||
(`verb(url, body, opts)`); `opts` is
|
||||
`{headers, timeout_ms, follow_redirects, max_redirects}` (unknown keys
|
||||
throw). Body dispatch by type: Map/Array → JSON, String → text/plain,
|
||||
`()` → none. Response is `#{ status, headers, body, body_raw }` with
|
||||
`body` auto-parsed when the response is `application/json`. Non-2xx
|
||||
does NOT throw (fetch-style); network/timeout/SSRF/size errors throw
|
||||
with an `"http: …"` prefix.
|
||||
- **SSRF deny-list** — applied to the resolved IP via a custom reqwest
|
||||
`dns_resolver` (so it covers every redirect hop and defeats DNS
|
||||
rebinding), plus a literal-IP check at URL-parse time. Blocks
|
||||
loopback, RFC1918 private, link-local (incl. `169.254.169.254`),
|
||||
carrier-grade NAT, multicast, reserved, IPv6 ULA/link-local/loopback,
|
||||
and IPv4-mapped IPv6 (re-checked against the embedded v4 address).
|
||||
The script-visible error carries a CIDR-category reason, never the IP.
|
||||
`PICLOUD_HTTP_ALLOW_PRIVATE=true` disables it (dev-only; logs a startup
|
||||
warning).
|
||||
- **`HttpService` trait** (`picloud-shared`) + `HttpServiceImpl`
|
||||
(manager-core, reqwest-backed). Wired into the `Services` bundle as
|
||||
`http: Arc<dyn HttpService>`.
|
||||
- **`Capability::AppHttpRequest(AppId)`** — maps to the existing
|
||||
`script:write` scope (any outbound request can exfiltrate data, so the
|
||||
conservative write mapping is used). No new `Scope` variant — the
|
||||
seven-scope commitment holds. Script-as-gate: skipped when the script
|
||||
runs unauthenticated.
|
||||
- **Cron triggers** — `POST /api/v1/admin/apps/{id}/triggers/cron`
|
||||
(`script_id`, `schedule`, `timezone`, optional retry overrides).
|
||||
6-field cron expressions (with seconds) validated by the `cron` crate;
|
||||
IANA timezones validated by `chrono-tz`. A scheduler task
|
||||
(`spawn_cron_scheduler`, poll cadence `PICLOUD_CRON_TICK_INTERVAL_MS`,
|
||||
default 30s) enqueues due triggers into the outbox; the existing
|
||||
dispatcher delivers them. Catch-up policy: a trigger that missed N
|
||||
windows fires exactly **once** on the next tick, not N times.
|
||||
- **`ctx.event.cron`** — `{ schedule, timezone, scheduled_at, fired_at }`
|
||||
for cron-trigger handlers (`ctx.event.source == "cron"`,
|
||||
`ctx.event.op == "tick"`).
|
||||
- **Dashboard Triggers tab** — admin-gated cron trigger create form
|
||||
(target endpoint script, schedule, timezone dropdown) + triggers list
|
||||
showing schedule / timezone / last-fired.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Workspace version**: `1.1.3` → `1.1.4`.
|
||||
- **Rhai SDK version**: `1.4` → `1.5` (additive — `http::*` SDK +
|
||||
`ctx.event.cron`). The `Services` bundle constructor becomes
|
||||
`Services::new(kv, docs, dead_letters, events, modules, http)`.
|
||||
- **Dashboard version**: `0.9.0` → `0.10.0`.
|
||||
- **`SdkCallCx`** — gains a `script_id` field (audit attribution + the
|
||||
default outbound `User-Agent`, `picloud/<version> (script:<id>)`).
|
||||
- **Rhai pin tightened** — workspace dep `rhai = "1.19"` → `rhai = "=1.24"`
|
||||
so future bumps of the non-semver-stable `internals` surface are
|
||||
deliberate.
|
||||
- **Module backend errors redacted** — `PicloudModuleResolver` now
|
||||
surfaces a stable generic (`"module backend unavailable; check server
|
||||
logs"`) to scripts and logs the original at error level, instead of
|
||||
leaking the backend error verbatim (see v1.1.3 follow-up).
|
||||
|
||||
### Migrations
|
||||
|
||||
- `0017_cron_triggers.sql` — widens `triggers.kind` and
|
||||
`outbox.source_kind` CHECK constraints to include `'cron'`; adds
|
||||
`cron_trigger_details (trigger_id, schedule, timezone, last_fired_at)`
|
||||
with a `last_fired_at` index. Additive — applies cleanly on a fresh DB
|
||||
and on top of the v1.1.3 schema.
|
||||
|
||||
### New environment variables
|
||||
|
||||
- `PICLOUD_HTTP_ALLOW_PRIVATE` (default false; dev-only) — disable the
|
||||
SSRF deny-list.
|
||||
- `PICLOUD_HTTP_MAX_REQUEST_BODY_BYTES` / `PICLOUD_HTTP_MAX_RESPONSE_BODY_BYTES`
|
||||
(default 10 MB each).
|
||||
- `PICLOUD_CRON_TICK_INTERVAL_MS` (default 30000) — cron scheduler poll
|
||||
cadence (floored at 1s).
|
||||
|
||||
## v1.1.3 — Modules (unreleased)
|
||||
|
||||
Real per-app Rhai module system. Scripts can `import "<name>" as
|
||||
<alias>;` other scripts in the same app as reusable libraries. The
|
||||
v1.0 placeholder `DummyModuleResolver` is replaced by a per-call
|
||||
`PicloudModuleResolver` that loads `kind = 'module'` scripts via a
|
||||
new `ModuleSource` trait, compiles them into Rhai modules, caches
|
||||
the compiled output, and enforces cross-app isolation, circular-
|
||||
import detection, and an import-depth limit. Two LRU AST caches
|
||||
(top-level script + per-module compiled module) eliminate the
|
||||
per-invocation compile cost; both invalidate on `updated_at` change.
|
||||
|
||||
### Added
|
||||
|
||||
- **`scripts.kind` column** — `'endpoint' | 'module'`, default
|
||||
`'endpoint'`. Endpoints handle HTTP routes / trigger events;
|
||||
modules are libraries imported by other scripts. The dashboard
|
||||
scripts list + script detail page surface the distinction as a
|
||||
colored badge.
|
||||
- **`script_imports` dep-graph table** — populated at script save-
|
||||
time from the literal-path `import "<name>"` declarations in the
|
||||
source. FK-CASCADE on both columns. No admin surface in v1.1.3
|
||||
(drives a v1.2+ "Used by" dashboard panel and v1.3+ cluster-mode
|
||||
eager invalidation).
|
||||
- **`ModuleSource` trait** — `lookup(&SdkCallCx, name)`. Postgres
|
||||
impl `PostgresModuleSource` in manager-core. `app_id` derived from
|
||||
`cx.app_id` (cross-app isolation boundary, mirrors KV / docs).
|
||||
- **`PicloudModuleResolver`** — implements `rhai::ModuleResolver`.
|
||||
Per-call instance owns `Arc<SdkCallCx>`, the in-progress imports
|
||||
stack, the depth counter. Bridges sync `resolve()` to async
|
||||
`lookup()` via `Handle::block_on` (safe under the executor's
|
||||
`spawn_blocking` wrap). Replaces `DummyModuleResolver` at line 139
|
||||
of `executor-core::engine::build_engine`.
|
||||
- **Module-shape validation** — `kind = 'module'` source must contain
|
||||
only `fn` declarations, `const` declarations, and `import`
|
||||
statements at top level (no executable expressions). Walks
|
||||
`ast.statements()` via `rhai/internals`. Admin endpoint is the
|
||||
primary gate; the resolver re-runs the check at load time for
|
||||
defense in depth against DB-direct inserts.
|
||||
- **Per-module compiled-Module cache** — `LruCache<(AppId, name),
|
||||
(updated_at, Arc<rhai::Module>)>` owned by `Engine`. Invalidated
|
||||
lazily on `updated_at` mismatch. Size via
|
||||
`PICLOUD_MODULE_CACHE_SIZE` (default 512).
|
||||
- **Top-level script AST cache** — `LruCache<ScriptId, (updated_at,
|
||||
Arc<rhai::AST>)>` owned by `LocalExecutorClient`. Same staleness
|
||||
semantics. Size via `PICLOUD_SCRIPT_CACHE_SIZE` (default 256).
|
||||
- **`ScriptIdentity` + `ExecutorClient::execute_with_identity`** —
|
||||
new method on the trait; default impl forwards to `execute` so
|
||||
`RemoteExecutorClient` (and future transports) keep working.
|
||||
`LocalExecutorClient` overrides it to consult the script cache and
|
||||
pass the resulting `Arc<rhai::AST>` to `Engine::execute_ast`.
|
||||
- **`Engine::execute_ast`** — companion to `execute` that takes a
|
||||
pre-compiled AST so callers (the orchestrator) can reuse one
|
||||
compile across many invocations.
|
||||
- **Import depth limit** — `Limits::module_import_depth_max`
|
||||
(default 8). Not script-overridable.
|
||||
- **Reserved module names** — module-kind scripts cannot be named
|
||||
`log`, `regex`, `random`, `time`, `json`, `base64`, `hex`, `url`,
|
||||
`kv`, `docs`, `dead_letters`, `http`, `files`, `pubsub`, `secrets`,
|
||||
`email`, `users`, `queue`. Defense against author confusion with
|
||||
stdlib namespaces.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Workspace version**: `1.1.2` → `1.1.3`.
|
||||
- **Rhai SDK version**: `1.3` → `1.4` (additive — every v1.3 script
|
||||
still runs unchanged; new surface: `import "<name>" as <alias>;`
|
||||
for endpoint scripts that consume modules in the same app).
|
||||
- **Dashboard version**: `0.8.0` → `0.9.0`. Adds kind dropdown on
|
||||
script create + kind badges on the scripts list and detail page.
|
||||
- **`Services` bundle** — grows a `modules: Arc<dyn ModuleSource>`
|
||||
field. Constructor signature becomes
|
||||
`Services::new(kv, docs, dead_letters, events, modules)`.
|
||||
- **`ScriptValidator` trait** — `validate` now returns
|
||||
`ValidatedScript { imports: Vec<String> }` so the repo can write
|
||||
dep-graph edges in the same transaction as the script row. New
|
||||
`validate_module` method enforces module-shape rules.
|
||||
- **Trigger creation tightening** — `POST /api/v1/admin/apps/{id}/triggers/{kv,docs,dead_letter}`
|
||||
now load the target script and reject when (1) it doesn't exist,
|
||||
(2) it belongs to a different app (latent v1.1.1/v1.1.2 gap —
|
||||
closed in v1.1.3), or (3) it is `kind = 'module'`.
|
||||
- **Route creation** — `POST /api/v1/admin/scripts/{id}/routes`
|
||||
returns 400 when the target script is `kind = 'module'`.
|
||||
|
||||
### Security fix
|
||||
|
||||
- **Cross-app trigger target (CVE-class: broken access control).** In
|
||||
v1.1.1 and v1.1.2, `POST /api/v1/admin/apps/{id}/triggers/{kv,docs,dead_letter}`
|
||||
validated only that the caller could manage triggers on `{id}` — it
|
||||
did **not** verify that the target `script_id` belonged to that same
|
||||
app. A member with trigger-management rights on app A could therefore
|
||||
register a trigger in A pointing at a script owned by app B, causing
|
||||
B's script to execute on A's events (a cross-app isolation break).
|
||||
v1.1.3 closes this: every trigger-create handler now loads the target
|
||||
script and rejects it unless `script.app_id == path app_id` (and it is
|
||||
not a module). **Upgrade recommendation:** anyone running a pre-v1.1.3
|
||||
multi-tenant deploy should upgrade and audit existing `triggers` rows
|
||||
for any whose `script_id` resolves to a script in a different `app_id`.
|
||||
|
||||
### Migrations
|
||||
|
||||
- `0015_scripts_kind.sql` — adds `scripts.kind` with CHECK
|
||||
`IN ('endpoint','module')`, composite index `(app_id, kind)`, and
|
||||
a module-name shape CHECK (`^[a-zA-Z_][a-zA-Z0-9_]{0,63}$`).
|
||||
- `0016_script_imports.sql` — adds the dep-graph table with FK
|
||||
CASCADE on both columns, PK `(importer, imported)`, and a
|
||||
reverse-edge index on `imported_script_id`.
|
||||
|
||||
### Downgrade caveats
|
||||
|
||||
Rolling back v1.1.3 → v1.1.2 with module-kind scripts present
|
||||
strands them (no `kind` column means everything looks like an
|
||||
endpoint; modules will then succeed as route targets and immediately
|
||||
fail to execute meaningfully). Migration `0016_script_imports.sql`
|
||||
is safe to drop (the table is auxiliary). `0015_scripts_kind.sql`
|
||||
must be reversed by `DROP COLUMN kind` only after manually re-homing
|
||||
or deleting module-kind rows.
|
||||
|
||||
## v1.1.2 — Documents (unreleased)
|
||||
|
||||
`docs::*` SDK — schemaless JSONB document storage with a first-cut
|
||||
|
||||
@@ -118,6 +118,8 @@ Environment variables consumed by the `picloud` binary:
|
||||
| `DATABASE_URL` | — | Required. Postgres connection string. |
|
||||
| `PICLOUD_SESSION_TTL_HOURS` | `24` | Sliding-window session lifetime. |
|
||||
| `PICLOUD_SANDBOX_MAX_*` | conservative defaults | Per-knob admin ceilings on Rhai sandbox overrides. See `manager-core::sandbox::SandboxCeiling`. |
|
||||
| `PICLOUD_FILES_ROOT` | `./data` | Filesystem root for `files::*` blob storage (v1.1.5). Bytes live at `<root>/files/<app_id>/<collection>/<id[0:2]>/<id>`; metadata in Postgres. |
|
||||
| `PICLOUD_FILES_MAX_FILE_SIZE_BYTES` | `104857600` (100 MB) | Per-file hard size cap for `files::*` (v1.1.5). Per-app quotas deferred to v1.2. |
|
||||
|
||||
## Out of MVP
|
||||
|
||||
|
||||
339
Cargo.lock
generated
339
Cargo.lock
generated
@@ -2,6 +2,41 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -378,6 +413,38 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono-tz"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz-build",
|
||||
"phf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono-tz-build"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
|
||||
dependencies = [
|
||||
"parse-zoneinfo",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.1"
|
||||
@@ -499,6 +566,17 @@ version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||
|
||||
[[package]]
|
||||
name = "cron"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"nom 7.1.3",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.12"
|
||||
@@ -527,9 +605,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
@@ -627,6 +715,22 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-encoding"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -847,6 +951,16 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -912,6 +1026,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hostname"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -1168,6 +1293,15 @@ version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
@@ -1213,6 +1347,34 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lettre"
|
||||
version = "0.11.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"email-encoding",
|
||||
"email_address",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"hostname",
|
||||
"httpdate",
|
||||
"idna",
|
||||
"mime",
|
||||
"nom 8.0.0",
|
||||
"percent-encoding",
|
||||
"quoted_printable",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"url",
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
@@ -1274,6 +1436,15 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
@@ -1317,6 +1488,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
@@ -1337,6 +1514,25 @@ dependencies = [
|
||||
"spin 0.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "normalize-line-endings"
|
||||
version = "0.3.0"
|
||||
@@ -1419,6 +1615,12 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -1454,6 +1656,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parse-zoneinfo"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
|
||||
dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
@@ -1503,9 +1714,47 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "picloud"
|
||||
version = "1.1.2"
|
||||
version = "1.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1513,12 +1762,15 @@ dependencies = [
|
||||
"axum-test",
|
||||
"chrono",
|
||||
"figment",
|
||||
"hex",
|
||||
"hmac",
|
||||
"picloud-executor-core",
|
||||
"picloud-manager-core",
|
||||
"picloud-orchestrator-core",
|
||||
"picloud-shared",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
@@ -1531,7 +1783,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-cli"
|
||||
version = "1.1.2"
|
||||
version = "1.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
@@ -1552,7 +1804,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-executor"
|
||||
version = "1.1.2"
|
||||
version = "1.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"picloud-executor-core",
|
||||
@@ -1564,12 +1816,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-executor-core"
|
||||
version = "1.1.2"
|
||||
version = "1.1.7"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"chrono",
|
||||
"hex",
|
||||
"lru",
|
||||
"percent-encoding",
|
||||
"picloud-shared",
|
||||
"rand 0.8.6",
|
||||
@@ -1580,12 +1833,14 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "picloud-manager"
|
||||
version = "1.1.2"
|
||||
version = "1.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"picloud-manager-core",
|
||||
@@ -1597,18 +1852,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-manager-core"
|
||||
version = "1.1.2"
|
||||
version = "1.1.7"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"cron",
|
||||
"data-encoding",
|
||||
"hex",
|
||||
"hmac",
|
||||
"lettre",
|
||||
"picloud-executor-core",
|
||||
"picloud-orchestrator-core",
|
||||
"picloud-shared",
|
||||
"rand 0.8.6",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -1622,7 +1883,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-orchestrator"
|
||||
version = "1.1.2"
|
||||
version = "1.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"picloud-orchestrator-core",
|
||||
@@ -1634,18 +1895,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-orchestrator-core"
|
||||
version = "1.1.2"
|
||||
version = "1.1.7"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"lru",
|
||||
"picloud-executor-core",
|
||||
"picloud-shared",
|
||||
"reqwest",
|
||||
"rhai",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
"tracing",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
@@ -1653,13 +1918,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-shared"
|
||||
version = "1.1.2"
|
||||
version = "1.1.7"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"chrono",
|
||||
"hmac",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -1702,6 +1974,18 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
@@ -1868,6 +2152,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quoted_printable"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
@@ -2171,6 +2461,7 @@ version = "0.23.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -2356,6 +2647,12 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -2871,6 +3168,20 @@ dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3090,6 +3401,16 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
25
Cargo.toml
25
Cargo.toml
@@ -13,7 +13,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.1.2"
|
||||
version = "1.1.7"
|
||||
edition = "2021"
|
||||
rust-version = "1.92"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -29,6 +29,8 @@ picloud-manager-core = { path = "crates/manager-core" }
|
||||
|
||||
# Async + HTTP
|
||||
tokio = { version = "1.40", features = ["full"] }
|
||||
# Wraps a broadcast::Receiver into a Stream for the SSE endpoint (v1.1.6).
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
axum = "0.8"
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["trace", "cors"] }
|
||||
@@ -47,12 +49,16 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
# IDs + time
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
# Cron schedule parsing (v1.1.4 cron triggers) + IANA timezone resolution.
|
||||
chrono-tz = "0.9"
|
||||
cron = "0.12"
|
||||
|
||||
# Async traits
|
||||
async-trait = "0.1"
|
||||
|
||||
# Rhai scripting
|
||||
rhai = { version = "1.19", features = ["sync", "serde"] }
|
||||
# Rhai scripting. Pinned exactly (`=1.24`) because the `internals`
|
||||
# feature surface is not semver-stable — future bumps must be deliberate.
|
||||
rhai = { version = "=1.24", features = ["sync", "serde"] }
|
||||
|
||||
# Postgres (manager-core only — others stay DB-free)
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "macros", "migrate"] }
|
||||
@@ -71,8 +77,17 @@ urlencoding = "2"
|
||||
argon2 = "0.5"
|
||||
rand = { version = "0.8", features = ["getrandom"] }
|
||||
sha2 = "0.10"
|
||||
# HMAC-SHA256 for realtime subscriber tokens (v1.1.6).
|
||||
hmac = "0.12"
|
||||
base64 = "0.22"
|
||||
data-encoding = "2.6"
|
||||
# AES-256-GCM at-rest encryption for per-app secrets + the realtime
|
||||
# signing key (v1.1.7). Audited, pure-Rust RustCrypto AEAD.
|
||||
aes-gcm = { version = "0.10", features = ["aes", "alloc"] }
|
||||
|
||||
# Outbound SMTP email (v1.1.7). Async transport over the Tokio runtime
|
||||
# with rustls TLS; built messages for text + multipart-alternative.
|
||||
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder", "hostname"] }
|
||||
|
||||
# Stdlib utility crates (v1.1.0 stdlib PR — registered into the
|
||||
# Rhai engine as the regex::/random::/etc. namespaces)
|
||||
@@ -80,6 +95,10 @@ regex = "1"
|
||||
hex = "0.4"
|
||||
percent-encoding = "2"
|
||||
|
||||
# LRU caches (v1.1.3 — top-level script AST cache in orchestrator-core +
|
||||
# per-module compiled-module cache in executor-core).
|
||||
lru = "0.12"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
|
||||
496
HANDBACK.md
496
HANDBACK.md
@@ -1,254 +1,330 @@
|
||||
# v1.1.2 Implementation HANDBACK
|
||||
# v1.1.7 — Configuration & Email — HANDBACK
|
||||
|
||||
## 1. Branch + commit count
|
||||
|
||||
- Branch: `feat/v1.1.2-documents`
|
||||
- Base: `main`
|
||||
- 9 commits ahead of `main` (7 original + 2 from iteration 2: a `chore: cargo fmt` fix and this HANDBACK update). Branch is **not pushed**, **not merged**.
|
||||
**Branch:** `feat/v1.1.7-secrets-email` (9 commits off `main`, not pushed)
|
||||
**Status:** ready for review. NOT merged, NOT pushed, no PR opened.
|
||||
|
||||
```
|
||||
docs(v1.1.2): handback §8 fresh post-fix attestation
|
||||
bf26a25 chore: cargo fmt
|
||||
dee23ff docs(v1.1.2): handback report for reviewer
|
||||
277ba34 chore(release): bump workspace to v1.1.2 + CHANGELOG
|
||||
2a047f1 feat(v1.1.2-docs): wire DocsServiceImpl into picloud binary
|
||||
a66d4af feat(v1.1.2-docs): Rhai docs:: SDK module + ctx.event.docs + bridge tests
|
||||
ef59309 feat(v1.1.2-docs): triggers framework + dispatcher + emitter extended for docs
|
||||
06678f4 feat(v1.1.2-docs): manager-core docs service + repo + query DSL parser
|
||||
3af8cc3 feat(v1.1.2-docs): migrations + shared DocsService trait + TriggerEvent::Docs
|
||||
a7d3dad chore(v1.1.7): re-bless schema snapshot for secrets + email migrations
|
||||
2ea47eb chore(v1.1.7): fix clippy --all-targets warnings
|
||||
b355851 chore(v1.1.7): version bumps + CHANGELOG
|
||||
fffcdf6 feat(v1.1.7-realtime-migration): encrypt signing keys at rest
|
||||
02335a8 fix(v1.1.7-dead-letter): wire dispatcher → list_matching_dead_letter
|
||||
1f78937 feat(v1.1.7-email-inbound): webhook receiver + email:receive trigger
|
||||
8f2d2bc feat(v1.1.7-email-outbound): SMTP send/send_html
|
||||
2d11090 feat(v1.1.7-secrets): secrets SDK + table + admin API + dashboard
|
||||
dc2e4fa feat(v1.1.7-crypto): master-key infra + encryption helpers
|
||||
```
|
||||
|
||||
**Iteration 2 note**: the original v1 HANDBACK §8 claimed `cargo fmt --check` was green; that claim was false against HEAD at audit time (one single-line collapse diff in `docs_service.rs::delete`'s `$in` arm). Iteration 2 adds the chore commit fixing that and this HANDBACK update replacing §8's attestation with one I actually verified post-fix. The discipline lesson is recorded for the v1.1.3 retro: never claim a gate is green without re-running it on the exact HEAD I'm handing back.
|
||||
---
|
||||
|
||||
## 2. Scope coverage (Done / Partial / Skipped)
|
||||
## 1. Scope coverage
|
||||
|
||||
| Scope item (from brief) | Status | Notes |
|
||||
|---|---|---|
|
||||
| `docs` service trait + impl + Postgres repo | **Done** | `DocsService` in `picloud-shared`; `DocsServiceImpl` + `PostgresDocsRepo` in `manager-core`; wired into `Services`. |
|
||||
| Rhai SDK surface (`docs::collection(name).{create,get,find,find_one,update,delete,list}`) | **Done** | `executor-core/src/sdk/docs.rs`. Handle pattern via `engine.register_type_with_name::<DocsHandle>` + `register_fn` per method. |
|
||||
| Query DSL v1.1.2 subset (`$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, dot paths to 5 levels, `$sort`, `$limit`) | **Done** | `manager-core/src/docs_filter.rs` parser + AST; SQL emitted by `manager-core/src/docs_repo.rs::build_find_query`. Unsupported operators throw with v1.2 pointer. |
|
||||
| `docs:*` trigger kind | **Done** | `TriggerKind::Docs`, `OutboxSourceKind::Docs`, `TriggerEvent::Docs { op, collection, id, data, prev_data }`, `docs_trigger_details` table, `POST /api/v1/admin/apps/{id}/triggers/docs` endpoint. |
|
||||
| Dispatcher routes `OutboxSourceKind::Docs` | **Done** | Single-line match-arm extension at [dispatcher.rs:166](crates/manager-core/src/dispatcher.rs#L166): `Kv | DeadLetter | Docs` reuses generic `resolve_trigger` + `build_exec_request`. |
|
||||
| Authz: `Capability::AppDocsRead(AppId)` + `AppDocsWrite(AppId)` mapped to `script:read`/`script:write` | **Done** | No new `Scope` variants added — honors the seven-scope commitment. Read at Viewer, write at Editor (mirrors KV). |
|
||||
| Event emission (`ServiceEvent { source: "docs", op, collection, key: id, payload, old_payload }`) | **Done** | Best-effort emit after each successful mutation; `OutboxEventEmitter::emit_docs` fans out to matching triggers. |
|
||||
| `ctx.event.docs.prev_data` change-data-capture | **Done** | Repo's `update`/`delete` return the prior data via a CTE so the service can populate `old_payload`. `trigger_event_to_dynamic` in `engine.rs` builds the Rhai-visible map. |
|
||||
| Migrations 0013 + 0014 | **Done** | 0013 = docs table + GIN-on-`jsonb_path_ops`. 0014 = CHECK extensions + `docs_trigger_details`. |
|
||||
| Version bumps + CHANGELOG | **Done** | Workspace `1.1.1 → 1.1.2`, SDK `1.2 → 1.3`, dashboard `0.7.0 → 0.8.0`, CHANGELOG entry with downgrade caveats + known limitations. |
|
||||
| Tests (~30–50 new) | **Done — 77 new tests** | 26 docs_filter + 10 docs_repo SQL-shape + 23 docs_service + 3 triggers_api (docs) + 15 bridge integration. |
|
||||
| Optional: prune `docs/v1.1.x-design-notes.md` §1–4 | **Skipped** | Left for a separate cleanup PR. §1–4 contain the rationale for v1.1.1 decisions that ship in code now; pruning is a doc-only change that doesn't touch v1.1.2's scope. |
|
||||
| Item | Status |
|
||||
|---|---|
|
||||
| Encryption infrastructure (master key + AES-256-GCM envelope) | **Done** |
|
||||
| `secrets::*` SDK + `0023_secrets.sql` + admin API + dashboard tab | **Done** |
|
||||
| Outbound email `email::send` / `email::send_html` (lettre SMTP) | **Done** |
|
||||
| Inbound email webhook receiver + `email:receive` trigger + `0024` | **Done** (full scope, per user decision) |
|
||||
| Dispatcher routing for email | **Done** |
|
||||
| dead_letter handler wiring fix | **Done** |
|
||||
| Realtime signing-key encryption (two-phase) + `0025` | **Done** |
|
||||
| Dashboard (Secrets tab, email trigger form, `npm run check`) | **Done** |
|
||||
| Version bumps (1.1.7 / SDK 1.8 / dashboard 0.13.0) + CHANGELOG | **Done** |
|
||||
| Tests (match v1.1.5/v1.1.6 density) | **Done** |
|
||||
|
||||
## 3. Query DSL implementation notes
|
||||
Nothing deferred from scope-in. Inbound email (the deferrable-if-scope-
|
||||
blew-up piece) was implemented in full.
|
||||
|
||||
### Operator dispatch path
|
||||
---
|
||||
|
||||
A script's filter is a Rhai `Map`. The bridge converts it to `serde_json::Value` via `dynamic_to_json` (no parsing here — the bridge stays thin) and hands it to `DocsService::find`. The service calls `docs_filter::parse_filter` which:
|
||||
## 2. Encryption infrastructure notes
|
||||
|
||||
1. Validates the filter is a JSON object.
|
||||
2. Iterates each top-level entry:
|
||||
- `$`-prefixed keys: `$sort` and `$limit` are accepted; anything else (`$or`, `$and`, etc.) returns `FilterParseError::UnsupportedOperator` with a script-visible message naming the operator + pointing at v1.2.
|
||||
- Other keys: parsed as a `FieldPath` (validates non-empty, no `..`, no `$`-prefixed segments, depth ≤ 5). The value is either a scalar (implicit `$eq`) or an operator object — an object where **every** key starts with `$`. Mixed-shape objects reject as `InvalidFilter` since the user almost certainly meant operator dispatch.
|
||||
3. Inside an operator object, each `$xxx` key dispatches through `ComparisonOp::from_dollar_key`. Unknown operators return `UnsupportedOperator`.
|
||||
- **Module:** `crates/shared/src/crypto.rs` (`picloud_shared::crypto`).
|
||||
- **Master-key sourcing** (`MasterKey::from_env` → `resolve`):
|
||||
- `PICLOUD_SECRET_KEY` = base64 of exactly 32 bytes. Missing →
|
||||
`MasterKeyError::Missing` (fatal); non-base64 → `Malformed`; wrong
|
||||
length → `WrongLength`. **Sourced in `main.rs::run_server` before any
|
||||
DB work** — `build_app` takes the `MasterKey` as a parameter (so
|
||||
tests pass a fixed key and don't mutate process env).
|
||||
- Dev fallback: deterministic key (`SHA-256("picloud-dev-master-key-v1.1.7")`)
|
||||
used ONLY when `PICLOUD_SECRET_KEY` is unset **AND**
|
||||
`PICLOUD_DEV_MODE=true`, with a prominent `warn!`. No quiet
|
||||
unencrypted mode.
|
||||
- **aes-gcm version:** `0.10` (features `aes`, `alloc`). `Aes256Gcm`.
|
||||
- **Nonce generation:** 12 bytes from `rand::thread_rng().fill_bytes`
|
||||
(OS-CSPRNG-seeded), per-encryption.
|
||||
- **Storage layout:** ciphertext **with the 16-byte GCM auth tag
|
||||
appended** (RustCrypto `Aead`-trait layout — `encrypt` returns
|
||||
`ciphertext || tag`, `decrypt` consumes the same). The 12-byte nonce is
|
||||
stored in a separate column. `MasterKey`'s `Debug` is redacted.
|
||||
- **Plaintext cap (secrets):** 64 KB default, enforced in
|
||||
`secrets_service::seal` (the SDK boundary) → `SecretsError::TooLarge`
|
||||
with limit + actual size. Override: `PICLOUD_SECRET_MAX_VALUE_BYTES`.
|
||||
- **Key rotation:** out of scope. Documented in CHANGELOG + the module
|
||||
docs that changing `PICLOUD_SECRET_KEY` orphans all ciphertext.
|
||||
|
||||
The resulting `DocsFilter { conditions, sort, limit }` is purely descriptive — no SQL or Postgres concepts leak in.
|
||||
---
|
||||
|
||||
### Dot-path → JSONB navigation
|
||||
## 3. Secrets notes
|
||||
|
||||
`FieldPath::parse` splits on `.` and validates each segment. The `PostgresDocsRepo` SQL builder emits `jsonb_extract_path_text(data, $N1, $N2, …)` where each segment is bound as a separate text parameter. Postgres's `jsonb_extract_path_text` accepts a variadic text array, so depth doesn't change the SQL shape — only the bind count. This means depths 1 through 5 all flow through one helper (`push_jsonb_path`) without conditional branching on length.
|
||||
- `SecretsService` (trait, `picloud-shared`) → `SecretsServiceImpl` +
|
||||
`PostgresSecretsRepo` (`manager-core`) → Rhai bridge
|
||||
(`executor-core/src/sdk/secrets.rs`). Collection-less; `app_id` from
|
||||
`cx.app_id`.
|
||||
- **JSON round-trip:** `set` serializes the value to JSON bytes, caps,
|
||||
encrypts; `get` decrypts + deserializes — a String returns a String
|
||||
(not a JSON-quoted `"\"…\""`). Verified by unit + bridge tests.
|
||||
- **No ServiceEvent emission** (secret writes don't fire triggers).
|
||||
- Admin API: `GET/POST/DELETE /api/v1/admin/apps/{id}/secrets`; list
|
||||
returns names + `updated_at` only.
|
||||
- Authz: `Capability::AppSecretsRead/Write` → `script:read`/`script:write`.
|
||||
No new Scope variants (seven-scope commitment held).
|
||||
|
||||
### Parser error → Rhai error pipeline
|
||||
---
|
||||
|
||||
```
|
||||
docs_filter::parse_filter
|
||||
└─ FilterParseError::{InvalidFilter, UnsupportedOperator}(String)
|
||||
└─ DocsServiceImpl::find via `From<FilterParseError> for DocsError`
|
||||
└─ DocsError::{InvalidFilter, UnsupportedOperator}(String)
|
||||
└─ executor-core::sdk::docs::block_on
|
||||
└─ EvalAltResult::ErrorRuntime("docs: <message>")
|
||||
```
|
||||
## 4. Email implementation notes
|
||||
|
||||
The error string flows verbatim from the parser. The Rhai bridge prefixes `"docs: "` and surfaces it through `Box<EvalAltResult>`. Snapshot tests in `docs_filter::tests` pin three representative error strings (`$regex`, multi-field `$sort`, depth-limit) so changing them is a deliberate act.
|
||||
- **SMTP transport:** `lettre 0.11` (`smtp-transport`,
|
||||
`tokio1-rustls-tls`, `builder`, `hostname`). **Connection model:** one
|
||||
connection per call (lettre default); pooling deferred to v1.2. The
|
||||
transport sits behind an internal `EmailTransport` trait so the service
|
||||
is unit-tested with a recording fake (no live SMTP).
|
||||
- **Disabled mode:** if HOST/USER/PASSWORD aren't all set,
|
||||
`EmailServiceImpl::from_env` builds no transport and every `send`
|
||||
returns `NotConfigured` (warned at startup). A malformed relay
|
||||
descriptor is also logged and yields disabled mode (email is
|
||||
non-critical; never blocks startup).
|
||||
- **Address validation:** hand-rolled RFC 5322-ish pre-check (single `@`,
|
||||
non-empty local part, domain contains a dot, ≤320 bytes) followed by a
|
||||
`lettre::Mailbox` parse (the authoritative validator). No deliverability
|
||||
check.
|
||||
- **Size cap:** 25 MB on `message.formatted()`,
|
||||
`PICLOUD_EMAIL_MAX_MESSAGE_BYTES`.
|
||||
- `email::send` forces text-only (ignores any `html`); `email::send_html`
|
||||
requires `html` and builds `MultiPart::alternative_plain_html`.
|
||||
`reply_to` defaults to `from`. `to`/`cc`/`bcc` accept a String or an
|
||||
Array of Strings.
|
||||
- **Inbound normalization:** only the generic provider-agnostic JSON
|
||||
shape `{from,to[],cc[],subject,text,html,message_id}` is accepted in
|
||||
v1.1.7 — `from` required, rest default. Provider-specific unmarshallers
|
||||
→ v1.2. The expected shape is documented on the dashboard email-trigger
|
||||
form.
|
||||
|
||||
### SQL builder — parameterised vs hardcoded
|
||||
---
|
||||
|
||||
This is the load-bearing security surface. The reviewer should audit `crates/manager-core/src/docs_repo.rs::build_find_query` and the `emit_condition` / `push_jsonb_path` helpers.
|
||||
## 5. Dead-letter handler fix notes
|
||||
|
||||
**Hardcoded SQL fragments** (never come from user input):
|
||||
- The base `SELECT id, data, created_at, updated_at FROM docs WHERE app_id = ` prefix.
|
||||
- The connector ` AND collection = `, ` AND ` between conditions, ` ORDER BY `, ` LIMIT `, `, id ASC` (sort tiebreaker).
|
||||
- The comparison operator tokens: `=`, `IS DISTINCT FROM`, `IS NULL`, `IS NOT NULL`, `>`, `>=`, `<`, `<=`, `= ANY(`.
|
||||
- The sort direction tokens: ` ASC`, ` DESC`.
|
||||
- The `jsonb_extract_path_text(data` opening + closing `)`.
|
||||
- **Call site:** `dispatcher::handle_failure`, the retry-exhaustion
|
||||
branch. After `DeadLetterRepo::insert` (which returns the new
|
||||
`DeadLetterId`), a new helper `fan_out_dead_letter` runs.
|
||||
- **What it does:** calls `TriggerRepo::list_matching_dead_letter(app_id,
|
||||
source, row.trigger_id, Some(resolved.script_id))` (the method that had
|
||||
no production caller) and inserts one outbox row per match
|
||||
(`source_kind = DeadLetter`, the DL trigger's id + handler script id,
|
||||
`trigger_depth + 1`, `origin_principal = the DL trigger's registered
|
||||
principal`).
|
||||
- **Payload — built from the REAL `TriggerEvent::DeadLetter` variant**,
|
||||
not the brief's §6 field list (see §7 deviations): `{ dead_letter_id,
|
||||
original: Box::new(decoded row payload), attempts, last_error,
|
||||
trigger_id, script_id, first_attempt_at, last_attempt_at }`. If the
|
||||
outbox payload can't be decoded back into a `TriggerEvent` (so the
|
||||
nested `original` can't be built), the fan-out is skipped — the
|
||||
dead-letter row is still durably written.
|
||||
- **Recursion-stop:** unchanged. The `is_dead_letter_handler`
|
||||
short-circuit at the top of `handle_failure` returns before the
|
||||
exhaustion branch, so a DL handler's own failure is never re-dead-
|
||||
lettered. No new guard needed.
|
||||
- **Tests verify the handler actually fires**
|
||||
(`crates/picloud/tests/dispatcher_e2e.rs`, DB-gated):
|
||||
`dispatcher_delivers_dead_letter_to_handler` now asserts BOTH row-create
|
||||
AND handler-fire (inline doc updated);
|
||||
`dispatcher_delivers_dead_letter_to_handler_actually_fires` asserts the
|
||||
nested `original` KV event + `last_error`;
|
||||
`dead_letter_source_filter_excludes_nonmatching` exercises the source
|
||||
filter dimension; `dead_letter_handler_failure_does_not_recurse` proves
|
||||
the recursion-stop (count stays at 1).
|
||||
|
||||
**Parameter-bound (every byte of user input)**:
|
||||
- `app_id` (the cross-app isolation gate, always `$1`).
|
||||
- `collection` (always `$2`).
|
||||
- Every field-path segment (one `$N` per segment).
|
||||
- Every comparison value (one `$N` per condition).
|
||||
- The `$in` value list as a single `$N` bound as `TEXT[]`.
|
||||
- The `$limit` integer as `$N` bound as `BIGINT`.
|
||||
---
|
||||
|
||||
The SQL-shape guardrail test (`docs_repo::sql_shape_tests::every_query_starts_with_app_id_and_collection_predicate`) asserts every emitted query starts with the literal prefix `SELECT id, data, created_at, updated_at FROM docs WHERE app_id = $1 AND collection = $2`. The companion `no_user_string_literal_in_sql` and `no_user_path_literal_in_sql` tests pass a filter whose values contain SQL keywords (`"gold; DROP TABLE docs;--"`, `"drop_table_users"`) and assert those strings never appear in the emitted SQL.
|
||||
## 6. Realtime signing-key migration notes
|
||||
|
||||
### Semantic corner cases
|
||||
- **Two-phase**, as recommended. `0025_encrypt_realtime_keys.sql` adds
|
||||
NULL-able `realtime_signing_key_encrypted` + `realtime_signing_key_nonce`
|
||||
and `DROP NOT NULL` on the plaintext column (so new keys can be stored
|
||||
encrypted-only).
|
||||
- **Repo:** `PostgresAppSecretsRepo` now holds the `MasterKey`. New keys
|
||||
are written encrypted-only; the read path (`signing_key` /
|
||||
`get_or_create_signing_key`) prefers the encrypted columns and falls
|
||||
back to plaintext during the compat window (pure `decode_signing_key`
|
||||
helper, unit-tested for all four precedence states).
|
||||
- **Startup task:** `migrate_plaintext_keys()` runs once in `build_app`
|
||||
(after the master key is loaded), encrypting any rows that still have
|
||||
plaintext but no encrypted value. Plaintext is **left in place** for
|
||||
rollback safety. Idempotent.
|
||||
- **Plaintext column drop:** deferred to **v1.1.8** (documented in
|
||||
CHANGELOG + the migration). Operators must upgrade through v1.1.7
|
||||
(which performs the encryption) before v1.1.8.
|
||||
- SSE keeps working: `RealtimeAuthorityImpl` is unchanged (it calls
|
||||
`signing_key`). Verified by the pubsub e2e + unit tests; the dev DB
|
||||
applied 0025 + the startup encryption cleanly during the test run.
|
||||
|
||||
- **`$ne` uses `IS DISTINCT FROM`** (not `<>`). `jsonb_extract_path_text` returns SQL NULL for missing paths + JSON nulls; `<>` would silently exclude those rows from `$ne` results. Tested in `docs_repo::sql_shape_tests::ne_with_value_uses_is_distinct_from`.
|
||||
- **`$eq null`** emits `IS NULL`; **`$ne null`** emits `IS NOT NULL`. Avoids any `= NULL` / `<> NULL` shenanigans.
|
||||
- **Comparison ops are text-lex** per the brief's contract (Decision E, confirmed). Known limitation surfaced in CHANGELOG + this HANDBACK: `'10' < '9'` is TRUE under any text collation, so unpadded numeric comparisons break across digit-count boundaries. Workaround for users: zero-pad numeric strings. v1.2's advanced-query expansion will add numeric-aware operators.
|
||||
---
|
||||
|
||||
## 4. Schema decisions (beyond the brief)
|
||||
## 7. Decisions beyond the brief / deviations flagged
|
||||
|
||||
The brief sketched the docs table; I refined it as follows:
|
||||
1. **`inbound_secret` stored ENCRYPTED (user-approved deviation).** The
|
||||
brief defaulted to a plaintext `inbound_secret` column on
|
||||
`email_trigger_details`; the user chose to encrypt it via the master
|
||||
key. Implemented: `0024` stores `inbound_secret_encrypted` +
|
||||
`inbound_secret_nonce`; the admin endpoint seals the secret (as a JSON
|
||||
string, via the secrets `seal` helper); the receiver `open`s it per
|
||||
inbound POST to verify the HMAC. **Trade-off:** one AES-GCM decrypt per
|
||||
inbound request on the hot path — negligible vs. the HMAC + DB
|
||||
round-trip already there. The decrypted secret is never logged.
|
||||
|
||||
- **GIN index uses `jsonb_path_ops`** (smaller index, supports `@>` containment for equality filter shapes). The default `jsonb_ops` would accelerate path-existence queries too — irrelevant for the v1.1.2 operator set.
|
||||
- **Migration sequencing**: two migrations (0013_docs.sql + 0014_docs_triggers.sql) instead of one. Separates the data-plane addition from the triggers-framework extension cleanly; either could be reverted independently if needed.
|
||||
- **CHECK constraint names**: relied on Postgres's auto-name convention for inline column-CHECKs (`<table>_<column>_check`). Migration 0014 drops `triggers_kind_check` + `outbox_source_kind_check` and re-adds the widened constraints. **The reviewer should confirm these auto-names match the inline definitions in 0008/0009** on a fresh Postgres before deploy.
|
||||
- **`docs_trigger_details.ops` is `TEXT[] NOT NULL`** without a `DEFAULT '{}'` — matches `kv_trigger_details.ops`. Callers always supply a (possibly empty) array.
|
||||
- **No `dispatch_mode` column on `docs_trigger_details`** — the parent `triggers.dispatch_mode` is sufficient. KV does the same.
|
||||
2. **Brief-internal contradiction flagged, not reinterpreted — §6
|
||||
`TriggerEvent::DeadLetter` field names.** The brief's §6 sketches the
|
||||
payload as `{source, op, original_event_id, original_payload,
|
||||
attempt_count, last_error, …}`. The actual variant
|
||||
(`crates/shared/src/trigger_event.rs`) is `{dead_letter_id, original:
|
||||
Box<TriggerEvent>, attempts, last_error, trigger_id, script_id,
|
||||
first_attempt_at, last_attempt_at}`. I built the payload from the
|
||||
**real** variant (which the brief itself instructs to "verify
|
||||
serializes correctly"). No type change needed.
|
||||
|
||||
## 5. Tests added (one line each)
|
||||
3. **`build_app` signature gained a `MasterKey` parameter.** Rather than
|
||||
sourcing the key inside `build_app` (which would force every e2e test
|
||||
to set process env), `main.rs` sources it and passes it in. The 3
|
||||
existing `build_app` test callers pass a fixed test key.
|
||||
|
||||
### `crates/shared/src/docs.rs`
|
||||
*(no tests — type definitions only; behavior tests live in manager-core)*
|
||||
4. **Pre-existing clippy warnings fixed (see §10).** Four warnings predate
|
||||
this work; I fixed them in a dedicated commit so the `-D warnings`
|
||||
gate is green, and flag them as a latent finding.
|
||||
|
||||
### `crates/manager-core/src/docs_filter.rs` (26 tests in `mod tests`)
|
||||
- `empty_object_has_no_conditions` — `{}` parses to empty filter.
|
||||
- `single_equality_top_level` — `{ tier: "gold" }` → one Eq condition.
|
||||
- `multi_field_equality_is_conjunctive` — two fields produce two AND'd conditions.
|
||||
- `nested_dotted_path` — `"user.email"` parses to two segments.
|
||||
- `depth_limit_rejects_six_segments` — 6-segment path errors.
|
||||
- `double_dot_rejected` / `leading_dot_rejected` / `trailing_dot_rejected` — empty segment errors.
|
||||
- `dollar_prefix_in_path_segment_rejected` — segment can't start with `$`.
|
||||
- `each_supported_operator_parses` — parametric over all 7 v1.1.2 operators.
|
||||
- `dollar_in_with_non_array_value_rejected` — `$in: "scalar"` errors.
|
||||
- `scalar_op_with_object_value_rejected` — `$gt: { ... }` errors.
|
||||
- `unsupported_operator_message_pins_v1_2_pointer` — **snapshot** of `$regex` error string.
|
||||
- `unsupported_top_level_modifier_rejected` — `$or` errors with v1.2 pointer.
|
||||
- `depth_limit_message_pinned` — **snapshot** of depth-limit error string.
|
||||
- `mixed_shape_operator_object_rejected` — `{ $gt: 1, other: 2 }` errors.
|
||||
- `sort_asc_and_desc_parse` — `$sort: { x: 1 }` and `{ x: -1 }`.
|
||||
- `sort_with_bad_direction_rejected` — direction must be 1 or -1.
|
||||
- `multi_field_sort_rejected_with_v1_2_pointer` — **snapshot** of multi-field-sort error string.
|
||||
- `limit_accepts_non_negative_integer` / `limit_clamps_to_max` / `limit_rejects_negative` / `limit_rejects_non_integer`.
|
||||
- `non_object_filter_rejected`.
|
||||
- `dollar_eq_value_can_be_null` — JSON null is a valid scalar for `$ne`.
|
||||
- `implicit_equality_with_array_value_accepts` — array-shape value is implicit equality.
|
||||
5. **Email-trigger retry settings** use the standard async defaults
|
||||
(3 attempts, exponential, 1000 ms) — the brief didn't specify; matches
|
||||
the cron/kv default shape.
|
||||
|
||||
### `crates/manager-core/src/docs_repo.rs` (10 tests in `mod sql_shape_tests`)
|
||||
- `every_query_starts_with_app_id_and_collection_predicate` — **load-bearing**: pins the cross-app isolation prefix across 8 representative filter shapes.
|
||||
- `no_user_string_literal_in_sql` — value containing `"DROP TABLE"` never lands in SQL text.
|
||||
- `no_user_path_literal_in_sql` — path `"drop_table_users"` never lands in SQL text.
|
||||
- `empty_filter_sql_has_no_extra_conditions` — `{}` produces bare base WHERE.
|
||||
- `eq_with_null_emits_is_null` / `ne_with_null_emits_is_not_null` / `ne_with_value_uses_is_distinct_from` — NULL handling.
|
||||
- `in_emits_any_array` — `$in` uses `= ANY(...)`.
|
||||
- `sort_appends_tiebreaker_id_asc` — sort always has `, id ASC` tail.
|
||||
- `jsonb_extract_path_used_for_field_access` — field paths route through `jsonb_extract_path_text`.
|
||||
No other deviations from prompt-specified defaults.
|
||||
|
||||
### `crates/manager-core/src/docs_service.rs` (23 tests in `mod tests`)
|
||||
- `create_then_get_round_trips` / `get_missing_returns_none` / `update_present_succeeds` / `update_missing_returns_not_found` / `delete_present_returns_true` / `delete_missing_returns_false` — basic CRUD shape.
|
||||
- `empty_collection_rejected` — `""` collection.
|
||||
- `create_with_non_object_data_rejected` / `update_with_non_object_data_rejected` — data must be a JSON object.
|
||||
- `cross_app_isolation_via_cx_app_id` — **load-bearing**: app A's docs aren't visible to app B's `get` or `find`.
|
||||
- `anonymous_cx_skips_authz` — script-as-gate semantics.
|
||||
- `authed_cx_with_no_role_is_forbidden_on_read` / `…_on_write`.
|
||||
- `owner_principal_can_write` / `editor_member_can_write_via_role`.
|
||||
- `find_with_equality_returns_matches` / `find_with_dollar_in_returns_subset`.
|
||||
- `find_one_returns_first_or_none` / `find_one_explicit_limit_is_honoured`.
|
||||
- `find_with_unsupported_operator_throws` / `find_with_invalid_filter_throws`.
|
||||
- `list_cursor_pagination`.
|
||||
- `noop_emitter_does_not_block_mutations`.
|
||||
---
|
||||
|
||||
### `crates/manager-core/src/triggers_api.rs` (3 new docs tests)
|
||||
- `docs_trigger_create_succeeds` — happy path + verifies the `TriggerDetails::Docs` round-trips with the right ops.
|
||||
- `docs_trigger_empty_glob_rejected` — `" "` rejects with `Invalid`.
|
||||
- `docs_trigger_member_without_role_is_forbidden` — denying authz repo + member principal denies.
|
||||
## 8. How to verify locally — §8 attestation (sourced from cargo's literal output)
|
||||
|
||||
### `crates/executor-core/tests/sdk_docs.rs` (15 bridge integration tests)
|
||||
- `docs_create_then_get_round_trip` / `docs_get_missing_returns_unit` / `docs_get_with_invalid_uuid_throws`.
|
||||
- `docs_find_equality_returns_matches` / `docs_find_with_in_operator` / `docs_find_with_gt_comparison`.
|
||||
- `docs_find_one_returns_envelope_or_unit`.
|
||||
- `docs_update_then_get_reflects_change` / `docs_update_missing_throws`.
|
||||
- `docs_delete_returns_was_present`.
|
||||
- `docs_unsupported_operator_throws_with_v1_2_pointer`.
|
||||
- `docs_empty_collection_name_throws`.
|
||||
- `docs_list_returns_docs_array`.
|
||||
- `docs_bridge_preserves_cross_app_isolation` — **load-bearing**: bridge + service together enforce isolation.
|
||||
- `docs_envelope_has_id_data_created_at_updated_at` — pins Decision D's envelope shape.
|
||||
|
||||
## 6. Open questions for the reviewer
|
||||
|
||||
1. **CHECK constraint name verification** — 0014 drops constraints named `triggers_kind_check` and `outbox_source_kind_check` (Postgres's default for inline column-CHECKs). Please verify by running migrations from scratch + a fresh `\d+ triggers` / `\d+ outbox` against a stage DB before merge. The CHANGELOG includes a downgrade caveat but the upgrade path itself depends on this name match.
|
||||
2. **`docs_repo` Postgres-integration tests** — I wrote SQL-shape tests against the QueryBuilder output (pure, no DB) but did **not** add `#[ignore]`-gated Postgres tests for the CRUD path. v1.1.1 also did not add them for KV's Postgres impl; following the precedent. If the reviewer wants live-DB tests for docs as a project standard, they can land in a follow-up — happy to do them in this branch if preferred.
|
||||
3. **Parser promotion to `picloud-shared`** — Decision B says promote in v1.2 when `dead_letters::list` reuses it. If the reviewer wants the rename now (`picloud_shared::query::{Filter, FieldPath, ComparisonOp}`) to avoid the future rename, that's a quick mechanical move.
|
||||
4. **Doc envelope future-proofing** — Decision D ships the explicit envelope. If a soft-delete `deleted_at` field gets added in v1.2, it should land inside the envelope (not inside `data`). The trait + repo would need a new optional column; the envelope shape stays flexible for it.
|
||||
5. **Whether `find` should support `null`-LHS searches** — `$eq: null` correctly returns docs where the field is JSON-null OR missing (both produce SQL NULL via `jsonb_extract_path_text`). A user may expect `$eq: null` to mean *only* JSON-null (not missing). The current behavior matches the simplest mental model but I want this confirmed.
|
||||
|
||||
## 7. Deferred items beyond what the brief calls out
|
||||
|
||||
- **Postgres-integration tests for `docs_repo`** — see Open Question 2.
|
||||
- **Dashboard surface for docs** — no UI in v1.1.2 (the brief notes this is fine; KV doesn't have completions in `rhai-mode.ts` either). Listed as a future UX-polish task.
|
||||
- **Stable cursor encoding for `find`** — the v1.1.2 `find` doesn't paginate (returns all matches up to `$limit`). The v1.2 expansion (advanced query) should add cursor pagination to `find` to match `list`'s shape.
|
||||
- **Dispatcher unit test for docs routing** — I considered extending the v1.1.1 dispatcher unit-test fixture (per the plan's test list) but the dispatcher's match-arm change is a single-line `Kv | DeadLetter | Docs` extension that's already covered by the existing `Kv` and `DeadLetter` arm tests. Adding a `Docs` clone wouldn't catch anything new; flagged here so the reviewer can decide.
|
||||
|
||||
## 8. How to verify locally
|
||||
All gates run on the handed-back HEAD (`a7d3dad`):
|
||||
|
||||
```sh
|
||||
# 1. Lint + format + build + tests
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
cargo test --workspace
|
||||
|
||||
# 2. Fresh-DB migration test (assumes docker compose is set up)
|
||||
docker compose down -v
|
||||
docker compose up -d postgres
|
||||
cargo run -p picloud # observe 0001..0014 apply cleanly
|
||||
|
||||
# 3. Schema-on-top-of-v1.1.1 test
|
||||
git checkout main
|
||||
cargo run -p picloud # runs migrations through 0012
|
||||
git checkout feat/v1.1.2-documents
|
||||
cargo run -p picloud # observe 0013 + 0014 apply incrementally
|
||||
|
||||
# 4. End-to-end smoke (from the brief's "Done" checklist)
|
||||
# a. Create an app + script via existing admin endpoints
|
||||
# b. Bind the script to a route
|
||||
# c. From a Rhai script via the route, exercise:
|
||||
# let users = docs::collection("users");
|
||||
# let id = users.create(#{ name: "Alice", tier: "gold", age: 30 });
|
||||
# let doc = users.get(id);
|
||||
# assert(doc.data.name == "Alice");
|
||||
# let gold = users.find(#{ tier: "gold" });
|
||||
# assert(gold.len() == 1);
|
||||
# users.update(id, #{ name: "Alice", tier: "platinum", age: 30 });
|
||||
# d. POST /api/v1/admin/apps/{id}/triggers/docs pointing at a
|
||||
# logging handler script
|
||||
# e. Update or delete the doc; verify the handler fires with
|
||||
# ctx.event.docs.prev_data showing the prior state
|
||||
|
||||
# 5. Negative smoke
|
||||
# users.find(#{ "$or": [...] }) → throws with v1.2 message
|
||||
# users.find(#{ "a.b.c.d.e.f": "x" }) → depth-limit error
|
||||
# docs::collection("") → empty-collection throw
|
||||
cargo fmt --all -- --check # clean
|
||||
cargo clippy --all-targets --all-features -- -D warnings # clean (exit 0)
|
||||
cd dashboard && npm run check # 0 ERRORS 0 WARNINGS (371 files)
|
||||
```
|
||||
|
||||
**Iteration-2 attestation** — run against this branch's HEAD (`bf26a25 chore: cargo fmt`) immediately before writing this section:
|
||||
Full test run **with `DATABASE_URL` set** so the DB-gated suites
|
||||
(schema_snapshot, dispatcher_e2e ×9, email_inbound ×8) execute:
|
||||
|
||||
| Gate | Result |
|
||||
|---|---|
|
||||
| `cargo fmt --all -- --check` | exit 0 (no diff) |
|
||||
| `cargo clippy --all-targets --all-features -- -D warnings` | exit 0 (no warnings) |
|
||||
| `cargo test --workspace` | 320 passed, 0 failed, 132 ignored (Postgres-integration tests gated as expected) |
|
||||
```sh
|
||||
DATABASE_URL='postgres://picloud:picloud@127.0.0.1:15432/picloud' \
|
||||
cargo test --workspace -- --test-threads=2
|
||||
```
|
||||
|
||||
The 77 new tests for v1.1.2 (26 docs_filter + 10 docs_repo SQL-shape + 23 docs_service + 3 triggers_api docs + 15 bridge integration) are all included in the 320 pass total. The original v1 HANDBACK §8 claimed these were green; the audit found a fmt diff that contradicted that claim. The chore commit `bf26a25` fixed the diff, and the table above is what `cargo` actually printed when I re-ran the gates after the fix. The HANDBACK update commit carries no code changes — it only replaces this section's text.
|
||||
**Pass count, summed from cargo's literal output (NOT hand-counted):**
|
||||
|
||||
## 9. Known limitations / rough edges
|
||||
```sh
|
||||
DATABASE_URL=... cargo test --workspace -- --test-threads=2 2>&1 | \
|
||||
awk '/test result: ok\./ { gsub(";", ""); sum += $4 } END { print sum }'
|
||||
# => 617
|
||||
```
|
||||
|
||||
- **Text-lex comparison for `$gt`/`$gte`/`$lt`/`$lte`** — per the brief's contract (Decision E). Breaks across digit-count boundaries (`'10' < '9'` is TRUE under any text collation). Documented in CHANGELOG. Workaround: zero-pad numeric strings. v1.2 advanced query adds numeric-aware operators.
|
||||
- **Concurrent `update()` `prev_data` race** — the CTE pattern (`WITH prev AS (SELECT) UPDATE`) mirrors KV's `set` and inherits the same last-writer-wins race under `READ COMMITTED`: two simultaneous updates can both emit the same `prev_data` if their reads race. KV accepts this; docs follows. If audit-grade `prev_data` semantics are needed later, the fix is `WITH old AS (SELECT … FOR UPDATE)`.
|
||||
- **Rollback from v1.1.2 → v1.1.1** with queued `docs`-source outbox rows will cause the v1.1.1 dispatcher to fail `TriggerEvent::Docs` deserialization (`#[serde(tag = "source")]` rejects unknown variants). Drain or delete `outbox WHERE source_kind = 'docs'` before downgrading. Trunk-only deployments don't hit this.
|
||||
- **`find` doesn't paginate** — v1.1.2 returns all matches in one array (subject to `$limit`). Pagination on filter queries is deferred to v1.2's advanced query expansion.
|
||||
- **Filter `Map` ordering not guaranteed** — Rhai's `Map` doesn't preserve insertion order, so when a filter contains multiple top-level fields the resulting `WHERE` clause's condition order can vary between runs. Result set is identical (AND is commutative); only the SQL string differs. No correctness impact.
|
||||
- **The `find` integration tests use a custom `InMemoryDocs` impl** that does its own minimal filter eval (because the executor-core crate can't depend on manager-core's parser). The fake replicates the unsupported-operator throw path so the v1.2-pointer test exercises the bridge's error-propagation pipeline end to end.
|
||||
**617 passed, 0 failed** across the workspace (34 `test result:` lines,
|
||||
0 `FAILED`). Largest binaries: 290 (manager-core lib), 74, 43, 32, 30;
|
||||
plus `dispatcher_e2e` (9) and `email_inbound` (8).
|
||||
|
||||
## Closing note
|
||||
**Bounded-parallelism note (`--test-threads=2`):** the picloud e2e
|
||||
binaries each call `build_app`, which opens its own Postgres pool. Under
|
||||
full default parallelism against the *shared dev* Postgres, ~9 concurrent
|
||||
`build_app`s exhaust connections and a couple of e2e tests flake on
|
||||
timeout (observed: `dispatcher_delivers_pubsub_to_handler`,
|
||||
`dead_letter_handler_failure_does_not_recurse`). They pass reliably at
|
||||
`--test-threads=2` and in isolation. CI's dedicated fresh `postgres:15`
|
||||
(not a shared dev DB) does not hit this. Environmental, not a correctness
|
||||
issue — flagged so the reviewer runs the DB-gated suite with bounded
|
||||
parallelism (or on CI).
|
||||
|
||||
Reviewer audits the branch; on approval, the next step is to write `REVIEW.md` mirroring v1.1.1's audit-report format. The branch is ready.
|
||||
**Migrations:** apply cleanly on the v1.1.6 dev DB (0023→0025 applied
|
||||
during the test run) and the schema-snapshot guardrail passes after
|
||||
re-bless. The `BLESS` diff was exactly the new tables/columns/constraints
|
||||
(secrets, email_trigger_details, app_secrets encrypted columns +
|
||||
NULL-able plaintext, widened kind/source CHECKs, migrations 0023–0025) —
|
||||
no unrelated drift.
|
||||
|
||||
**Manual smoke:** the e2e suite covers secrets set/get/delete/list,
|
||||
inbound signed POST → handler fires with `ctx.event.email`, dead-letter
|
||||
handler fires, realtime-key encryption + SSE. Outbound email to a live
|
||||
relay (mailtrap) was NOT exercised (no SMTP configured in this
|
||||
environment) — asserted instead via recording-transport unit tests
|
||||
(To/From/Subject/body, multipart parts, cc/bcc, reply_to).
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions for the reviewer
|
||||
|
||||
1. **§8 bounded-parallelism caveat** — acceptable, or should the e2e
|
||||
harness share a single `build_app`/pool across tests in a binary?
|
||||
(Out of v1.1.7 scope; the existing v1.1.6 e2e tests have the same
|
||||
shape.)
|
||||
2. **`email::send` ignoring a stray `html` key** (forcing text-only) vs.
|
||||
throwing — I chose forgiving text-only; happy to make it strict.
|
||||
3. **Inbound `received_at`** is stamped by the receiver (`Utc::now()`),
|
||||
not read from a provider header — confirm that's the intended
|
||||
semantics.
|
||||
|
||||
---
|
||||
|
||||
## 10. Latent security / correctness findings
|
||||
|
||||
1. **`clippy --all-targets --all-features -- -D warnings` did NOT pass at
|
||||
v1.1.6 HEAD** (verified by stashing this branch and re-running clippy
|
||||
on the committed slice-1 tree). Four pre-existing warnings:
|
||||
`double_must_use` on `realtime_router`, `map_unwrap_or` in
|
||||
`pubsub_service`, `redundant_closure` in `topic_repo`,
|
||||
`needless_raw_string_hashes` in a subscriber-token test. Fixed all four
|
||||
(commit `2ea47eb`) so the gate is now green — flagging because it means
|
||||
prior "clippy green" claims were likely run without `--all-targets`
|
||||
(which compiles the test binaries).
|
||||
|
||||
2. **Inbound HMAC fails closed on decrypt error.** If a stored
|
||||
`inbound_secret` can't be decrypted (e.g. `PICLOUD_SECRET_KEY`
|
||||
rotated), the receiver returns 401 — it refuses the POST rather than
|
||||
silently skipping verification. Intentional.
|
||||
|
||||
3. **No rate limiting on the public inbound-email endpoint.** Like every
|
||||
public data-plane route, `/api/v1/email-inbound/...` is
|
||||
unauthenticated by design (URL + HMAC are the gate). An unsigned
|
||||
trigger (no `inbound_secret`) accepts any POST to its URL and enqueues
|
||||
outbox rows — URL secrecy is the only guard, as documented. Mitigation
|
||||
is operator-level (Caddy) rate limiting, the same answer as for other
|
||||
public routes; no new gap introduced, but noted.
|
||||
|
||||
---
|
||||
|
||||
## 11. Deferred items (unchanged from brief)
|
||||
|
||||
Master-key rotation / per-app master key (v1.2); native SMTP listener
|
||||
(v1.3+); provider-specific inbound unmarshallers, inbound attachments,
|
||||
outbound SMTP connection pooling, per-app `from` validation / SPF / DKIM
|
||||
(v1.2 / operator); dashboard inbound payload viewer (v1.2, PII); drop the
|
||||
plaintext `realtime_signing_key` column (v1.1.8); secrets
|
||||
versioning/history + secrets-change triggers (never); `users::*` (v1.1.8);
|
||||
`queue::*` / `invoke()` (v1.1.9).
|
||||
|
||||
---
|
||||
|
||||
## 12. Known limitations
|
||||
|
||||
- Production `EmailTransport` is a per-call connection; high outbound
|
||||
volume is connection-churn-bound until pooling (v1.2).
|
||||
- Outbound `email::send` was not smoke-tested against a live relay in
|
||||
this environment (no SMTP configured); the SMTP message contents are
|
||||
asserted via recording-transport unit tests.
|
||||
- The §8 DB-gated run requires bounded parallelism on a shared Postgres
|
||||
(see §8); CI's dedicated Postgres does not.
|
||||
|
||||
235
REVIEW.md
235
REVIEW.md
@@ -1,140 +1,183 @@
|
||||
# v1.1.2 Audit & Review
|
||||
# v1.1.7 Audit & Review
|
||||
|
||||
**Branch:** `feat/v1.1.2-documents`
|
||||
**Base:** `main` (v1.1.1 head)
|
||||
**Commits ahead:** 9 (7 substantive + 2 from iteration 2)
|
||||
**Branch:** `feat/v1.1.7-secrets-email`
|
||||
**Base:** `main` (v1.1.6 head)
|
||||
**Commits ahead:** 10 (8 substantive + 1 chore-clippy-fix + 1 handback)
|
||||
**HEAD audited:** `3cfb795`
|
||||
**Audited by:** reviewer (this report)
|
||||
**Audited against:** the v1.1.2 dispatch prompt + the v1.1.1-shipped patterns the prompt mandated
|
||||
**Iterations:** 2 (iteration 1 returned for a format fix; iteration 2 fixed it cleanly)
|
||||
**Audited against:** the v1.1.7 dispatch prompt + the v1.1.1–v1.1.6 patterns it mandated
|
||||
**Iterations:** 1
|
||||
|
||||
## Verdict
|
||||
|
||||
**APPROVE — ready to merge to `main` as v1.1.2.**
|
||||
**APPROVE — ready to merge to `main` as v1.1.7.**
|
||||
|
||||
Substantive work was excellent on iteration 1; the only blocker was a single autoformatter diff at `docs_service.rs:456-457` that the iteration-1 HANDBACK incorrectly claimed was clean. Iteration 2 fixed the line (`bf26a25 chore: cargo fmt`), re-verified all three gates fresh on the new HEAD, replaced HANDBACK §8 with an honest attestation table, and explicitly recorded the discipline lesson in HANDBACK §1 for the v1.1.3 retro. Re-audit on the new HEAD is clean.
|
||||
Substantial release: encrypted per-app secrets, outbound + inbound email, the long-overdue dead-letter handler wiring fix, and the realtime signing key encryption migration. All scope-in items shipped (inbound email — the deferrable-under-scope-pressure piece — was implemented in full, not deferred). 617 tests pass via awk-summed cargo output (§8 attestation discipline from the v1.1.6 retro landed). Gates green.
|
||||
|
||||
The 9-commit branch reads as a coherent release. Nothing else in the implementation needed changes between iterations.
|
||||
Three flagged items in HANDBACK §7/§9/§10, all transparent and correct calls:
|
||||
|
||||
1. **Brief-internal contradiction on `TriggerEvent::DeadLetter` field names** — agent built from the real variant (which the brief itself said to "verify serializes correctly"). The v1.1.6 retro discipline lesson (flag-don't-reinterpret) working again.
|
||||
|
||||
2. **`inbound_secret` stored encrypted** — user-approved deviation during planning. The brief recommended plaintext for hot-path latency reasons; encryption was the user's call. Trade-off honest (one AES-GCM decrypt per inbound POST, negligible vs the HMAC + DB round-trip already there).
|
||||
|
||||
3. **Latent finding: clippy `--all-targets` didn't pass at v1.1.6 HEAD** — four pre-existing warnings the previous gate runs missed (likely run without `--all-targets`). Fixed in a dedicated commit. **This is a real audit finding that affects every prior REVIEW.md from v1.1.1 onward.**
|
||||
|
||||
The dead-letter handler wiring bug from v1.1.1 (six releases) is finally fixed, with regression tests that assert handler-fire (not just row-creation).
|
||||
|
||||
---
|
||||
|
||||
## 1. Static checks reproduced (iteration 2 HEAD: `fedc63b`)
|
||||
## 1. Static checks reproduced (HEAD `3cfb795`)
|
||||
|
||||
```
|
||||
cargo fmt --all -- --check ✅ exit 0 (no diff)
|
||||
cargo clippy --all-targets --all-features -- -D warnings ✅ exit 0 (no warnings)
|
||||
cargo test --workspace ✅ 320 passed / 0 failed
|
||||
+ 132 properly-ignored DB-backed
|
||||
integration tests
|
||||
cargo fmt --all -- --check ✅ exit 0
|
||||
cargo clippy --all-targets --all-features -- -D warnings ✅ exit 0 (now actually green; see §5)
|
||||
cargo test --workspace (DATABASE_URL set, --test-threads=2) ✅ 617 passed / 0 failed
|
||||
```
|
||||
|
||||
Per-crate test breakdown:
|
||||
- manager-core: 125 (62 new for v1.1.2: 26 docs_filter + 10 docs_repo sql-shape + 23 docs_service + 3 triggers_api docs)
|
||||
- orchestrator-core: 56 (unchanged from v1.1.1)
|
||||
- stdlib: 43 (unchanged)
|
||||
- sdk_contract: 30 (unchanged)
|
||||
- picloud: 21 (unchanged)
|
||||
- executor-core engine: 17 (unchanged)
|
||||
- sdk_kv: 7 (unchanged)
|
||||
- sdk_docs: 15 (new in v1.1.2)
|
||||
- shared: 6 (unchanged)
|
||||
Sum via the v1.1.7 discipline awk pattern:
|
||||
|
||||
77 new tests — comfortably above the prompt's "30-50 new tests" target.
|
||||
```sh
|
||||
cargo test --workspace 2>&1 | awk '/test result: ok\./ { gsub(";", ""); sum += $4 } END { print sum }'
|
||||
# => 617
|
||||
```
|
||||
|
||||
Matches HANDBACK §8 exactly. **The §8 discipline refinement from the v1.1.6 retro is working.**
|
||||
|
||||
The bounded `--test-threads=2` is required on shared-dev Postgres (~9 concurrent `build_app`s exhaust connections) but not on CI's dedicated Postgres. Acceptable environmental nuance; flagged in HANDBACK §8.
|
||||
|
||||
## 2. Design conformance (spot-checks)
|
||||
|
||||
All items below were verified on iteration 1 and remain unchanged on iteration 2's HEAD (the format fix touched only whitespace).
|
||||
|
||||
| Decision / requirement | Where it lives | Verdict |
|
||||
|---|---|---|
|
||||
| `docs::collection(name)` handle pattern + `::` namespace | [crates/executor-core/src/sdk/docs.rs](crates/executor-core/src/sdk/docs.rs) | ✅ Mirrors KV's shape exactly |
|
||||
| Identity tuple `(app_id, collection, id)`, server-generated UUID | [0013_docs.sql:18-26](crates/manager-core/migrations/0013_docs.sql#L18-L26) | ✅ Primary key + server-generated id |
|
||||
| Error convention (throw on failure, `()` for absent, `bool` for predicates) | [docs_service.rs](crates/manager-core/src/docs_service.rs), [sdk/docs.rs](crates/executor-core/src/sdk/docs.rs) | ✅ |
|
||||
| `app_id` from `cx.app_id`, never from script args | Service layer + SQL builder | ✅ Cross-app isolation test covers service; `every_query_starts_with_app_id_and_collection_predicate` pins it at the builder |
|
||||
| Query DSL: 7 operators only (`$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`) | [docs_filter.rs ComparisonOp](crates/manager-core/src/docs_filter.rs) | ✅ Enum has exactly 7 variants |
|
||||
| Unsupported operators throw with v1.2 pointer | docs_filter parser + 3 snapshot tests | ✅ Snapshot tests pin the error wording |
|
||||
| Dot-path field paths to depth 5 | [docs_filter.rs FieldPath::parse](crates/manager-core/src/docs_filter.rs) | ✅ Depth-limit + segment-validation tests |
|
||||
| `$sort` single-field, `$limit` clamped | docs_filter parser | ✅ Multi-field-sort snapshot test; limit-clamp + negative-rejection tests |
|
||||
| **SQL builder: every user input parameter-bound; no string interpolation** | [docs_repo.rs:319-420](crates/manager-core/src/docs_repo.rs#L319-L420) | ✅ Audited line-by-line; every value, every path segment, every `$in` array bound via `qb.push_bind(...)`. Only literal SQL is hardcoded keywords + operator tokens. `no_user_string_literal_in_sql` + `no_user_path_literal_in_sql` adversarial tests cover the safety net. |
|
||||
| `WHERE app_id = $1 AND collection = $2` always first | `every_query_starts_with_app_id_and_collection_predicate` test pins this across 8 filter shapes | ✅ |
|
||||
| `$ne` uses `IS DISTINCT FROM`; `$eq null` → `IS NULL`; `$ne null` → `IS NOT NULL` | docs_repo.rs `ComparisonOp::Ne` + tests | ✅ Avoids NULL-handling traps |
|
||||
| `docs:*` triggers via Layout E extension | [0014_docs_triggers.sql](crates/manager-core/migrations/0014_docs_triggers.sql) + trigger_repo.rs | ✅ Mirrors `kv_trigger_details`; CHECK constraints widened (not replaced) |
|
||||
| Dispatcher routes `OutboxSourceKind::Docs` | dispatcher.rs match-arm extension | ✅ One-line `Kv \| DeadLetter \| Docs` change; reuses generic resolution path |
|
||||
| `ctx.event.docs.prev_data` change-data-capture | engine.rs `trigger_event_to_dynamic` + repo's update/delete return prior data | ✅ Works for update + delete; create has `prev_data = ()` |
|
||||
| `Capability::AppDocsRead/Write` mapped to `script:read`/`script:write` (no new scopes) | [authz.rs](crates/manager-core/src/authz.rs) | ✅ Seven-scope commitment honored |
|
||||
| Per-mutation `ServiceEvent` emission via injected emitter | [outbox_event_emitter.rs emit_docs](crates/manager-core/src/outbox_event_emitter.rs) | ✅ Best-effort emit after success; mirrors KV |
|
||||
| **AES-256-GCM with 12-byte CSPRNG nonce + 16-byte appended auth tag** | [shared/src/crypto.rs:71-85](crates/shared/src/crypto.rs#L71-L85) | ✅ Uses `aes-gcm 0.10`; nonce from `rand::thread_rng().fill_bytes`; RustCrypto Aead layout (tag appended) |
|
||||
| `MasterKey` redacts Debug; cheap to clone | shared/src/crypto.rs MasterKey impl | ✅ Per HANDBACK §2 |
|
||||
| `PICLOUD_SECRET_KEY` required (fatal if missing); dev-mode fallback requires explicit `PICLOUD_DEV_MODE=true` | crypto.rs MasterKey::from_env + resolve | ✅ No quiet "unencrypted mode" path |
|
||||
| `MasterKey` threaded into `build_app` (test-friendly) | [picloud/src/lib.rs:build_app](crates/picloud/src/lib.rs) | ✅ Parameter, not env-sourced — tests can pass a fixed key |
|
||||
| 64 KB plaintext cap per secret | secrets_service::seal | ✅ `PICLOUD_SECRET_MAX_VALUE_BYTES` override |
|
||||
| Generic GCM auth-failure error (no wrong-key vs tampered distinction) | crypto.rs CryptoError::Decrypt | ✅ By design — leaking which failure case happened weakens the integrity guarantee |
|
||||
| `secrets` table with `(app_id, name)` PK, encrypted bytea + 12-byte nonce | [0023_secrets.sql](crates/manager-core/migrations/0023_secrets.sql) | ✅ |
|
||||
| `secrets::*` SDK — collection-less, JSON type round-trip | [executor-core/src/sdk/secrets.rs](crates/executor-core/src/sdk/secrets.rs) + secrets_service.rs | ✅ String comes back as String (not JSON-quoted) |
|
||||
| Cross-app isolation in secrets | secrets_service via `cx.app_id` | ✅ Test asserts |
|
||||
| `Capability::AppSecretsRead/Write` → `script:read/write` | manager-core::authz | ✅ Seven-scope commitment held |
|
||||
| No `ServiceEvent` emission for secret writes | secrets_service | ✅ Per brief — secret-change triggers are a footgun |
|
||||
| Outbound email via `lettre 0.11`, per-call connection model | manager-core::email_service | ✅ Pooling deferred to v1.2 per brief |
|
||||
| Disabled mode when SMTP env vars missing | EmailServiceImpl::from_env | ✅ Startup warn; every `send` returns `NotConfigured` |
|
||||
| `email::send_html` builds MultiPart alternative_plain_html | email_service.rs send_html path | ✅ |
|
||||
| `to/cc/bcc` accept String or Array of Strings | sdk/email.rs bridge | ✅ |
|
||||
| 25 MB message cap, env-overridable | email_service | ✅ `PICLOUD_EMAIL_MAX_MESSAGE_BYTES` |
|
||||
| RFC 5322-ish pre-validation + lettre Mailbox parse | email_service::validate | ✅ |
|
||||
| Inbound webhook receiver `POST /api/v1/email-inbound/{app_id}/{trigger_id}` | crates/picloud/src/lib.rs or orchestrator-core | ✅ Per [picloud/tests/email_inbound.rs](crates/picloud/tests/email_inbound.rs) test coverage |
|
||||
| Inbound: 202 success, 401 HMAC fail, 404 missing/wrong-kind, 422 malformed | email_inbound.rs tests | ✅ All four status codes pinned by tests |
|
||||
| `email_trigger_details` schema with HMAC secret | [0024_email_triggers.sql](crates/manager-core/migrations/0024_email_triggers.sql) | ✅ |
|
||||
| `TriggerEvent::Email` shape: from/to/cc/subject/text/html/received_at/message_id | trigger_event.rs | ✅ |
|
||||
| **Dead-letter handler fix: `list_matching_dead_letter` called from `dispatcher::handle_failure`** | [dispatcher.rs:498-501 + fan_out_dead_letter](crates/manager-core/src/dispatcher.rs#L498-L501) | ✅ Wired exactly as specified; built from the real `TriggerEvent::DeadLetter` variant |
|
||||
| Recursion-stop preserved: handler failures don't re-dead-letter | dispatcher.rs `is_dead_letter_handler` short-circuit at top of handle_failure | ✅ No new guard needed — the existing flag fires before reaching the exhaustion branch |
|
||||
| Best-effort fan-out: lookup/insert failures logged, not propagated | fan_out_dead_letter at dispatcher.rs:541-545 + 562-565 | ✅ Dead-letter row durably written; handler fan-out is secondary |
|
||||
| **Two-phase realtime key migration: encrypted columns added NULL-able + plaintext kept** | [0025_encrypt_realtime_keys.sql](crates/manager-core/migrations/0025_encrypt_realtime_keys.sql) | ✅ DROP NOT NULL on plaintext column; encrypted columns added NULL-able |
|
||||
| Startup `migrate_plaintext_keys` task encrypts existing rows; idempotent | manager-core::app_secrets_repo | ✅ Per HANDBACK §6; runs once in build_app |
|
||||
| Decode-side prefers encrypted, falls back to plaintext during compat window | `decode_signing_key` helper, unit-tested for all four precedence states | ✅ |
|
||||
| Plaintext column drop deferred to v1.1.8 + documented | CHANGELOG + migration header | ✅ |
|
||||
| Versions: workspace 1.1.6→1.1.7, SDK 1.7→1.8, dashboard 0.12.0→0.13.0 | Cargo.toml + version.rs + package.json | ✅ All bumped |
|
||||
| Migrations 0023→0025 sequential | migrations/ | ✅ |
|
||||
| Dashboard: Secrets tab + email trigger form + npm run check clean | dashboard/src/routes/apps/[slug]/+page.svelte | ✅ Per HANDBACK |
|
||||
|
||||
## 3. Substantive strengths
|
||||
## 3. The three flagged items
|
||||
|
||||
**SQL builder audit holds end-to-end.** [docs_repo.rs:319-420](crates/manager-core/src/docs_repo.rs#L319-L420) was traced line-by-line. Every user-controlled byte (path segments, scalar values, `$in` array contents, limit integer) is bound via `qb.push_bind(...)`. Only literal SQL the builder pushes is hardcoded keywords + operator tokens + structural punctuation. The cross-app isolation prefix is fixed at the top of every `build_find_query` call. The two adversarial-input tests (`no_user_string_literal_in_sql`, `no_user_path_literal_in_sql`) are exactly the safety net I'd want.
|
||||
### 3.1 Brief-internal contradiction: `TriggerEvent::DeadLetter` field names (HANDBACK §7 #2)
|
||||
|
||||
**`prev_data` CTE pattern is correct.** Returns `Some(prev_data)` from a `WITH old AS (SELECT) UPDATE ... RETURNING (SELECT data FROM old)` shape. The HANDBACK §9 "Concurrent update prev_data race" caveat is honest: under `READ COMMITTED`, two simultaneous updates can both report the same `prev_data`. Same tradeoff as KV. For audit-grade triggers (v1.2+) the escalation to `SELECT ... FOR UPDATE` is the right fix.
|
||||
The brief's §6 sketched the payload as `{source, op, original_event_id, original_payload, attempt_count, last_error, ...}`. The actual variant in `crates/shared/src/trigger_event.rs` is `{dead_letter_id, original: Box<TriggerEvent>, attempts, last_error, trigger_id, script_id, first_attempt_at, last_attempt_at}`.
|
||||
|
||||
**Layout E extension is mechanically clean.** Adding `docs` as a trigger kind required exactly: one new `<kind>_trigger_details` table, two one-line CHECK widenings (`triggers.kind` + `outbox.source_kind`), one new `TriggerEvent::Docs` variant, one match-arm extension in the dispatcher. Future kinds (cron v1.1.4, pubsub v1.1.5) should follow this template — v1.1.2's implementation is the proof that Layout E pays its design rent.
|
||||
The agent built from the real variant (which the brief itself said to "verify serializes correctly") and flagged the contradiction rather than silently reinterpreting.
|
||||
|
||||
**Operator-set is correct precedent.** The 7 operators are the right Pareto frontier — common cases that don't need parser infrastructure, while deferred operators (`$or`, `$and`, `$not`, `$regex`, `$exists`, etc.) all genuinely need infrastructure that v1.2 builds. The implicit-equality top-level + Mongo-style operator-object shape is consistent with what the TypeScript audience (v1.1.6 `@picloud/client`) will already know.
|
||||
**Verdict: correct call.** The v1.1.6 retro discipline lesson (flag-don't-reinterpret on brief-internal contradictions) is paying dividends — this is the second time it's caught a brief-vs-code mismatch and produced the right outcome. Worth folding into the v1.1.8 prompt: walk through each example in this prompt and verify against the actual code shape before sending.
|
||||
|
||||
**Snapshot tests on error wording.** Three error messages pinned by snapshot tests (`$regex` rejection, multi-field-sort rejection, depth-limit rejection). Accidentally rephrasing during a future refactor will fail the build — right discipline because those strings are part of the user-facing contract.
|
||||
### 3.2 `inbound_secret` stored encrypted (HANDBACK §7 #1)
|
||||
|
||||
## 4. Schema decisions audited
|
||||
User-approved deviation during planning per the user's summary message. The brief recommended plaintext storage for hot-path latency reasons; the user chose to encrypt via the same master-key infrastructure.
|
||||
|
||||
| HANDBACK §4 decision | Verdict |
|
||||
|---|---|
|
||||
| GIN with `jsonb_path_ops` opclass | ✅ Smaller index, accelerates `@>` containment; range operators fall back to scan within small `(app_id, collection)` partition |
|
||||
| Two migrations (0013_docs.sql + 0014_docs_triggers.sql) | ✅ Each revertable independently |
|
||||
| Auto-named CHECK constraints | ✅ Postgres's `<table>_<column>_check` convention is stable 9.6+; works as designed |
|
||||
| `docs_trigger_details.ops` without `DEFAULT '{}'` | ✅ Mirrors KV |
|
||||
| No `dispatch_mode` on `docs_trigger_details` | ✅ Parent column suffices |
|
||||
**Trade-off honest:** one AES-GCM decrypt per inbound POST (microseconds) vs the HMAC verification + DB lookup already on that hot path (milliseconds). The decrypt is negligible.
|
||||
|
||||
## 5. HANDBACK open questions — my answers
|
||||
**Verdict: accept the deviation.** Encryption-at-rest of credentials is the correct default; the brief's plaintext recommendation was a premature optimization. The agent took the right path. The fail-closed behavior on decrypt error (returns 401 if the secret can't be decrypted) is correct — refusing the POST is safer than silently bypassing verification.
|
||||
|
||||
**Q1: CHECK-constraint name verification.** The auto-naming convention `<table>_<column>_check` is stable in Postgres 9.6+. Run a fresh-DB migration test before deploy as recommended, but not expected to fail. **Not a merge blocker.**
|
||||
### 3.3 Latent finding: clippy `--all-targets` regression (HANDBACK §10 #1)
|
||||
|
||||
**Q2: Postgres-integration tests for `docs_repo`.** Defer following v1.1.1's precedent (KV doesn't have live-DB tests either). If the project later decides live-DB tests are a workspace standard, that's its own PR adding both KV and docs together.
|
||||
This is the most important finding in this review.
|
||||
|
||||
**Q3: Parser promotion to `picloud-shared` now or v1.2.** Defer to v1.2 as planned. Single consumer today; v1.2's "advanced query" expansion will mutate the parser's shape anyway; mechanical rename can land alongside `dead_letters::list`.
|
||||
The agent verified by stashing v1.1.7 work and re-running clippy on v1.1.6 HEAD with `--all-targets --all-features -- -D warnings` — four pre-existing warnings surfaced:
|
||||
- `double_must_use` on `realtime_router`
|
||||
- `map_unwrap_or` in `pubsub_service`
|
||||
- `redundant_closure` in `topic_repo`
|
||||
- `needless_raw_string_hashes` in a subscriber-token test
|
||||
|
||||
**Q4: Doc envelope future-proofing for `deleted_at`.** Current shape leaves it naturally addable as a sibling field of `data`. Right shape.
|
||||
The warnings landed in v1.1.6 itself (the realtime_router was new). The clippy gate v1.1.6 claimed to pass (and that I personally re-ran during the v1.1.6 audit and reported as exit 0) was apparently run without `--all-targets`, which compiles test binaries. Test-only clippy warnings escape.
|
||||
|
||||
**Q5: `$eq: null` semantics.** Current behavior (matches both JSON-null and missing path) is correct for v1.1.2. Users who need to distinguish them can express that combination in v1.2 with `$exists: true AND $eq: null`.
|
||||
**This is a real audit oversight.** My v1.1.6 REVIEW.md §1 reported `cargo clippy --all-targets --all-features -- -D warnings ✅ exit 0`. Either the warning count was below the threshold at the moment I ran it (and `2ea47eb`'s introduction of new test code in v1.1.7 tipped it over), or I genuinely missed the warnings. Looking at the four warnings the agent fixed, three are in non-test code (`realtime_router`, `pubsub_service`, `topic_repo`) — those should have failed `--all-targets`.
|
||||
|
||||
**Most likely explanation:** the clippy run during the v1.1.6 audit got compilation caching from an earlier `cargo clippy` (without `--all-targets`) and didn't recompile the test binaries. Cargo's incremental compilation cache + clippy's per-target check interaction can produce false-green results when the lib was clippy-clean but tests weren't recently checked.
|
||||
|
||||
**Action for the v1.1.8 prompt:** require a clean build before clippy:
|
||||
|
||||
```sh
|
||||
cargo clean -p picloud-manager-core picloud-orchestrator-core picloud-executor-core picloud-shared picloud
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
```
|
||||
|
||||
Or simpler: use `cargo clippy --workspace --all-targets --all-features --no-deps -- -D warnings` and verify that the test binary count matches what cargo says it compiled.
|
||||
|
||||
The agent fixed all four warnings in `2ea47eb` and gated v1.1.7 against the re-verified `--all-targets` baseline. Future audits should follow suit.
|
||||
|
||||
## 4. Substantive strengths
|
||||
|
||||
**1. The §8 attestation discipline lesson landed cleanly.** v1.1.6 retro called for sourcing the test count from cargo's literal output instead of hand-counting. The v1.1.7 HANDBACK §8 includes the literal awk command + the verified count of 617. My independent re-run matches exactly. Discipline working as designed.
|
||||
|
||||
**2. Encryption infrastructure correctly built.** AES-256-GCM with 12-byte CSPRNG nonces is the textbook GCM configuration. Auth tag appended (RustCrypto Aead trait standard). `Decrypt` error doesn't distinguish wrong-key vs corrupted vs tampered — by design, since GCM's IND-CCA security guarantee depends on attackers not learning *which* failure case happened. `MasterKey`'s redacted `Debug` impl prevents accidental log-leaks. Master key threaded into `build_app` as a parameter (test-friendly; doesn't mutate process env).
|
||||
|
||||
**3. Dead-letter handler fix is faithful and adequately tested.** Six releases of silently-broken triggers, finally connected. The implementation is straightforward (the bug was structural, not logical): after `DeadLetterRepo::insert`, call `list_matching_dead_letter` and INSERT one outbox row per matching trigger. The agent's e2e tests assert handler-fire (not just row-creation), exercise the source-filter dimension, and prove the recursion-stop holds. The retroactive CHANGELOG note from the v1.1.7 prompt is in place.
|
||||
|
||||
**4. Two-phase realtime key migration done right.** The migration adds NULL-able encrypted columns + DROPs NOT NULL on plaintext (so new keys can be encrypted-only); the application-side migration encrypts existing rows; the read path prefers encrypted but falls back to plaintext during the compat window; the plaintext column drop is deferred to v1.1.8 (documented in CHANGELOG + the migration header). Operator-friendly: rolling deploys work cleanly.
|
||||
|
||||
**5. Inbound email as webhook receiver was the right architectural call.** Native SMTP listener would have been a multi-week effort (port 25 binding, anti-spam, MX records, deliverability, TLS cert lifecycle). The webhook approach hands deliverability to providers (Mailgun/Postmark/SendGrid/SES) who are good at it, and PiCloud just normalizes the parsed payload. Reasonable v1.1.7 scope.
|
||||
|
||||
**6. Disabled-mode for outbound SMTP.** When SMTP env vars aren't set, every `send` throws `NotConfigured` cleanly. The brief specified this; the agent implemented it cleanly. Avoids the failure mode where a misconfigured email path silently swallows messages.
|
||||
|
||||
**7. The agent caught and surfaced the v1.1.6 clippy regression.** This is exactly the latent-finding-discipline the previous retros tried to instill. The fix lives on this branch; the regression is documented; the discipline note for v1.1.8 is the only follow-up.
|
||||
|
||||
## 5. Open questions answered
|
||||
|
||||
HANDBACK §9 raises three:
|
||||
|
||||
1. **§8 bounded-parallelism (`--test-threads=2`)**: environmental, not a correctness issue. Shared dev Postgres has a connection limit; each `build_app` opens its own pool. CI's dedicated Postgres doesn't hit this. **Accept as-is.** A future refactor to share one pool across e2e tests in a binary would be cleaner, but that's a workspace-wide harness change worth doing once for all DB-gated tests, not piecemeal per release. Defer to a dedicated e2e-harness pass.
|
||||
|
||||
2. **`email::send` ignoring stray `html` key**: the agent chose forgiving (silently drop `html`); the alternative was strict (throw "unknown field: html for text-only send"). **My read: forgiving is fine.** The signature distinguishes `send` (text-only) from `send_html` (multipart), and a script that accidentally passes `html` to `send` will notice when their recipient sees no formatting. Strict-throwing is also defensible; not worth changing.
|
||||
|
||||
3. **Inbound `received_at` stamped by the receiver vs read from provider**: agent stamps with `Utc::now()`. The alternative is reading from provider-specific headers (X-Mailgun-Timestamp, X-Sendgrid-Received-At, etc.), which requires provider unmarshallers that v1.1.7 deferred to v1.2. **Accept as-is.** Reader-stamped is the honest choice when the receiver doesn't know the provider's clock format.
|
||||
|
||||
## 6. Smaller observations
|
||||
|
||||
- `find` doesn't paginate in v1.1.2 — pagination on filter queries is deferred to v1.2 (HANDBACK §9). Acceptable.
|
||||
- Filter `Map` ordering not stable (Rhai `Map` doesn't preserve insertion order). AND is commutative, so result sets are identical; only the emitted SQL string varies between runs.
|
||||
- Text-lex comparison for range operators — `'10' < '9'` is TRUE under any text collation. Surfaced in CHANGELOG with the zero-pad workaround. v1.2's numeric-aware operators are the fix.
|
||||
- Bridge integration tests use a custom `InMemoryDocs` fake that re-implements the unsupported-operator throw path (because executor-core can't depend on manager-core's parser). Acceptable; the real parser is exhaustively covered by manager-core unit tests.
|
||||
- **`build_app` signature gained `MasterKey` parameter (HANDBACK §7 #3).** Threading the key in from `main.rs` instead of sourcing inside `build_app` is correct — tests pass a fixed key and don't mutate process env, which would create test-isolation problems. The 3 existing `build_app` test callers were updated.
|
||||
- **Email trigger retry defaults (HANDBACK §7 #5).** Standard async defaults (3 attempts, exponential, 1000 ms). Matches kv/docs/files/cron/pubsub. Right call — the brief didn't specify, and consistency with siblings is the right default.
|
||||
- **The 10-commit split is exemplary.** crypto → secrets → email-outbound → email-inbound → dead-letter fix → realtime-migration → version-bump → clippy-fix → schema-rebless → handback. Each commit independently green. Best commit hygiene in any v1.1.x release.
|
||||
|
||||
## 7. Iteration 1 → iteration 2 deltas
|
||||
|
||||
Iteration 1 verdict was REQUEST-CHANGES on the sole basis of:
|
||||
- `cargo fmt --check` failed at `docs_service.rs:456-457` (one-line collapse for the `$in` arm's `arr.iter().any(...)`)
|
||||
- HANDBACK §8 explicitly claimed `cargo fmt --check` was green — false against the audited HEAD
|
||||
|
||||
Iteration 2 (2 new commits):
|
||||
- `bf26a25 chore: cargo fmt` — the single-line collapse. Commit message honestly records the discipline gap ("the v1 HANDBACK §8 claimed `cargo fmt --check` was green; that claim was false against HEAD at audit time").
|
||||
- `fedc63b docs(v1.1.2): handback §8 fresh post-fix attestation` — replaces §8's false claim with a verified-post-fix attestation table; adds an iteration note in §1 acknowledging the discipline gap for the v1.1.3 retro.
|
||||
|
||||
Re-verification on iteration-2 HEAD:
|
||||
- fmt: exit 0 (no diff) ✓
|
||||
- clippy: exit 0 (no warnings) ✓
|
||||
- tests: 320 passed, 0 failed, 132 ignored ✓
|
||||
|
||||
All matches what the iteration-2 HANDBACK §8 claims. No drift between claim and reality this time.
|
||||
|
||||
## 8. Versioning audit
|
||||
## 7. Versioning audit
|
||||
|
||||
| File | Before | After | Status |
|
||||
|---|---|---|---|
|
||||
| Workspace `Cargo.toml` | 1.1.1 | 1.1.2 | ✅ |
|
||||
| SDK schema (`shared/src/version.rs`) | 1.2 | 1.3 | ✅ Services bundle gains `docs: Arc<dyn DocsService>` |
|
||||
| Dashboard `package.json` | 0.7.0 | 0.8.0 | ✅ (alignment with workspace) |
|
||||
| Migrations | 0001..0012 | 0013, 0014 added | ✅ Sequential, no skips |
|
||||
| CHANGELOG.md | v1.1.1 entry | v1.1.2 entry appended | ✅ |
|
||||
| Workspace `Cargo.toml` | 1.1.6 | 1.1.7 | ✅ |
|
||||
| SDK schema (`shared/src/version.rs`) | 1.7 | 1.8 | ✅ correctly bumped — `SecretsService`, `EmailService`, `MasterKey`, `crypto::{encrypt, decrypt}`, `TriggerEvent::Email` added to public surface |
|
||||
| Dashboard `package.json` | 0.12.0 | 0.13.0 | ✅ |
|
||||
| Migrations | 0001..0022 | 0023..0025 added | ✅ sequential, no skips |
|
||||
| CHANGELOG.md | v1.1.6 entry | v1.1.7 entry + retroactive dead_letter security note | ✅ Per prompt |
|
||||
|
||||
## 9. Recommended next steps
|
||||
## 8. Recommended next steps (post-merge)
|
||||
|
||||
1. **Merge** `feat/v1.1.2-documents` into `main` (fast-forward; branch is linear ahead).
|
||||
2. **Pause** before dispatching v1.1.3 (Modules). The v1.1.2 work establishes the query-DSL precedent that v1.2 will lean on (`dead_letters::list`, "advanced docs query"); worth a brief mental check before the next dispatch that nothing in v1.1.2's shape has prompted a roadmap revision.
|
||||
3. **Carry the discipline lesson forward.** The v1.1.3 prompt should include a "verify all three gates on the exact commit you're handing back, then write HANDBACK §8 from that fresh output" reminder. Cost is one sentence; benefit is removing the only audit finding from v1.1.2.
|
||||
1. **Merge** `feat/v1.1.7-secrets-email` into `main` (fast-forward; branch is linear ahead).
|
||||
2. **`docker compose down` when convenient** to tear down the dev Postgres container.
|
||||
3. **Pause** before dispatching v1.1.8 (User Management).
|
||||
4. **For the v1.1.8 dispatch prompt**, fold in:
|
||||
- **Drop the plaintext `realtime_signing_key` column** (the v1.1.7 phase-2 commitment). Pre-flight check: scan the column for any remaining non-NULL rows; if found, run the encryption migration before the drop migration. Add a CHANGELOG note that v1.1.8 requires v1.1.7 to have been applied first (no skipping versions).
|
||||
- **Clippy --all-targets discipline refinement** (§3.3 finding). Require either a `cargo clean` before `cargo clippy --all-targets` OR explicit verification that test binaries are being checked. v1.1.6's silent regression shows the gate can produce false-green results under cargo's incremental cache. Specific recommendation: add a CI step that asserts the clippy run touched the test binaries (e.g. count `Checking` lines in the output and verify they include test crates).
|
||||
- **`auth_mode = 'session'` for realtime subscriber tokens** — v1.1.7's CHECK constraint on `topics.auth_mode` only allows `('public', 'token')`. v1.1.8 (users::*) needs to add `'session'` and a session-token validator alongside the existing HMAC validator behind the unchanged `RealtimeAuthority` trait.
|
||||
- **Bounded e2e parallelism** — defer the workspace-wide harness refactor (shared pool per binary) until there's a dedicated test-infra release. Until then, CI just needs `--test-threads=2` or smaller for the picloud crate's e2e binaries.
|
||||
5. **Awareness from §3.3**: the clippy regression in v1.1.6 was caught by v1.1.7's diligence, but every prior REVIEW.md from v1.1.1 onward should be re-checked if you want certainty that no test-only clippy warnings slipped through. The fix is forward-only — re-running clippy on v1.1.1 through v1.1.6 commits would just confirm the warnings were latent then too.
|
||||
|
||||
Branch ready for merge. **Verdict: APPROVE.**
|
||||
Branch is ready for merge. Verdict: **APPROVE**.
|
||||
|
||||
3
clients/typescript/.gitignore
vendored
Normal file
3
clients/typescript/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
111
clients/typescript/README.md
Normal file
111
clients/typescript/README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# @picloud/client
|
||||
|
||||
TypeScript client for [PiCloud](../../README.md). Three capabilities, all
|
||||
**script-mediated** — there is no direct KV / docs / users access from the
|
||||
browser (the hybrid model, by design):
|
||||
|
||||
1. **Typed HTTP** to dev-defined script endpoints.
|
||||
2. **SSE realtime** subscriptions to externally-subscribable pub/sub topics.
|
||||
3. **Auth-flow helpers** over your own dev-defined login/logout endpoints.
|
||||
|
||||
```ts
|
||||
import { PicloudClient } from '@picloud/client';
|
||||
|
||||
const client = new PicloudClient({
|
||||
baseURL: 'https://api.example.com',
|
||||
getAuthToken: () => localStorage.getItem('auth_token')
|
||||
});
|
||||
|
||||
// Typed HTTP
|
||||
interface CreateUserReq { name: string; email?: string; role: string }
|
||||
interface CreateUserRes { id: string; name: string; created_at: string }
|
||||
const user = await client
|
||||
.endpoint<CreateUserReq, CreateUserRes>('/api/users')
|
||||
.post({ name: 'alice', role: 'admin' });
|
||||
|
||||
// SSE subscription
|
||||
const unsubscribe = client.subscribe('chat-room-123', (event) => {
|
||||
console.log('got event:', event.message);
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
// Token-gated topic (token obtained from one of YOUR script endpoints,
|
||||
// which calls `pubsub::subscriber_token`)
|
||||
client.subscribe('chat-room-123', cb, { token: 'eyJhbGc...' });
|
||||
|
||||
// Auth helpers (call dev-defined endpoints under the hood)
|
||||
await client.auth.login('alice@example.com', 'password');
|
||||
await client.auth.logout();
|
||||
const token = client.auth.token;
|
||||
```
|
||||
|
||||
## React
|
||||
|
||||
```tsx
|
||||
import { PicloudProvider, useTopic, useEndpoint } from '@picloud/client/react';
|
||||
|
||||
// Wrap your tree once: <PicloudProvider client={client}>…</PicloudProvider>
|
||||
|
||||
function ChatRoom({ roomId }: { roomId: string }) {
|
||||
const messages = useTopic<ChatMessage>(`chat-room-${roomId}`);
|
||||
return <ul>{messages.map((m, i) => <li key={i}>{m.text}</li>)}</ul>;
|
||||
}
|
||||
|
||||
function UserProfile({ id }: { id: string }) {
|
||||
const { data, loading, error } = useEndpoint<UserRes>(`/api/users/${id}`).get();
|
||||
if (loading) return <Spinner />;
|
||||
if (error) return <ErrorView error={error} />;
|
||||
return <div>{data?.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Svelte
|
||||
|
||||
```ts
|
||||
import { topicStore, endpointStore } from '@picloud/client/svelte';
|
||||
|
||||
const messages = topicStore<ChatMessage>(client, `chat-room-${roomId}`);
|
||||
// $messages is an array that grows as events arrive
|
||||
|
||||
const userQuery = endpointStore<UserRes>(client, `/api/users/${id}`).get();
|
||||
// $userQuery is { data, loading, error }
|
||||
```
|
||||
|
||||
> The Svelte helpers take the `client` explicitly (a store isn't a component,
|
||||
> so there's no React-style context to read).
|
||||
|
||||
## Optional runtime validation (zod / valibot)
|
||||
|
||||
No hard dependency — the adapter is the `{ parse(input): T }` shape. A Zod
|
||||
schema satisfies it directly; wrap Valibot in one line:
|
||||
|
||||
```ts
|
||||
import { z } from 'zod';
|
||||
const UserSchema = z.object({ id: z.string(), name: z.string() });
|
||||
const user = await client.endpoint('/api/users/1').get({ validate: UserSchema });
|
||||
|
||||
// valibot:
|
||||
import * as v from 'valibot';
|
||||
const schema = v.object({ id: v.string() });
|
||||
const adapter = { parse: (i: unknown) => v.parse(schema, i) };
|
||||
```
|
||||
|
||||
## Transport notes
|
||||
|
||||
- SSE is implemented over streaming `fetch` (not native `EventSource`) so the
|
||||
client can refresh an expired token on a 401, send `Last-Event-ID` on resume,
|
||||
and apply its own exponential backoff (1s → 2s → 4s … capped at 30s).
|
||||
- **React Native** has no native `EventSource`, but it also can't stream
|
||||
`fetch` bodies on all engines — if you target RN, supply a streaming-capable
|
||||
`fetch` polyfill via the `fetch` option, or use a `react-native-sse`-based
|
||||
adapter. (Server-side `Last-Event-ID` replay is not implemented in v1.1.6;
|
||||
the client sends the header so it's ready when the server adds replay.)
|
||||
|
||||
## Build / test
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run lint # tsc --noEmit (strict)
|
||||
npm run test # vitest
|
||||
npm run build # tsup → dist/ (ESM + CJS + .d.ts)
|
||||
```
|
||||
3580
clients/typescript/package-lock.json
generated
Normal file
3580
clients/typescript/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
clients/typescript/package.json
Normal file
61
clients/typescript/package.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@picloud/client",
|
||||
"version": "1.0.0",
|
||||
"description": "TypeScript client for PiCloud — typed HTTP to script endpoints, SSE realtime subscriptions, auth-flow helpers, and React/Svelte hooks.",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./react": {
|
||||
"types": "./dist/react/index.d.ts",
|
||||
"import": "./dist/react/index.js",
|
||||
"require": "./dist/react/index.cjs"
|
||||
},
|
||||
"./svelte": {
|
||||
"types": "./dist/svelte/index.d.ts",
|
||||
"import": "./dist/svelte/index.js",
|
||||
"require": "./dist/svelte/index.cjs"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"svelte": ">=4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"svelte": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"svelte": "^4.2.0",
|
||||
"tsup": "^8.3.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
71
clients/typescript/src/auth.ts
Normal file
71
clients/typescript/src/auth.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Endpoint } from './endpoint.js';
|
||||
import type { AuthTokenProvider } from './types.js';
|
||||
|
||||
export interface AuthClientConfig {
|
||||
baseURL: string;
|
||||
fetchImpl: typeof fetch;
|
||||
/** Path of the dev-defined login endpoint (default `/api/auth/login`). */
|
||||
loginPath?: string;
|
||||
/** Path of the dev-defined logout endpoint (default `/api/auth/logout`). */
|
||||
logoutPath?: string;
|
||||
/** Called whenever the stored token changes (e.g. to persist it). */
|
||||
onToken?: (token: string | null) => void;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth-flow helpers. These call **dev-defined** endpoints under the hood
|
||||
* (the script layer owns the actual auth); the lib only standardizes the
|
||||
* dance + in-memory token storage. There is no built-in identity model —
|
||||
* `login` POSTs credentials and stores whatever `token` comes back.
|
||||
*/
|
||||
export class AuthClient {
|
||||
private current: string | null = null;
|
||||
|
||||
constructor(private readonly cfg: AuthClientConfig) {}
|
||||
|
||||
/** The current bearer token, or null. */
|
||||
get token(): string | null {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/** Suitable as `PicloudClientOptions.getAuthToken`. */
|
||||
readonly provider: AuthTokenProvider = () => this.current;
|
||||
|
||||
/** POST credentials to the login endpoint; store the returned token. */
|
||||
async login(email: string, password: string): Promise<string | null> {
|
||||
const ep = new Endpoint<{ email: string; password: string }, LoginResponse>({
|
||||
baseURL: this.cfg.baseURL,
|
||||
path: this.cfg.loginPath ?? '/api/auth/login',
|
||||
fetchImpl: this.cfg.fetchImpl
|
||||
});
|
||||
const res = await ep.post({ email, password });
|
||||
this.setToken(typeof res?.token === 'string' ? res.token : null);
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/** POST to the logout endpoint (best-effort) and clear the token. */
|
||||
async logout(): Promise<void> {
|
||||
const ep = new Endpoint<undefined, unknown>({
|
||||
baseURL: this.cfg.baseURL,
|
||||
path: this.cfg.logoutPath ?? '/api/auth/logout',
|
||||
// Send the current token so the script can invalidate the session.
|
||||
getAuthToken: () => this.current,
|
||||
fetchImpl: this.cfg.fetchImpl
|
||||
});
|
||||
try {
|
||||
await ep.post();
|
||||
} finally {
|
||||
this.setToken(null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Manually set (or clear) the token — e.g. restoring from storage. */
|
||||
setToken(token: string | null): void {
|
||||
this.current = token;
|
||||
this.cfg.onToken?.(token);
|
||||
}
|
||||
}
|
||||
61
clients/typescript/src/client.ts
Normal file
61
clients/typescript/src/client.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { AuthClient } from './auth.js';
|
||||
import { Endpoint } from './endpoint.js';
|
||||
import { subscribeTopic } from './subscribe.js';
|
||||
import type {
|
||||
PicloudClientOptions,
|
||||
RealtimeEvent,
|
||||
SubscribeOptions,
|
||||
Unsubscribe
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* The PiCloud frontend client. Three capabilities, all script-mediated
|
||||
* (the hybrid model — no direct KV/docs/users access from the browser):
|
||||
*
|
||||
* - `endpoint<Req, Res>(path)` — typed HTTP to a dev-defined route.
|
||||
* - `subscribe(topic, cb, opts?)` — SSE realtime subscription.
|
||||
* - `auth` — login/logout/token helpers over dev-defined endpoints.
|
||||
*/
|
||||
export class PicloudClient {
|
||||
readonly auth: AuthClient;
|
||||
private readonly baseURL: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly getAuthToken: PicloudClientOptions['getAuthToken'];
|
||||
|
||||
constructor(opts: PicloudClientOptions) {
|
||||
if (!opts.baseURL) throw new Error('PicloudClient: baseURL is required');
|
||||
this.baseURL = opts.baseURL;
|
||||
const f = opts.fetch ?? globalThis.fetch;
|
||||
if (typeof f !== 'function') {
|
||||
throw new Error('PicloudClient: no fetch available — pass options.fetch');
|
||||
}
|
||||
// Bind to avoid "Illegal invocation" when calling a detached global.
|
||||
this.fetchImpl = f.bind(globalThis);
|
||||
this.getAuthToken = opts.getAuthToken;
|
||||
this.auth = new AuthClient({ baseURL: this.baseURL, fetchImpl: this.fetchImpl });
|
||||
}
|
||||
|
||||
/** A typed handle to a dev-defined endpoint. */
|
||||
endpoint<Req = unknown, Res = unknown>(path: string): Endpoint<Req, Res> {
|
||||
return new Endpoint<Req, Res>({
|
||||
baseURL: this.baseURL,
|
||||
path,
|
||||
getAuthToken: this.getAuthToken,
|
||||
fetchImpl: this.fetchImpl
|
||||
});
|
||||
}
|
||||
|
||||
/** Subscribe to a realtime topic. Returns an unsubscribe function. */
|
||||
subscribe<T = unknown>(
|
||||
topic: string,
|
||||
onMessage: (event: RealtimeEvent<T>) => void,
|
||||
opts?: SubscribeOptions<T>
|
||||
): Unsubscribe {
|
||||
return subscribeTopic<T>(
|
||||
{ baseURL: this.baseURL, fetchImpl: this.fetchImpl },
|
||||
topic,
|
||||
onMessage,
|
||||
opts
|
||||
);
|
||||
}
|
||||
}
|
||||
106
clients/typescript/src/endpoint.ts
Normal file
106
clients/typescript/src/endpoint.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { PicloudHttpError, type AuthTokenProvider, type Validator } from './types.js';
|
||||
|
||||
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
|
||||
export interface EndpointConfig {
|
||||
baseURL: string;
|
||||
path: string;
|
||||
getAuthToken?: AuthTokenProvider;
|
||||
fetchImpl: typeof fetch;
|
||||
}
|
||||
|
||||
export interface RequestOptions<Res> {
|
||||
/** Extra headers merged over the defaults. */
|
||||
headers?: Record<string, string>;
|
||||
/** Optional runtime validation of the parsed response. */
|
||||
validate?: Validator<Res>;
|
||||
/** AbortSignal to cancel the request. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed HTTP to a dev-defined script endpoint. Auth header injection +
|
||||
* structured errors; the request/response types are caller-supplied
|
||||
* generics (`endpoint<Req, Res>('/path')`). No service access — every
|
||||
* call hits a route a script binds (the hybrid model).
|
||||
*/
|
||||
export class Endpoint<Req = unknown, Res = unknown> {
|
||||
constructor(private readonly cfg: EndpointConfig) {}
|
||||
|
||||
get(opts?: RequestOptions<Res>): Promise<Res> {
|
||||
return this.send('GET', undefined, opts);
|
||||
}
|
||||
|
||||
post(body?: Req, opts?: RequestOptions<Res>): Promise<Res> {
|
||||
return this.send('POST', body, opts);
|
||||
}
|
||||
|
||||
put(body?: Req, opts?: RequestOptions<Res>): Promise<Res> {
|
||||
return this.send('PUT', body, opts);
|
||||
}
|
||||
|
||||
patch(body?: Req, opts?: RequestOptions<Res>): Promise<Res> {
|
||||
return this.send('PATCH', body, opts);
|
||||
}
|
||||
|
||||
delete(opts?: RequestOptions<Res>): Promise<Res> {
|
||||
return this.send('DELETE', undefined, opts);
|
||||
}
|
||||
|
||||
private async send(method: Method, body: Req | undefined, opts?: RequestOptions<Res>): Promise<Res> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
...(opts?.headers ?? {})
|
||||
};
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] ??= 'application/json';
|
||||
}
|
||||
const token = this.cfg.getAuthToken ? await this.cfg.getAuthToken() : undefined;
|
||||
if (token) {
|
||||
headers['Authorization'] ??= `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const url = joinUrl(this.cfg.baseURL, this.cfg.path);
|
||||
const init: RequestInit = { method, headers };
|
||||
if (body !== undefined) {
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
if (opts?.signal) {
|
||||
init.signal = opts.signal;
|
||||
}
|
||||
|
||||
const res = await this.cfg.fetchImpl(url, init);
|
||||
const parsed = await parseBody(res);
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
(isRecord(parsed) && typeof parsed['error'] === 'string' && parsed['error']) ||
|
||||
`${method} ${this.cfg.path} failed with ${res.status}`;
|
||||
throw new PicloudHttpError(res.status, message, parsed);
|
||||
}
|
||||
return opts?.validate ? opts.validate.parse(parsed) : (parsed as Res);
|
||||
}
|
||||
}
|
||||
|
||||
async function parseBody(res: Response): Promise<unknown> {
|
||||
const text = await res.text();
|
||||
if (text.length === 0) return null;
|
||||
const ct = res.headers.get('content-type') ?? '';
|
||||
if (ct.includes('application/json')) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null;
|
||||
}
|
||||
|
||||
export function joinUrl(base: string, path: string): string {
|
||||
const b = base.endsWith('/') ? base.slice(0, -1) : base;
|
||||
const p = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${b}${p}`;
|
||||
}
|
||||
14
clients/typescript/src/index.ts
Normal file
14
clients/typescript/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export { PicloudClient } from './client.js';
|
||||
export { Endpoint } from './endpoint.js';
|
||||
export { AuthClient } from './auth.js';
|
||||
export { subscribeTopic } from './subscribe.js';
|
||||
export {
|
||||
PicloudHttpError,
|
||||
type PicloudClientOptions,
|
||||
type AuthTokenProvider,
|
||||
type RealtimeEvent,
|
||||
type SubscribeOptions,
|
||||
type Unsubscribe,
|
||||
type Validator
|
||||
} from './types.js';
|
||||
export type { RequestOptions } from './endpoint.js';
|
||||
101
clients/typescript/src/react/index.ts
Normal file
101
clients/typescript/src/react/index.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
createContext,
|
||||
createElement,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode
|
||||
} from 'react';
|
||||
|
||||
import type { PicloudClient } from '../client.js';
|
||||
import type { SubscribeOptions } from '../types.js';
|
||||
|
||||
const PicloudContext = createContext<PicloudClient | null>(null);
|
||||
|
||||
export interface PicloudProviderProps {
|
||||
client: PicloudClient;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/** Provides a `PicloudClient` to `useTopic` / `useEndpoint`. */
|
||||
export function PicloudProvider(props: PicloudProviderProps) {
|
||||
return createElement(PicloudContext.Provider, { value: props.client }, props.children);
|
||||
}
|
||||
|
||||
/** The client from the nearest `PicloudProvider`. Throws if absent. */
|
||||
export function usePicloud(): PicloudClient {
|
||||
const client = useContext(PicloudContext);
|
||||
if (!client) {
|
||||
throw new Error('usePicloud: wrap your tree in <PicloudProvider client={...}>');
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a realtime topic; returns the accumulated messages in
|
||||
* arrival order. Re-subscribes when `topic` changes; unsubscribes on
|
||||
* unmount.
|
||||
*/
|
||||
export function useTopic<T = unknown>(topic: string, opts?: SubscribeOptions<T>): T[] {
|
||||
const client = usePicloud();
|
||||
const [messages, setMessages] = useState<T[]>([]);
|
||||
useEffect(() => {
|
||||
setMessages([]);
|
||||
const unsubscribe = client.subscribe<T>(
|
||||
topic,
|
||||
(event) => setMessages((prev) => [...prev, event.message]),
|
||||
opts
|
||||
);
|
||||
return () => unsubscribe();
|
||||
// `opts` is intentionally excluded: a new object literal each render
|
||||
// would otherwise resubscribe every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client, topic]);
|
||||
return messages;
|
||||
}
|
||||
|
||||
export interface QueryState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
export interface EndpointHook<Req, Res> {
|
||||
get: () => QueryState<Res>;
|
||||
post: (body?: Req) => QueryState<Res>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed endpoint hook. `useEndpoint<Res>(path).get()` fires a GET and
|
||||
* returns `{ data, loading, error }`, re-running when `path` changes.
|
||||
* `.post(body)` is the mutation variant (auto-fires once per mount).
|
||||
*/
|
||||
export function useEndpoint<Res = unknown, Req = unknown>(path: string): EndpointHook<Req, Res> {
|
||||
const client = usePicloud();
|
||||
return {
|
||||
get: () => useResource<Res>(() => client.endpoint<Req, Res>(path).get(), path, 'GET'),
|
||||
post: (body?: Req) =>
|
||||
useResource<Res>(() => client.endpoint<Req, Res>(path).post(body), path, 'POST')
|
||||
};
|
||||
}
|
||||
|
||||
function useResource<Res>(run: () => Promise<Res>, key: string, method: string): QueryState<Res> {
|
||||
const [state, setState] = useState<QueryState<Res>>({
|
||||
data: null,
|
||||
loading: true,
|
||||
error: null
|
||||
});
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setState({ data: null, loading: true, error: null });
|
||||
run()
|
||||
.then((data) => active && setState({ data, loading: false, error: null }))
|
||||
.catch((error) => active && setState({ data: null, loading: false, error }));
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// `run` is recreated each render; key it on path + method instead.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, method]);
|
||||
return state;
|
||||
}
|
||||
194
clients/typescript/src/subscribe.ts
Normal file
194
clients/typescript/src/subscribe.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { joinUrl } from './endpoint.js';
|
||||
import type { RealtimeEvent, SubscribeOptions, Unsubscribe } from './types.js';
|
||||
|
||||
interface SubscribeConfig {
|
||||
baseURL: string;
|
||||
fetchImpl: typeof fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an app pub/sub topic over SSE.
|
||||
*
|
||||
* Implemented over streaming `fetch` (not native `EventSource`) so the
|
||||
* lib can: detect a 401 on (re)connect and refresh the token, send a
|
||||
* `Last-Event-ID` header on resume, and apply its own exponential
|
||||
* backoff. See HANDBACK for the rationale. Returns an unsubscribe
|
||||
* function that aborts the connection and stops reconnecting.
|
||||
*/
|
||||
export function subscribeTopic<T = unknown>(
|
||||
cfg: SubscribeConfig,
|
||||
topic: string,
|
||||
onMessage: (event: RealtimeEvent<T>) => void,
|
||||
opts: SubscribeOptions<T> = {}
|
||||
): Unsubscribe {
|
||||
const baseBackoff = opts.baseBackoffMs ?? 1_000;
|
||||
const maxBackoff = opts.maxBackoffMs ?? 30_000;
|
||||
let token = opts.token;
|
||||
let stopped = false;
|
||||
let attempt = 0;
|
||||
let lastEventId: string | undefined;
|
||||
let controller: AbortController | null = null;
|
||||
let backoffTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
if (backoffTimer) clearTimeout(backoffTimer);
|
||||
controller?.abort();
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (stopped) return;
|
||||
// Exponential backoff: base, 2x, 4x… capped at maxBackoff.
|
||||
const delay = Math.min(maxBackoff, baseBackoff * 2 ** attempt);
|
||||
attempt += 1;
|
||||
backoffTimer = setTimeout(() => void connect(), delay);
|
||||
};
|
||||
|
||||
const connect = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
controller = new AbortController();
|
||||
const url = buildUrl(cfg.baseURL, topic, token);
|
||||
const headers: Record<string, string> = { Accept: 'text/event-stream' };
|
||||
if (lastEventId) headers['Last-Event-ID'] = lastEventId;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await cfg.fetchImpl(url, { headers, signal: controller.signal });
|
||||
} catch (err) {
|
||||
if (stopped || isAbort(err)) return;
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
// Token expired / rejected — try to refresh, else give up.
|
||||
const fresh = opts.onTokenExpired ? await opts.onTokenExpired() : null;
|
||||
if (fresh) {
|
||||
token = fresh;
|
||||
attempt = 0; // fresh credential → reconnect immediately
|
||||
void connect();
|
||||
} else {
|
||||
opts.onError?.(new Error('realtime subscribe unauthorized (401)'));
|
||||
stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
if (!stopped) scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// Connected — reset backoff and stream frames until the body ends.
|
||||
attempt = 0;
|
||||
try {
|
||||
await readStream(res.body, (frame) => {
|
||||
if (frame.id !== undefined) lastEventId = frame.id;
|
||||
if (frame.data === undefined) return; // comment / heartbeat
|
||||
const parsed = parseEvent<T>(frame.data, opts);
|
||||
if (parsed) onMessage(parsed);
|
||||
});
|
||||
} catch (err) {
|
||||
if (stopped || isAbort(err)) return;
|
||||
}
|
||||
// Stream ended (server closed, e.g. topic deleted) → reconnect.
|
||||
if (!stopped) scheduleReconnect();
|
||||
};
|
||||
|
||||
void connect();
|
||||
return stop;
|
||||
}
|
||||
|
||||
function buildUrl(baseURL: string, topic: string, token?: string): string {
|
||||
const url = joinUrl(baseURL, `/realtime/topics/${encodeURIComponent(topic)}`);
|
||||
// EventSource can't set headers, so the token rides in the query
|
||||
// string — the same path a raw EventSource would use.
|
||||
return token ? `${url}?token=${encodeURIComponent(token)}` : url;
|
||||
}
|
||||
|
||||
function parseEvent<T>(data: string, opts: SubscribeOptions<T>): RealtimeEvent<T> | null {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isRealtimeShape(json)) return null;
|
||||
const message = opts.validate ? opts.validate.parse(json.message) : (json.message as T);
|
||||
return { topic: json.topic, message, published_at: json.published_at };
|
||||
}
|
||||
|
||||
function isRealtimeShape(v: unknown): v is RealtimeEvent<unknown> {
|
||||
return (
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
typeof (v as Record<string, unknown>)['topic'] === 'string' &&
|
||||
typeof (v as Record<string, unknown>)['published_at'] === 'string' &&
|
||||
'message' in (v as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
interface SseFrame {
|
||||
data?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an SSE response body, invoking `onFrame` per event. Minimal
|
||||
* parser: accumulates `data:` lines (joined by `\n`) and `id:` until a
|
||||
* blank line dispatches the frame. Lines starting with `:` are comments
|
||||
* (heartbeats) — surfaced as a frame with no `data` so the id can still
|
||||
* advance.
|
||||
*/
|
||||
async function readStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
onFrame: (frame: SseFrame) => void
|
||||
): Promise<void> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let dataLines: string[] = [];
|
||||
let id: string | undefined;
|
||||
let sawComment = false;
|
||||
|
||||
const dispatch = () => {
|
||||
if (dataLines.length > 0) {
|
||||
onFrame({ data: dataLines.join('\n'), id });
|
||||
} else if (sawComment) {
|
||||
onFrame({ id });
|
||||
}
|
||||
dataLines = [];
|
||||
sawComment = false;
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, nl).replace(/\r$/, '');
|
||||
buffer = buffer.slice(nl + 1);
|
||||
if (line === '') {
|
||||
dispatch();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith(':')) {
|
||||
sawComment = true;
|
||||
continue;
|
||||
}
|
||||
const colon = line.indexOf(':');
|
||||
const field = colon === -1 ? line : line.slice(0, colon);
|
||||
const rawVal = colon === -1 ? '' : line.slice(colon + 1);
|
||||
const val = rawVal.startsWith(' ') ? rawVal.slice(1) : rawVal;
|
||||
if (field === 'data') dataLines.push(val);
|
||||
else if (field === 'id') id = val;
|
||||
}
|
||||
}
|
||||
// Flush a trailing frame if the stream ended without a blank line.
|
||||
dispatch();
|
||||
}
|
||||
|
||||
function isAbort(err: unknown): boolean {
|
||||
return typeof err === 'object' && err !== null && (err as { name?: string }).name === 'AbortError';
|
||||
}
|
||||
72
clients/typescript/src/svelte/index.ts
Normal file
72
clients/typescript/src/svelte/index.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { readable, type Readable } from 'svelte/store';
|
||||
|
||||
import type { PicloudClient } from '../client.js';
|
||||
import type { SubscribeOptions } from '../types.js';
|
||||
|
||||
/**
|
||||
* A Svelte store of realtime messages for a topic. `$messages` is an
|
||||
* array that grows as events arrive. The SSE connection opens on the
|
||||
* first subscriber and closes when the last unsubscribes (standard
|
||||
* `readable` lifecycle).
|
||||
*
|
||||
* The client is passed explicitly (Svelte stores aren't components, so
|
||||
* there's no React-style context to read). See HANDBACK §7.
|
||||
*/
|
||||
export function topicStore<T = unknown>(
|
||||
client: PicloudClient,
|
||||
topic: string,
|
||||
opts?: SubscribeOptions<T>
|
||||
): Readable<T[]> {
|
||||
return readable<T[]>([], (set) => {
|
||||
let items: T[] = [];
|
||||
const unsubscribe = client.subscribe<T>(
|
||||
topic,
|
||||
(event) => {
|
||||
items = [...items, event.message];
|
||||
set(items);
|
||||
},
|
||||
opts
|
||||
);
|
||||
return () => unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
export interface QueryState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
export interface EndpointStore<Req, Res> {
|
||||
get: () => Readable<QueryState<Res>>;
|
||||
post: (body?: Req) => Readable<QueryState<Res>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Svelte store wrapper over a typed endpoint. `$query` is
|
||||
* `{ data, loading, error }`. The request fires when the store gains its
|
||||
* first subscriber.
|
||||
*/
|
||||
export function endpointStore<Res = unknown, Req = unknown>(
|
||||
client: PicloudClient,
|
||||
path: string
|
||||
): EndpointStore<Req, Res> {
|
||||
const run = (exec: () => Promise<Res>): Readable<QueryState<Res>> =>
|
||||
readable<QueryState<Res>>({ data: null, loading: true, error: null }, (set) => {
|
||||
let active = true;
|
||||
exec()
|
||||
.then((data) => {
|
||||
if (active) set({ data, loading: false, error: null });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (active) set({ data: null, loading: false, error });
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
});
|
||||
return {
|
||||
get: () => run(() => client.endpoint<Req, Res>(path).get()),
|
||||
post: (body?: Req) => run(() => client.endpoint<Req, Res>(path).post(body))
|
||||
};
|
||||
}
|
||||
73
clients/typescript/src/types.ts
Normal file
73
clients/typescript/src/types.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Shared types for @picloud/client.
|
||||
|
||||
/** Returns the current bearer token (or null) before each HTTP request. */
|
||||
export type AuthTokenProvider = () => string | null | undefined | Promise<string | null | undefined>;
|
||||
|
||||
export interface PicloudClientOptions {
|
||||
/** Base URL of the PiCloud deployment, e.g. `https://api.example.com`. */
|
||||
baseURL: string;
|
||||
/**
|
||||
* Optional: returns the current bearer token, called before each
|
||||
* request. The client doesn't manage tokens — it just sends them.
|
||||
*/
|
||||
getAuthToken?: AuthTokenProvider;
|
||||
/**
|
||||
* Optional fetch implementation (defaults to the global `fetch`).
|
||||
* Injected mainly for tests / non-browser runtimes.
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
/** A realtime event as delivered over SSE. */
|
||||
export interface RealtimeEvent<T = unknown> {
|
||||
topic: string;
|
||||
message: T;
|
||||
published_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal validator shape for the optional runtime-validation adapter.
|
||||
* A Zod schema satisfies this directly (`schema.parse`); for Valibot,
|
||||
* wrap it: `{ parse: (i) => v.parse(schema, i) }`. No hard dep on either.
|
||||
*/
|
||||
export interface Validator<T> {
|
||||
parse: (input: unknown) => T;
|
||||
}
|
||||
|
||||
/** Thrown when an endpoint call returns a non-2xx status. */
|
||||
export class PicloudHttpError extends Error {
|
||||
readonly status: number;
|
||||
readonly body: unknown;
|
||||
constructor(status: number, message: string, body: unknown) {
|
||||
super(message);
|
||||
this.name = 'PicloudHttpError';
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SubscribeOptions<T = unknown> {
|
||||
/**
|
||||
* Subscriber token for `auth_mode = 'token'` topics. Obtained from one
|
||||
* of your app's script endpoints (which calls
|
||||
* `pubsub::subscriber_token`). Sent as `?token=` (EventSource-parity).
|
||||
*/
|
||||
token?: string;
|
||||
/**
|
||||
* Called when a (re)connect is rejected with 401 — typically an
|
||||
* expired token. Return a fresh token to retry immediately, or
|
||||
* null/undefined to stop and surface the error.
|
||||
*/
|
||||
onTokenExpired?: () => string | null | undefined | Promise<string | null | undefined>;
|
||||
/** Called on a terminal error (after retries are exhausted or aborted). */
|
||||
onError?: (err: unknown) => void;
|
||||
/** Optional runtime validation of each event's `message`. */
|
||||
validate?: Validator<T>;
|
||||
/** Max reconnect backoff in ms (default 30_000). */
|
||||
maxBackoffMs?: number;
|
||||
/** Base reconnect backoff in ms (default 1_000). */
|
||||
baseBackoffMs?: number;
|
||||
}
|
||||
|
||||
/** Cancels a realtime subscription. */
|
||||
export type Unsubscribe = () => void;
|
||||
41
clients/typescript/tests/auth.test.ts
Normal file
41
clients/typescript/tests/auth.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PicloudClient } from '../src/index.js';
|
||||
import { jsonResponse, lastUrl, type FetchArgs } from './helpers.js';
|
||||
|
||||
describe('auth', () => {
|
||||
it('login POSTs credentials and stores the returned token', async () => {
|
||||
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) =>
|
||||
jsonResponse({ token: 'session-abc' })
|
||||
);
|
||||
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||
|
||||
const token = await client.auth.login('alice@example.com', 'pw');
|
||||
expect(token).toBe('session-abc');
|
||||
expect(client.auth.token).toBe('session-abc');
|
||||
expect(lastUrl(fetchMock)).toBe('https://api.test/api/auth/login');
|
||||
|
||||
const init = fetchMock.mock.calls[0]?.[1];
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
email: 'alice@example.com',
|
||||
password: 'pw'
|
||||
});
|
||||
});
|
||||
|
||||
it('logout clears the stored token', async () => {
|
||||
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) => jsonResponse({}));
|
||||
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||
client.auth.setToken('existing');
|
||||
await client.auth.logout();
|
||||
expect(client.auth.token).toBeNull();
|
||||
});
|
||||
|
||||
it('provider returns the current token for getAuthToken wiring', async () => {
|
||||
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) =>
|
||||
jsonResponse({ token: 't' })
|
||||
);
|
||||
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||
await client.auth.login('a@b.c', 'pw');
|
||||
expect(client.auth.provider()).toBe('t');
|
||||
});
|
||||
});
|
||||
82
clients/typescript/tests/endpoint.test.ts
Normal file
82
clients/typescript/tests/endpoint.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PicloudClient, PicloudHttpError } from '../src/index.js';
|
||||
import { headerOf, jsonResponse, lastInit, lastUrl, type FetchArgs } from './helpers.js';
|
||||
|
||||
describe('endpoint', () => {
|
||||
it('post round-trips a typed request/response', async () => {
|
||||
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) =>
|
||||
jsonResponse({ id: '1', name: 'alice', created_at: 'now' }, 201)
|
||||
);
|
||||
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||
|
||||
interface Req {
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
interface Res {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
}
|
||||
const res = await client.endpoint<Req, Res>('/api/users').post({ name: 'alice', role: 'admin' });
|
||||
|
||||
expect(res).toEqual({ id: '1', name: 'alice', created_at: 'now' });
|
||||
expect(lastUrl(fetchMock)).toBe('https://api.test/api/users');
|
||||
const init = lastInit(fetchMock);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(String(init.body))).toEqual({ name: 'alice', role: 'admin' });
|
||||
expect(headerOf(init, 'Content-Type')).toBe('application/json');
|
||||
});
|
||||
|
||||
it('get round-trips', async () => {
|
||||
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) =>
|
||||
jsonResponse({ name: 'bob' })
|
||||
);
|
||||
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||
const res = await client.endpoint<unknown, { name: string }>('/api/users/1').get();
|
||||
expect(res.name).toBe('bob');
|
||||
expect(lastInit(fetchMock).method).toBe('GET');
|
||||
});
|
||||
|
||||
it('injects the auth token from getAuthToken', async () => {
|
||||
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) => jsonResponse({ ok: true }));
|
||||
const client = new PicloudClient({
|
||||
baseURL: 'https://api.test',
|
||||
fetch: fetchMock,
|
||||
getAuthToken: () => 'tok-123'
|
||||
});
|
||||
await client.endpoint('/api/me').get();
|
||||
expect(headerOf(lastInit(fetchMock), 'Authorization')).toBe('Bearer tok-123');
|
||||
});
|
||||
|
||||
it('throws PicloudHttpError with status + body on non-2xx', async () => {
|
||||
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) =>
|
||||
jsonResponse({ error: 'bad input' }, 422)
|
||||
);
|
||||
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||
const err = await client
|
||||
.endpoint('/api/x')
|
||||
.get()
|
||||
.catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(PicloudHttpError);
|
||||
expect((err as PicloudHttpError).status).toBe(422);
|
||||
expect((err as PicloudHttpError).message).toBe('bad input');
|
||||
});
|
||||
|
||||
it('applies an optional validator to the response', async () => {
|
||||
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) =>
|
||||
jsonResponse({ id: 7 })
|
||||
);
|
||||
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||
const validator = {
|
||||
parse: (input: unknown) => {
|
||||
const r = input as { id: number };
|
||||
if (typeof r.id !== 'number') throw new Error('bad');
|
||||
return r;
|
||||
}
|
||||
};
|
||||
const res = await client.endpoint<unknown, { id: number }>('/api/x').get({ validate: validator });
|
||||
expect(res.id).toBe(7);
|
||||
});
|
||||
});
|
||||
54
clients/typescript/tests/helpers.ts
Normal file
54
clients/typescript/tests/helpers.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// Test helpers: build JSON + SSE Response objects and a typed fetch mock.
|
||||
|
||||
export function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
export function emptyResponse(status = 200): Response {
|
||||
return new Response(null, { status });
|
||||
}
|
||||
|
||||
/** Build a text/event-stream Response from raw SSE frame strings. */
|
||||
export function sseResponse(frames: string[], status = 200): Response {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const frame of frames) controller.enqueue(encoder.encode(frame));
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
return new Response(stream, {
|
||||
status,
|
||||
headers: { 'content-type': 'text/event-stream' }
|
||||
});
|
||||
}
|
||||
|
||||
/** One SSE `data:` event frame for a realtime payload. */
|
||||
export function dataFrame(topic: string, message: unknown, publishedAt = '2026-06-04T00:00:00Z'): string {
|
||||
const payload = JSON.stringify({ topic, message, published_at: publishedAt });
|
||||
return `data: ${payload}\n\n`;
|
||||
}
|
||||
|
||||
export type FetchArgs = [string | URL | Request, RequestInit?];
|
||||
|
||||
type MockLike = { mock: { calls: ReadonlyArray<ReadonlyArray<unknown>> } };
|
||||
|
||||
export function lastInit(mock: MockLike, i = 0): RequestInit {
|
||||
const call = mock.mock.calls[i];
|
||||
if (!call) throw new Error(`no fetch call at index ${i}`);
|
||||
return (call[1] as RequestInit | undefined) ?? {};
|
||||
}
|
||||
|
||||
export function lastUrl(mock: MockLike, i = 0): string {
|
||||
const call = mock.mock.calls[i];
|
||||
if (!call) throw new Error(`no fetch call at index ${i}`);
|
||||
return String(call[0]);
|
||||
}
|
||||
|
||||
export function headerOf(init: RequestInit, name: string): string | undefined {
|
||||
const h = init.headers as Record<string, string> | undefined;
|
||||
return h?.[name];
|
||||
}
|
||||
41
clients/typescript/tests/react.test.tsx
Normal file
41
clients/typescript/tests/react.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { PicloudClient, RealtimeEvent, Unsubscribe } from '../src/index.js';
|
||||
import { PicloudProvider, useTopic } from '../src/react/index.js';
|
||||
|
||||
type Cb = (e: RealtimeEvent<unknown>) => void;
|
||||
|
||||
function fakeClient() {
|
||||
const unsubscribe = vi.fn();
|
||||
let captured: Cb | null = null;
|
||||
const subscribe = vi.fn(
|
||||
(_topic: string, cb: Cb): Unsubscribe => {
|
||||
captured = cb;
|
||||
return unsubscribe as unknown as Unsubscribe;
|
||||
}
|
||||
);
|
||||
const client = { subscribe } as unknown as PicloudClient;
|
||||
return { client, subscribe, unsubscribe, emit: (e: RealtimeEvent<unknown>) => captured?.(e) };
|
||||
}
|
||||
|
||||
describe('react useTopic', () => {
|
||||
it('subscribes on mount, accumulates messages, unsubscribes on unmount', () => {
|
||||
const { client, subscribe, unsubscribe, emit } = fakeClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
PicloudProvider({ client, children });
|
||||
|
||||
const { result, unmount } = renderHook(() => useTopic<{ n: number }>('chat'), { wrapper });
|
||||
|
||||
expect(subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(result.current).toEqual([]);
|
||||
|
||||
act(() => emit({ topic: 'chat', message: { n: 1 }, published_at: 't' }));
|
||||
act(() => emit({ topic: 'chat', message: { n: 2 }, published_at: 't' }));
|
||||
expect(result.current).toEqual([{ n: 1 }, { n: 2 }]);
|
||||
|
||||
unmount();
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
99
clients/typescript/tests/subscribe.test.ts
Normal file
99
clients/typescript/tests/subscribe.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PicloudClient, type RealtimeEvent } from '../src/index.js';
|
||||
import { dataFrame, emptyResponse, lastUrl, sseResponse, type FetchArgs } from './helpers.js';
|
||||
|
||||
/** A fetch mock that plays through a queue of response factories. */
|
||||
function queuedFetch(responders: Array<() => Promise<Response>>) {
|
||||
let i = 0;
|
||||
return vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) => {
|
||||
const idx = Math.min(i, responders.length - 1);
|
||||
i += 1;
|
||||
const r = responders[idx];
|
||||
if (!r) throw new Error('no responder');
|
||||
return r();
|
||||
});
|
||||
}
|
||||
|
||||
describe('subscribe', () => {
|
||||
it('connects to the SSE endpoint and delivers events', async () => {
|
||||
const fetchMock = queuedFetch([async () => sseResponse([dataFrame('chat', { hi: 1 })])]);
|
||||
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||
|
||||
const received: Array<RealtimeEvent<{ hi: number }>> = [];
|
||||
const unsubscribe = client.subscribe<{ hi: number }>('chat', (e) => received.push(e));
|
||||
|
||||
await vi.waitFor(() => expect(received.length).toBe(1));
|
||||
unsubscribe();
|
||||
|
||||
expect(received[0]?.topic).toBe('chat');
|
||||
expect(received[0]?.message).toEqual({ hi: 1 });
|
||||
expect(lastUrl(fetchMock)).toBe('https://api.test/realtime/topics/chat');
|
||||
});
|
||||
|
||||
it('passes a token via the query string', async () => {
|
||||
const fetchMock = queuedFetch([async () => sseResponse([dataFrame('chat', 1)])]);
|
||||
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||
const unsubscribe = client.subscribe('chat', () => {}, { token: 'abc.def' });
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
unsubscribe();
|
||||
expect(lastUrl(fetchMock)).toBe('https://api.test/realtime/topics/chat?token=abc.def');
|
||||
});
|
||||
|
||||
it('reconnects with backoff after an initial connection failure', async () => {
|
||||
const fetchMock = queuedFetch([
|
||||
async () => {
|
||||
throw new Error('network down');
|
||||
},
|
||||
async () => sseResponse([dataFrame('chat', { ok: true })])
|
||||
]);
|
||||
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||
|
||||
const received: unknown[] = [];
|
||||
const unsubscribe = client.subscribe('chat', (e) => received.push(e.message), {
|
||||
baseBackoffMs: 5,
|
||||
maxBackoffMs: 20
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(received.length).toBeGreaterThanOrEqual(1), { timeout: 1000 });
|
||||
unsubscribe();
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(received[0]).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('refreshes the token after a 401 and reconnects', async () => {
|
||||
const fetchMock = queuedFetch([
|
||||
async () => emptyResponse(401),
|
||||
async () => sseResponse([dataFrame('chat', { v: 2 })])
|
||||
]);
|
||||
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||
|
||||
const onTokenExpired = vi.fn(() => 'fresh-token');
|
||||
const received: unknown[] = [];
|
||||
const unsubscribe = client.subscribe('chat', (e) => received.push(e.message), {
|
||||
token: 'stale',
|
||||
onTokenExpired,
|
||||
baseBackoffMs: 5
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(received.length).toBeGreaterThanOrEqual(1), { timeout: 1000 });
|
||||
unsubscribe();
|
||||
|
||||
expect(onTokenExpired).toHaveBeenCalled();
|
||||
// Second connect carries the refreshed token.
|
||||
expect(lastUrl(fetchMock, 1)).toContain('token=fresh-token');
|
||||
expect(received[0]).toEqual({ v: 2 });
|
||||
});
|
||||
|
||||
it('stops and reports when a 401 cannot be refreshed', async () => {
|
||||
const fetchMock = queuedFetch([async () => emptyResponse(401)]);
|
||||
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||
const onError = vi.fn();
|
||||
const unsubscribe = client.subscribe('chat', () => {}, {
|
||||
onTokenExpired: () => null,
|
||||
onError
|
||||
});
|
||||
await vi.waitFor(() => expect(onError).toHaveBeenCalled());
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
34
clients/typescript/tests/svelte.test.ts
Normal file
34
clients/typescript/tests/svelte.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { get } from 'svelte/store';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { PicloudClient, RealtimeEvent, Unsubscribe } from '../src/index.js';
|
||||
import { topicStore } from '../src/svelte/index.js';
|
||||
|
||||
type Cb = (e: RealtimeEvent<unknown>) => void;
|
||||
|
||||
describe('svelte topicStore', () => {
|
||||
it('subscribes on first subscriber and unsubscribes on last', () => {
|
||||
const unsubscribe = vi.fn();
|
||||
const holder: { cb: Cb | null } = { cb: null };
|
||||
const subscribe = vi.fn((_topic: string, cb: Cb): Unsubscribe => {
|
||||
holder.cb = cb;
|
||||
return unsubscribe as unknown as Unsubscribe;
|
||||
});
|
||||
const client = { subscribe } as unknown as PicloudClient;
|
||||
|
||||
const store = topicStore<{ x: number }>(client, 'chat');
|
||||
// No SSE connection until someone subscribes (readable lifecycle).
|
||||
expect(subscribe).not.toHaveBeenCalled();
|
||||
|
||||
let value: { x: number }[] = [];
|
||||
const stop = store.subscribe((v) => (value = v));
|
||||
expect(subscribe).toHaveBeenCalledTimes(1);
|
||||
|
||||
holder.cb?.({ topic: 'chat', message: { x: 1 }, published_at: 't' });
|
||||
expect(value).toEqual([{ x: 1 }]);
|
||||
expect(get(store)).toEqual([{ x: 1 }]);
|
||||
|
||||
stop();
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
21
clients/typescript/tsconfig.json
Normal file
21
clients/typescript/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"noImplicitOverride": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src", "tests"]
|
||||
}
|
||||
18
clients/typescript/tsup.config.ts
Normal file
18
clients/typescript/tsup.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
// Dual ESM + CJS emit with .d.ts for the main entry and the two
|
||||
// framework subpath exports. React and Svelte are peer deps — kept
|
||||
// external so the lib never bundles a framework copy.
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
index: 'src/index.ts',
|
||||
'react/index': 'src/react/index.ts',
|
||||
'svelte/index': 'src/svelte/index.ts'
|
||||
},
|
||||
format: ['esm', 'cjs'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
treeshake: true,
|
||||
external: ['react', 'svelte', 'svelte/store']
|
||||
});
|
||||
11
clients/typescript/vitest.config.ts
Normal file
11
clients/typescript/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// jsdom so the React/Svelte hook tests have a DOM; the core
|
||||
// endpoint/subscribe/auth tests are environment-agnostic.
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx']
|
||||
}
|
||||
});
|
||||
@@ -18,7 +18,16 @@ tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
rhai.workspace = true
|
||||
async-trait.workspace = true
|
||||
# `internals` feature surfaces `rhai::Stmt`, `rhai::Expr`, `ASTFlags`
|
||||
# (used by the v1.1.3 module-shape validator to walk top-level
|
||||
# statements and accept only `fn` / `const` / `import`). Pinned at
|
||||
# the workspace level; bumping rhai is a deliberate, reviewed change.
|
||||
rhai = { workspace = true, features = ["internals"] }
|
||||
|
||||
# v1.1.3 — per-module compiled-Module cache lives in this crate so the
|
||||
# resolver can reuse compiled modules across invocations.
|
||||
lru.workspace = true
|
||||
|
||||
# Stdlib utility modules — see crates/executor-core/src/sdk/stdlib/.
|
||||
regex.workspace = true
|
||||
@@ -26,6 +35,13 @@ rand.workspace = true
|
||||
base64.workspace = true
|
||||
hex.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
# v1.1.4 — `http::post_form` uses `url::form_urlencoded` for correct
|
||||
# application/x-www-form-urlencoded body encoding.
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait.workspace = true
|
||||
# v1.1.4 §10a: capture tracing output to assert the original module
|
||||
# backend error is logged at error level after being redacted from the
|
||||
# script-visible message.
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
@@ -4,11 +4,15 @@ use std::time::Instant;
|
||||
|
||||
use chrono::Utc;
|
||||
use picloud_shared::{
|
||||
ScriptValidator, SdkCallCx, Services, TriggerEvent, ValidationError, SDK_VERSION,
|
||||
ScriptValidator, SdkCallCx, Services, TriggerEvent, ValidatedScript, ValidationError,
|
||||
SDK_VERSION,
|
||||
};
|
||||
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module, Scope};
|
||||
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module, Scope, AST};
|
||||
use serde_json::Value as Json;
|
||||
|
||||
use crate::module_resolver::{
|
||||
extract_imports, new_module_cache, validate_module_source, ModuleCache, PicloudModuleResolver,
|
||||
};
|
||||
use crate::sandbox::Limits;
|
||||
use crate::sdk;
|
||||
use crate::sdk::bridge::{dynamic_to_json, json_to_dynamic};
|
||||
@@ -16,6 +20,11 @@ use crate::types::{
|
||||
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
|
||||
};
|
||||
|
||||
/// Default capacity for the module cache. Sized assuming a small fleet
|
||||
/// of distinct modules per process; can be overridden via
|
||||
/// `PICLOUD_MODULE_CACHE_SIZE`.
|
||||
const DEFAULT_MODULE_CACHE_SIZE: usize = 512;
|
||||
|
||||
/// Preconfigured Rhai engine with sandbox limits applied and the SDK
|
||||
/// `Services` bundle attached.
|
||||
///
|
||||
@@ -31,12 +40,34 @@ use crate::types::{
|
||||
pub struct Engine {
|
||||
limits: Limits,
|
||||
services: Services,
|
||||
/// v1.1.3: shared compiled-module cache. Per-key
|
||||
/// `(app_id, name)`; invalidated lazily by `updated_at` mismatch
|
||||
/// at resolver time.
|
||||
module_cache: Arc<ModuleCache>,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
#[must_use]
|
||||
pub fn new(limits: Limits, services: Services) -> Self {
|
||||
Self { limits, services }
|
||||
let cap = std::env::var("PICLOUD_MODULE_CACHE_SIZE")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.unwrap_or(DEFAULT_MODULE_CACHE_SIZE);
|
||||
Self::with_module_cache_capacity(limits, services, cap)
|
||||
}
|
||||
|
||||
/// Explicit capacity for tests that exercise LRU eviction.
|
||||
#[must_use]
|
||||
pub fn with_module_cache_capacity(
|
||||
limits: Limits,
|
||||
services: Services,
|
||||
module_cache_capacity: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
limits,
|
||||
services,
|
||||
module_cache: new_module_cache(module_cache_capacity),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -44,16 +75,42 @@ impl Engine {
|
||||
&self.limits
|
||||
}
|
||||
|
||||
/// Parse-only validation. Surfaced at script-upload time so syntax
|
||||
/// errors are caught before the first invocation. Same logic as the
|
||||
/// `ScriptValidator` impl below but with the richer `ExecError`
|
||||
/// variant; callers in the executor path use this, the manager
|
||||
/// path goes through the trait.
|
||||
pub fn validate(&self, source: &str) -> Result<(), ExecError> {
|
||||
/// Shared compiled-module cache. Exposed so tests can introspect
|
||||
/// the cache state (length, contents) under a Mutex lock.
|
||||
#[must_use]
|
||||
pub fn module_cache(&self) -> &Arc<ModuleCache> {
|
||||
&self.module_cache
|
||||
}
|
||||
|
||||
/// Parse-only validation for endpoint scripts. Surfaced at script-
|
||||
/// upload time so syntax errors are caught before the first
|
||||
/// invocation. Returns the script's literal-path `import "<name>"`
|
||||
/// declarations so the repo can populate the dep-graph table.
|
||||
pub fn validate(&self, source: &str) -> Result<ValidatedScript, ExecError> {
|
||||
// Validation uses a fresh `RhaiEngine` without service hooks
|
||||
// attached — modules are only resolved at execute() time, so
|
||||
// the resolver during validate is intentionally Dummy (no DB
|
||||
// access here; we just need the parser).
|
||||
let engine = build_engine(self.limits, None);
|
||||
extract_imports(&engine, source).map_err(ExecError::Parse)
|
||||
}
|
||||
|
||||
/// Module-shape validation (v1.1.3). Compiles, rejects any top-
|
||||
/// level statement that isn't `fn`/`const`/`import`, and returns
|
||||
/// the declared imports.
|
||||
pub fn validate_module(&self, source: &str) -> Result<ValidatedScript, ExecError> {
|
||||
let engine = build_engine(self.limits, None);
|
||||
validate_module_source(&engine, source).map_err(ExecError::Parse)
|
||||
}
|
||||
|
||||
/// Compile `source` to a reusable AST. Lets callers (the
|
||||
/// orchestrator's script cache) compile once and execute many
|
||||
/// times against the same AST.
|
||||
pub fn compile(&self, source: &str) -> Result<Arc<AST>, ExecError> {
|
||||
let engine = build_engine(self.limits, None);
|
||||
engine
|
||||
.compile(source)
|
||||
.map(|_| ())
|
||||
.map(Arc::new)
|
||||
.map_err(|e| ExecError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -63,6 +120,21 @@ impl Engine {
|
||||
/// request replace the engine's defaults field-by-field; the
|
||||
/// manager already clamped them against the admin ceiling.
|
||||
pub fn execute(&self, source: &str, req: ExecRequest) -> Result<ExecResponse, ExecError> {
|
||||
let effective_limits = self.limits.with_overrides(&req.sandbox_overrides);
|
||||
// Compile inline so the source-only path stays available for
|
||||
// tests and one-off callers that don't pre-cache an AST.
|
||||
let engine_for_compile = build_engine(effective_limits, None);
|
||||
let ast = engine_for_compile
|
||||
.compile(source)
|
||||
.map(Arc::new)
|
||||
.map_err(|e| ExecError::Parse(e.to_string()))?;
|
||||
self.execute_ast(&ast, req)
|
||||
}
|
||||
|
||||
/// v1.1.3: execute a pre-compiled AST. The orchestrator's script
|
||||
/// cache hands compiled ASTs in directly; this path skips the
|
||||
/// per-call compile.
|
||||
pub fn execute_ast(&self, ast: &Arc<AST>, req: ExecRequest) -> Result<ExecResponse, ExecError> {
|
||||
let effective_limits = self.limits.with_overrides(&req.sandbox_overrides);
|
||||
let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut engine = build_engine(effective_limits, Some(logs.clone()));
|
||||
@@ -72,6 +144,7 @@ impl Engine {
|
||||
// capture cheap clones of the cx for use at script-call time.
|
||||
let cx = Arc::new(SdkCallCx {
|
||||
app_id: req.app_id,
|
||||
script_id: req.script_id,
|
||||
principal: req.principal.clone(),
|
||||
execution_id: req.execution_id,
|
||||
request_id: req.request_id,
|
||||
@@ -80,18 +153,25 @@ impl Engine {
|
||||
is_dead_letter_handler: req.is_dead_letter_handler,
|
||||
event: req.event.clone(),
|
||||
});
|
||||
// v1.1.3: replace the no-op `DummyModuleResolver` build_engine
|
||||
// installed with the real per-call resolver. The resolver owns
|
||||
// `cx.clone()` so cross-app isolation derives from this exact
|
||||
// call's context, not from any script-passed argument.
|
||||
let resolver = PicloudModuleResolver::new(
|
||||
self.services.modules.clone(),
|
||||
cx.clone(),
|
||||
self.module_cache.clone(),
|
||||
effective_limits.module_import_depth_max,
|
||||
);
|
||||
engine.set_module_resolver(resolver);
|
||||
sdk::register_all(&mut engine, &self.services, cx);
|
||||
|
||||
let ast = engine
|
||||
.compile(source)
|
||||
.map_err(|e| ExecError::Parse(e.to_string()))?;
|
||||
|
||||
let mut scope = Scope::new();
|
||||
scope.push_constant("ctx", build_ctx_map(&req));
|
||||
|
||||
let started = Instant::now();
|
||||
let value: Dynamic = engine
|
||||
.eval_ast_with_scope(&mut scope, &ast)
|
||||
.eval_ast_with_scope(&mut scope, ast.as_ref())
|
||||
.map_err(map_eval_error)?;
|
||||
let duration = started.elapsed();
|
||||
|
||||
@@ -116,8 +196,18 @@ impl Engine {
|
||||
}
|
||||
|
||||
impl ScriptValidator for Engine {
|
||||
fn validate(&self, source: &str) -> Result<(), ValidationError> {
|
||||
Engine::validate(self, source).map_err(|e| ValidationError::Syntax(e.to_string()))
|
||||
fn validate(&self, source: &str) -> Result<ValidatedScript, ValidationError> {
|
||||
Engine::validate(self, source).map_err(|e| match e {
|
||||
ExecError::Parse(msg) => ValidationError::Syntax(msg),
|
||||
other => ValidationError::Syntax(other.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_module(&self, source: &str) -> Result<ValidatedScript, ValidationError> {
|
||||
Engine::validate_module(self, source).map_err(|e| match e {
|
||||
ExecError::Parse(msg) => ValidationError::ModuleShape(msg),
|
||||
other => ValidationError::ModuleShape(other.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +348,7 @@ fn build_ctx_map(req: &ExecRequest) -> Map {
|
||||
/// `docs/v1.1.x-design-notes.md` §4 (the dead-letter sub-shape) and
|
||||
/// §2/blueprint §9 (KV). Each variant becomes a Rhai map with a
|
||||
/// `source` discriminant plus per-source fields.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn trigger_event_to_dynamic(event: &TriggerEvent) -> Dynamic {
|
||||
let mut m = Map::new();
|
||||
m.insert("source".into(), event.source().into());
|
||||
@@ -299,6 +390,98 @@ fn trigger_event_to_dynamic(event: &TriggerEvent) -> Dynamic {
|
||||
);
|
||||
m.insert("docs".into(), docs_map.into());
|
||||
}
|
||||
TriggerEvent::Cron {
|
||||
schedule,
|
||||
timezone,
|
||||
scheduled_at,
|
||||
fired_at,
|
||||
} => {
|
||||
// `ctx.event.op` is always "tick" for cron (the only op a
|
||||
// schedule produces). Mirrors the docs/v1.1.x-design-notes
|
||||
// §7 shape.
|
||||
m.insert("op".into(), "tick".into());
|
||||
let mut cron_map = Map::new();
|
||||
cron_map.insert("schedule".into(), schedule.clone().into());
|
||||
cron_map.insert("timezone".into(), timezone.clone().into());
|
||||
cron_map.insert("scheduled_at".into(), scheduled_at.to_rfc3339().into());
|
||||
cron_map.insert("fired_at".into(), fired_at.to_rfc3339().into());
|
||||
m.insert("cron".into(), cron_map.into());
|
||||
}
|
||||
TriggerEvent::Files {
|
||||
op,
|
||||
collection,
|
||||
id,
|
||||
name,
|
||||
content_type,
|
||||
size,
|
||||
checksum,
|
||||
prev,
|
||||
} => {
|
||||
m.insert("op".into(), op.as_str().into());
|
||||
let mut files_map = Map::new();
|
||||
files_map.insert("collection".into(), collection.clone().into());
|
||||
files_map.insert("id".into(), id.clone().into());
|
||||
files_map.insert("name".into(), name.clone().into());
|
||||
files_map.insert("content_type".into(), content_type.clone().into());
|
||||
files_map.insert(
|
||||
"size".into(),
|
||||
i64::try_from(*size).unwrap_or(i64::MAX).into(),
|
||||
);
|
||||
files_map.insert("checksum".into(), checksum.clone().into());
|
||||
files_map.insert(
|
||||
"prev".into(),
|
||||
prev.clone().map_or(Dynamic::UNIT, json_to_dynamic),
|
||||
);
|
||||
m.insert("files".into(), files_map.into());
|
||||
}
|
||||
TriggerEvent::Pubsub {
|
||||
topic,
|
||||
message,
|
||||
published_at,
|
||||
} => {
|
||||
// `ctx.event.op` is always "publish" for pub/sub (the only
|
||||
// op a publish produces).
|
||||
m.insert("op".into(), "publish".into());
|
||||
let mut ps = Map::new();
|
||||
ps.insert("topic".into(), topic.clone().into());
|
||||
ps.insert("message".into(), json_to_dynamic(message.clone()));
|
||||
ps.insert("published_at".into(), published_at.to_rfc3339().into());
|
||||
m.insert("pubsub".into(), ps.into());
|
||||
}
|
||||
TriggerEvent::Email {
|
||||
from,
|
||||
to,
|
||||
cc,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
received_at,
|
||||
message_id,
|
||||
} => {
|
||||
// `ctx.event.op` is always "receive" for inbound email.
|
||||
m.insert("op".into(), "receive".into());
|
||||
let mut em = Map::new();
|
||||
em.insert("from".into(), from.clone().into());
|
||||
let to_arr: rhai::Array = to.iter().map(|a| Dynamic::from(a.clone())).collect();
|
||||
em.insert("to".into(), to_arr.into());
|
||||
let cc_arr: rhai::Array = cc.iter().map(|a| Dynamic::from(a.clone())).collect();
|
||||
em.insert("cc".into(), cc_arr.into());
|
||||
em.insert("subject".into(), subject.clone().into());
|
||||
em.insert(
|
||||
"text".into(),
|
||||
text.clone().map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
em.insert(
|
||||
"html".into(),
|
||||
html.clone().map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
em.insert("received_at".into(), received_at.to_rfc3339().into());
|
||||
em.insert(
|
||||
"message_id".into(),
|
||||
message_id.clone().map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
m.insert("email".into(), em.into());
|
||||
}
|
||||
TriggerEvent::DeadLetter {
|
||||
dead_letter_id,
|
||||
original,
|
||||
|
||||
@@ -7,11 +7,16 @@
|
||||
pub mod context;
|
||||
pub mod engine;
|
||||
pub mod logging;
|
||||
pub mod module_resolver;
|
||||
pub mod sandbox;
|
||||
pub mod sdk;
|
||||
pub mod types;
|
||||
|
||||
pub use engine::Engine;
|
||||
pub use module_resolver::{
|
||||
extract_imports, new_module_cache, validate_module_source, CachedModule, ModuleCache,
|
||||
ModuleCacheKey, PicloudModuleResolver,
|
||||
};
|
||||
pub use sandbox::Limits;
|
||||
pub use types::{
|
||||
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
|
||||
|
||||
440
crates/executor-core/src/module_resolver.rs
Normal file
440
crates/executor-core/src/module_resolver.rs
Normal file
@@ -0,0 +1,440 @@
|
||||
//! `PicloudModuleResolver` — the v1.1.3 per-app Rhai module resolver.
|
||||
//!
|
||||
//! Replaces `DummyModuleResolver` in `Engine::build_engine`. Constructed
|
||||
//! fresh per `Engine::execute` call: holds an `Arc<SdkCallCx>` so every
|
||||
//! `import "<name>"` request resolves against the calling app
|
||||
//! (`cx.app_id`). The script-side `name` argument carries no `app_id`
|
||||
//! — that's the load-bearing cross-app isolation property.
|
||||
//!
|
||||
//! Three runtime invariants are enforced:
|
||||
//!
|
||||
//! 1. **Cross-app isolation** — `ModuleSource::lookup` is called with
|
||||
//! `&cx`; the Postgres impl scopes by `cx.app_id` (never by a
|
||||
//! script-passed argument).
|
||||
//! 2. **Cycle detection** — an in-progress-imports stack rejects
|
||||
//! `A → B → A` with `ErrorInModule(... circular import detected ...)`.
|
||||
//! 3. **Depth limit** — guards against deep but acyclic chains
|
||||
//! (default 8, override via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`).
|
||||
//!
|
||||
//! Compiled modules are cached per `(app_id, name)` and invalidated by
|
||||
//! `updated_at` change — no explicit pub/sub. The cache is owned by
|
||||
//! `Engine` and shared across calls; only the resolver state (stack,
|
||||
//! depth) is per-call.
|
||||
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use lru::LruCache;
|
||||
use picloud_shared::{AppId, ModuleSource, ModuleSourceError, SdkCallCx, ValidatedScript};
|
||||
use rhai::module_resolvers::ModuleResolver;
|
||||
use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST};
|
||||
|
||||
/// Local alias for `rhai::Shared<rhai::Module>` (rhai's `SharedRhaiModule`
|
||||
/// type alias is `pub(crate)`). Resolves to `Arc<Module>` under the
|
||||
/// `sync` feature that the workspace pins.
|
||||
type SharedRhaiModule = Shared<Module>;
|
||||
|
||||
/// Cache key: `(app_id, module name)`. v1.1.3 enforces module names as
|
||||
/// a conservative identifier shape (migration 0015 `scripts_module_name_shape`
|
||||
/// CHECK) so the `String` here is bounded by ~64 bytes.
|
||||
pub type ModuleCacheKey = (AppId, String);
|
||||
|
||||
/// Cache value: the freshness comparator + the compiled module Rhai
|
||||
/// hands to importing scripts. Cloning the `Shared<Module>` is an Arc bump.
|
||||
#[derive(Clone)]
|
||||
pub struct CachedModule {
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub module: Shared<Module>,
|
||||
}
|
||||
|
||||
/// Bounded LRU cache shared across all `Engine::execute` calls. Construct
|
||||
/// once at process startup; the resolver holds an Arc into it.
|
||||
pub type ModuleCache = Mutex<LruCache<ModuleCacheKey, CachedModule>>;
|
||||
|
||||
#[must_use]
|
||||
pub fn new_module_cache(capacity: usize) -> Arc<ModuleCache> {
|
||||
// capacity 0 is nonsensical for an LRU; clamp up to 1 so the cache
|
||||
// is at least usable (callers control this via env var, and 0 means
|
||||
// "I disabled caching" — but disabling caching by accident would
|
||||
// recompile every module every call, which is a worse UX than
|
||||
// capping at 1).
|
||||
let cap = NonZeroUsize::new(capacity.max(1)).expect("max(1) is non-zero");
|
||||
Arc::new(Mutex::new(LruCache::new(cap)))
|
||||
}
|
||||
|
||||
/// The v1.1.3 module resolver. One per `Engine::execute` call.
|
||||
pub struct PicloudModuleResolver {
|
||||
/// Backend the resolver consults for `(app_id, name)`. The bridge
|
||||
/// runs Rhai's sync `resolve()` and the async `lookup()` together
|
||||
/// via `tokio::runtime::Handle::block_on(...)` — safe because
|
||||
/// `LocalExecutorClient` runs `Engine::execute` inside
|
||||
/// `spawn_blocking`, which puts us on a Tokio blocking thread
|
||||
/// that still carries a `Handle`.
|
||||
source: Arc<dyn ModuleSource>,
|
||||
|
||||
/// Calling context. `cx.app_id` is the cross-app isolation
|
||||
/// boundary; the resolver passes `&cx` to every `ModuleSource`
|
||||
/// call so the backend can scope its queries.
|
||||
cx: Arc<SdkCallCx>,
|
||||
|
||||
/// Compiled-module cache. Shared across executions; invalidated
|
||||
/// per-entry on `updated_at` mismatch (no explicit pub/sub).
|
||||
cache: Arc<ModuleCache>,
|
||||
|
||||
/// In-progress imports stack — pushed before a `lookup`+compile,
|
||||
/// popped after. A hit on this stack while resolving means the
|
||||
/// graph contains a cycle.
|
||||
in_progress: Mutex<Vec<String>>,
|
||||
|
||||
/// Current import depth. Independent of the cycle check (cycles
|
||||
/// might be short; deep acyclic graphs might fit under the cap
|
||||
/// but still warrant a guard).
|
||||
depth: Mutex<u32>,
|
||||
|
||||
/// Hard ceiling on import depth. Defaults to 8; env-overridable
|
||||
/// via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`. Read from `Limits` at
|
||||
/// resolver construction.
|
||||
depth_limit: u32,
|
||||
}
|
||||
|
||||
impl PicloudModuleResolver {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
source: Arc<dyn ModuleSource>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
cache: Arc<ModuleCache>,
|
||||
depth_limit: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
source,
|
||||
cx,
|
||||
cache,
|
||||
in_progress: Mutex::new(Vec::new()),
|
||||
depth: Mutex::new(0),
|
||||
depth_limit,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate `ast` as a module body: only top-level `fn` decls,
|
||||
/// `const` decls, and `import` statements are allowed. Top-level
|
||||
/// expressions (which would execute on import — a footgun for
|
||||
/// cache semantics) are rejected.
|
||||
///
|
||||
/// `fn` declarations live in a separate slot on the AST and are
|
||||
/// not in `statements()`, so the only allowed `Stmt` variants we
|
||||
/// expect to see at top level are `Var` (when `CONSTANT` flag is
|
||||
/// set) and `Import`. Anything else triggers a `ModuleShape` error.
|
||||
fn check_module_shape(ast: &AST, name: &str) -> Result<(), String> {
|
||||
use rhai::ASTFlags;
|
||||
for stmt in ast.statements() {
|
||||
match stmt {
|
||||
rhai::Stmt::Var(_, opts, _) if opts.intersects(ASTFlags::CONSTANT) => {}
|
||||
rhai::Stmt::Import(..) | rhai::Stmt::Noop(..) => {}
|
||||
other => {
|
||||
return Err(format!(
|
||||
"module {name:?}: top-level {} is not allowed; \
|
||||
modules may only contain fn declarations, \
|
||||
const declarations, and import statements",
|
||||
stmt_kind_label(other),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walk a compiled AST and collect the literal-path `import "<name>"`
|
||||
/// declarations. Dynamic imports (e.g. `import some_var as y;`) are
|
||||
/// skipped because the dep-graph can only track names known at
|
||||
/// compile time. Exposed via [`extract_imports`] so the manager's
|
||||
/// admin endpoints can populate the `script_imports` table from
|
||||
/// the same logic the resolver uses.
|
||||
fn extract_imports_inner(ast: &AST) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for stmt in ast.statements() {
|
||||
if let rhai::Stmt::Import(boxed, _) = stmt {
|
||||
let (path_expr, _alias) = boxed.as_ref();
|
||||
if let rhai::Expr::StringConstant(s, _) = path_expr {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile-and-validate a candidate module body. Public so the
|
||||
/// `Engine::validate_module` impl in `engine.rs` can call into it
|
||||
/// without duplicating the shape check.
|
||||
pub fn compile_module_ast(engine: &RhaiEngine, source: &str) -> Result<AST, String> {
|
||||
let ast = engine.compile(source).map_err(|e| e.to_string())?;
|
||||
PicloudModuleResolver::check_module_shape(&ast, "<source>")?;
|
||||
Ok(ast)
|
||||
}
|
||||
|
||||
/// Parse `source` as an endpoint script (no module-shape check) and
|
||||
/// return its declared literal-path imports. Used by
|
||||
/// `Engine::validate` to populate `ValidatedScript::imports` so the
|
||||
/// repo can write dep-graph edges.
|
||||
pub fn extract_imports(engine: &RhaiEngine, source: &str) -> Result<ValidatedScript, String> {
|
||||
let ast = engine.compile(source).map_err(|e| e.to_string())?;
|
||||
Ok(ValidatedScript {
|
||||
imports: PicloudModuleResolver::extract_imports_inner(&ast),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse `source` as a module script: enforce shape, then extract
|
||||
/// imports. Used by `Engine::validate_module`.
|
||||
pub fn validate_module_source(
|
||||
engine: &RhaiEngine,
|
||||
source: &str,
|
||||
) -> Result<ValidatedScript, String> {
|
||||
let ast = compile_module_ast(engine, source)?;
|
||||
Ok(ValidatedScript {
|
||||
imports: PicloudModuleResolver::extract_imports_inner(&ast),
|
||||
})
|
||||
}
|
||||
|
||||
fn stmt_kind_label(stmt: &rhai::Stmt) -> &'static str {
|
||||
use rhai::ASTFlags;
|
||||
match stmt {
|
||||
rhai::Stmt::Var(_, opts, _) if opts.intersects(ASTFlags::CONSTANT) => "const declaration",
|
||||
rhai::Stmt::Var(..) => "let declaration",
|
||||
rhai::Stmt::Expr(..) => "expression",
|
||||
rhai::Stmt::FnCall(..) => "function call",
|
||||
rhai::Stmt::If(..) => "if statement",
|
||||
rhai::Stmt::Switch(..) => "switch statement",
|
||||
rhai::Stmt::While(..) => "while/loop statement",
|
||||
rhai::Stmt::Do(..) => "do statement",
|
||||
rhai::Stmt::For(..) => "for statement",
|
||||
rhai::Stmt::Assignment(..) => "assignment",
|
||||
rhai::Stmt::Block(..) => "block",
|
||||
rhai::Stmt::TryCatch(..) => "try/catch",
|
||||
rhai::Stmt::Return(..) => "return/throw statement",
|
||||
rhai::Stmt::BreakLoop(..) => "break/continue",
|
||||
rhai::Stmt::Import(..) => "import statement",
|
||||
rhai::Stmt::Export(..) => "export statement",
|
||||
_ => "statement",
|
||||
}
|
||||
}
|
||||
|
||||
impl ModuleResolver for PicloudModuleResolver {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn resolve(
|
||||
&self,
|
||||
engine: &RhaiEngine,
|
||||
_source: Option<&str>,
|
||||
path: &str,
|
||||
pos: Position,
|
||||
) -> Result<SharedRhaiModule, Box<EvalAltResult>> {
|
||||
// RAII guard wraps both the depth counter and the import-stack
|
||||
// push so that any early return (cycle / depth-exceeded / DB
|
||||
// error / compile error / panic) leaves both consistent for
|
||||
// any subsequent resolve() call on this resolver instance.
|
||||
struct StackGuard<'r> {
|
||||
stack: &'r Mutex<Vec<String>>,
|
||||
depth: &'r Mutex<u32>,
|
||||
armed: bool,
|
||||
}
|
||||
impl Drop for StackGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
if !self.armed {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut s) = self.stack.lock() {
|
||||
s.pop();
|
||||
}
|
||||
if let Ok(mut d) = self.depth.lock() {
|
||||
*d = d.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read-only check + atomic push under one lock pair, so a
|
||||
// sibling resolve() call on a shared resolver instance can't
|
||||
// race in between. (We don't expect parallel calls on the same
|
||||
// resolver — Rhai evaluates a single AST on one thread — but
|
||||
// grouping the operations is cheaper than reasoning about the
|
||||
// future.)
|
||||
{
|
||||
let mut depth = self.depth.lock().expect("module depth lock poisoned");
|
||||
if *depth >= self.depth_limit {
|
||||
return Err(Box::new(EvalAltResult::ErrorInModule(
|
||||
path.to_string(),
|
||||
Box::new(EvalAltResult::ErrorRuntime(
|
||||
format!(
|
||||
"import depth limit ({}) exceeded while resolving {path:?}",
|
||||
self.depth_limit
|
||||
)
|
||||
.into(),
|
||||
pos,
|
||||
)),
|
||||
pos,
|
||||
)));
|
||||
}
|
||||
let mut stack = self
|
||||
.in_progress
|
||||
.lock()
|
||||
.expect("module in_progress lock poisoned");
|
||||
if stack.iter().any(|p| p == path) {
|
||||
let mut chain = stack.clone();
|
||||
chain.push(path.to_string());
|
||||
return Err(Box::new(EvalAltResult::ErrorInModule(
|
||||
path.to_string(),
|
||||
Box::new(EvalAltResult::ErrorRuntime(
|
||||
format!("circular import detected: {}", chain.join(" -> ")).into(),
|
||||
pos,
|
||||
)),
|
||||
pos,
|
||||
)));
|
||||
}
|
||||
stack.push(path.to_string());
|
||||
*depth += 1;
|
||||
}
|
||||
let _guard = StackGuard {
|
||||
stack: &self.in_progress,
|
||||
depth: &self.depth,
|
||||
armed: true,
|
||||
};
|
||||
|
||||
// Bridge to async. The resolver typically runs on a
|
||||
// `spawn_blocking` thread (see LocalExecutorClient in
|
||||
// orchestrator-core), but tests may invoke `Engine::execute`
|
||||
// directly from a multi-threaded Tokio task. `try_current` +
|
||||
// `block_in_place` covers both — on a blocking thread it's a
|
||||
// no-op, on a worker thread it tells the runtime to relocate
|
||||
// other tasks. `current_thread` runtimes still panic; non-
|
||||
// Tokio contexts surface a clean Runtime error.
|
||||
let handle = tokio::runtime::Handle::try_current().map_err(|_| {
|
||||
Box::new(EvalAltResult::ErrorInModule(
|
||||
path.to_string(),
|
||||
Box::new(EvalAltResult::ErrorRuntime(
|
||||
"module resolver invoked outside a Tokio runtime; \
|
||||
wrap Engine::execute in tokio::task::spawn_blocking"
|
||||
.into(),
|
||||
pos,
|
||||
)),
|
||||
pos,
|
||||
))
|
||||
})?;
|
||||
|
||||
let lookup_result: Result<Option<picloud_shared::ModuleScript>, ModuleSourceError> =
|
||||
tokio::task::block_in_place(|| handle.block_on(self.source.lookup(&self.cx, path)));
|
||||
|
||||
let module_row = match lookup_result {
|
||||
Ok(Some(m)) => m,
|
||||
Ok(None) => {
|
||||
return Err(Box::new(EvalAltResult::ErrorModuleNotFound(
|
||||
path.to_string(),
|
||||
pos,
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
// v1.1.4 §10a: redact the backend error before it
|
||||
// reaches a script. In public-HTTP context (principal:
|
||||
// None) the verbatim message (e.g. "connection refused")
|
||||
// leaks internal infrastructure shape. Log the original
|
||||
// at error level for operators; surface a stable generic.
|
||||
tracing::error!(
|
||||
target = "picloud::modules",
|
||||
app_id = %self.cx.app_id,
|
||||
module = path,
|
||||
error = %e,
|
||||
"module backend error"
|
||||
);
|
||||
return Err(Box::new(EvalAltResult::ErrorInModule(
|
||||
path.to_string(),
|
||||
Box::new(EvalAltResult::ErrorRuntime(
|
||||
"module backend unavailable; check server logs".into(),
|
||||
pos,
|
||||
)),
|
||||
pos,
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// Cache lookup: hit only if both key matches AND updated_at
|
||||
// matches (cache is invalidated lazily on version change).
|
||||
let cache_key = (self.cx.app_id, path.to_string());
|
||||
{
|
||||
let mut cache = self.cache.lock().expect("module cache lock poisoned");
|
||||
if let Some(cached) = cache.get(&cache_key) {
|
||||
if cached.updated_at == module_row.updated_at {
|
||||
tracing::debug!(
|
||||
target = "picloud::modules::cache",
|
||||
app_id = %self.cx.app_id,
|
||||
module = path,
|
||||
"cache hit"
|
||||
);
|
||||
return Ok(cached.module.clone());
|
||||
}
|
||||
tracing::debug!(
|
||||
target = "picloud::modules::cache",
|
||||
app_id = %self.cx.app_id,
|
||||
module = path,
|
||||
"cache stale; recompiling"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
target = "picloud::modules::cache",
|
||||
app_id = %self.cx.app_id,
|
||||
module = path,
|
||||
"cache miss"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Compile + module-shape validation. Module sources MAY have
|
||||
// already been gated at create-time (admin endpoint runs
|
||||
// `validate_module`), but we revalidate here to catch DB-direct
|
||||
// inserts that bypass the API surface.
|
||||
let ast = engine.compile(&module_row.source).map_err(|e| {
|
||||
// Wrap as an ErrorRuntime to preserve the parse message
|
||||
// text without trying to reconstruct rhai's internal
|
||||
// ParseErrorType variant (which would require matching on
|
||||
// its full variant set).
|
||||
Box::new(EvalAltResult::ErrorInModule(
|
||||
path.to_string(),
|
||||
Box::new(EvalAltResult::ErrorRuntime(
|
||||
format!("module {path:?} parse error: {e}").into(),
|
||||
e.position(),
|
||||
)),
|
||||
pos,
|
||||
))
|
||||
})?;
|
||||
|
||||
if let Err(msg) = Self::check_module_shape(&ast, path) {
|
||||
return Err(Box::new(EvalAltResult::ErrorInModule(
|
||||
path.to_string(),
|
||||
Box::new(EvalAltResult::ErrorRuntime(msg.into(), pos)),
|
||||
pos,
|
||||
)));
|
||||
}
|
||||
|
||||
// Rhai's eval_ast_as_new compiles the AST's body + functions
|
||||
// into a Module that the importing script consumes via
|
||||
// `path::fn(...)` calls. Recursive imports inside this module
|
||||
// are resolved through the same `engine.set_module_resolver`
|
||||
// (which is THIS resolver), so cycle/depth tracking carries
|
||||
// through naturally.
|
||||
let module = Module::eval_ast_as_new(rhai::Scope::new(), &ast, engine)
|
||||
.map_err(|e| Box::new(EvalAltResult::ErrorInModule(path.to_string(), e, pos)))?;
|
||||
let shared: SharedRhaiModule = module.into();
|
||||
|
||||
// Insert (possibly evicting via LRU). Subsequent imports of
|
||||
// the same module under the same updated_at hit the cache.
|
||||
{
|
||||
let mut cache = self.cache.lock().expect("module cache lock poisoned");
|
||||
cache.put(
|
||||
cache_key,
|
||||
CachedModule {
|
||||
updated_at: module_row.updated_at,
|
||||
module: shared.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(shared)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,12 @@ pub struct Limits {
|
||||
/// Max call/expression nesting depth.
|
||||
pub max_call_levels: usize,
|
||||
pub max_expr_depth: usize,
|
||||
|
||||
/// v1.1.3: hard ceiling on `import` chain depth (A→B→C→…). Independent
|
||||
/// of cycle detection — guards against deep but acyclic graphs.
|
||||
/// Not script-overridable (this is a platform-level guard, not a
|
||||
/// per-script knob).
|
||||
pub module_import_depth_max: u32,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
@@ -35,6 +41,7 @@ impl Default for Limits {
|
||||
max_map_size: 10_000,
|
||||
max_call_levels: 64,
|
||||
max_expr_depth: 64,
|
||||
module_import_depth_max: 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +72,9 @@ impl Limits {
|
||||
max_expr_depth: overrides
|
||||
.max_expr_depth
|
||||
.map_or(self.max_expr_depth, narrow_usize),
|
||||
// module_import_depth_max is platform-level — overrides
|
||||
// never touch it. Carry through unchanged.
|
||||
module_import_depth_max: self.module_import_depth_max,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
150
crates/executor-core/src/sdk/email.rs
Normal file
150
crates/executor-core/src/sdk/email.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
//! `email::` Rhai bridge — outbound email (v1.1.7).
|
||||
//!
|
||||
//! ```rhai
|
||||
//! email::send(#{
|
||||
//! to: "alice@example.com", // String or Array of String
|
||||
//! from: "alerts@myapp.com",
|
||||
//! subject: "Build complete",
|
||||
//! text: "Your deploy finished."
|
||||
//! });
|
||||
//!
|
||||
//! email::send_html(#{
|
||||
//! to: ["alice@x.com", "bob@y.com"],
|
||||
//! cc: ["dave@z.com"],
|
||||
//! bcc: ["audit@myapp.com"],
|
||||
//! from: "alerts@myapp.com",
|
||||
//! reply_to: "support@myapp.com", // optional; defaults to `from`
|
||||
//! subject: "Build complete",
|
||||
//! text: "Your deploy finished.", // plain-text fallback
|
||||
//! html: "<p>Your deploy <b>finished</b>.</p>"
|
||||
//! });
|
||||
//! ```
|
||||
//!
|
||||
//! Both map onto `EmailService::send`. `email::send` forces a text-only
|
||||
//! message (any `html` key is ignored); `email::send_html` requires an
|
||||
//! `html` part. `app_id` is derived from `cx.app_id` in the service.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{EmailError, OutboundEmail, SdkCallCx, Services};
|
||||
use rhai::{Array, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let svc = services.email.clone();
|
||||
let mut module = Module::new();
|
||||
|
||||
// email::send(#{...}) — plain text (html ignored).
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn("send", move |opts: Map| -> Result<(), Box<EvalAltResult>> {
|
||||
let mut email = parse_email(&opts)?;
|
||||
email.html = None; // text-only path
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.send(&cx, email).await })
|
||||
});
|
||||
}
|
||||
|
||||
// email::send_html(#{...}) — multipart text + html (html required).
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"send_html",
|
||||
move |opts: Map| -> Result<(), Box<EvalAltResult>> {
|
||||
let email = parse_email(&opts)?;
|
||||
if email.html.as_ref().is_none_or(String::is_empty) {
|
||||
return Err(runtime_err(
|
||||
"email::send_html: an 'html' field is required (use email::send for text-only)",
|
||||
));
|
||||
}
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.send(&cx, email).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
engine.register_static_module("email", module.into());
|
||||
}
|
||||
|
||||
/// Parse the Rhai options map into an [`OutboundEmail`]. Field-level
|
||||
/// validation (required fields, address shape) happens in the service;
|
||||
/// here we only do type coercion (String/Array → Vec<String>).
|
||||
fn parse_email(opts: &Map) -> Result<OutboundEmail, Box<EvalAltResult>> {
|
||||
Ok(OutboundEmail {
|
||||
to: addresses(opts, "to")?,
|
||||
cc: addresses(opts, "cc")?,
|
||||
bcc: addresses(opts, "bcc")?,
|
||||
from: string_field(opts, "from").unwrap_or_default(),
|
||||
reply_to: string_field(opts, "reply_to"),
|
||||
subject: string_field(opts, "subject").unwrap_or_default(),
|
||||
text: string_field(opts, "text"),
|
||||
html: string_field(opts, "html"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a string field. Missing or `()` → `None`.
|
||||
fn string_field(opts: &Map, key: &str) -> Option<String> {
|
||||
match opts.get(key) {
|
||||
None => None,
|
||||
Some(d) if d.is_unit() => None,
|
||||
Some(d) if d.is_string() => Some(d.clone().into_string().unwrap_or_default()),
|
||||
// Coerce non-string scalars via display (numbers, etc.).
|
||||
Some(d) => Some(d.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an address list: a String becomes a one-element list; an Array
|
||||
/// of Strings becomes a list; missing/`()` is empty.
|
||||
fn addresses(opts: &Map, key: &str) -> Result<Vec<String>, Box<EvalAltResult>> {
|
||||
match opts.get(key) {
|
||||
None => Ok(Vec::new()),
|
||||
Some(d) if d.is_unit() => Ok(Vec::new()),
|
||||
Some(d) if d.is_string() => Ok(vec![d.clone().into_string().unwrap_or_default()]),
|
||||
Some(d) => {
|
||||
if let Some(arr) = d.clone().try_cast::<Array>() {
|
||||
let mut out = Vec::with_capacity(arr.len());
|
||||
for el in arr {
|
||||
if !el.is_string() {
|
||||
return Err(runtime_err(&format!(
|
||||
"email: '{key}' array must contain only strings"
|
||||
)));
|
||||
}
|
||||
out.push(el.into_string().unwrap_or_default());
|
||||
}
|
||||
Ok(out)
|
||||
} else {
|
||||
Err(runtime_err(&format!(
|
||||
"email: '{key}' must be a string or an array of strings"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_box_returns)]
|
||||
fn runtime_err(msg: &str) -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(msg.into(), rhai::Position::NONE).into()
|
||||
}
|
||||
|
||||
/// Run an `EmailService` future inside the synchronous Rhai context,
|
||||
/// mapping any `EmailError` to a Rhai runtime error. Mirrors
|
||||
/// `kv::block_on`.
|
||||
fn block_on<F>(fut: F) -> Result<(), Box<EvalAltResult>>
|
||||
where
|
||||
F: std::future::Future<Output = Result<(), EmailError>> + Send,
|
||||
{
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("email: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("email: {err}").into(), rhai::Position::NONE).into()
|
||||
})
|
||||
}
|
||||
281
crates/executor-core/src/sdk/files.rs
Normal file
281
crates/executor-core/src/sdk/files.rs
Normal file
@@ -0,0 +1,281 @@
|
||||
//! `files::` Rhai bridge — collection-scoped handle pattern (v1.1.5).
|
||||
//!
|
||||
//! ```rhai
|
||||
//! let avatars = files::collection("avatars");
|
||||
//! let id = avatars.create(#{ name: "a.jpg", content_type: "image/jpeg", data: blob });
|
||||
//! let meta = avatars.head(id); // metadata map or ()
|
||||
//! let bytes = avatars.get(id); // Blob or ()
|
||||
//! avatars.update(id, #{ data: new_bytes });
|
||||
//! let gone = avatars.delete(id); // bool (was-present)
|
||||
//! let page = avatars.list(); // #{ files: [...], next_cursor: () }
|
||||
//! ```
|
||||
//!
|
||||
//! The `FilesHandle` custom Rhai type captures the collection name once
|
||||
//! and routes each call through the injected `Arc<dyn FilesService>`
|
||||
//! with the per-call `Arc<SdkCallCx>`. **The service derives `app_id`
|
||||
//! from `cx.app_id` — it never appears in any signature script-side,
|
||||
//! preserving cross-app isolation.**
|
||||
//!
|
||||
//! Error convention (per `docs/sdk-shape.md`): `create`/`update`/
|
||||
//! `delete` throw on failure; `get`/`head` return `()` for a missing
|
||||
//! file; `delete` returns `bool` (was-present). The blob bytes are a
|
||||
//! Rhai `Blob` (byte array) in both directions.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{
|
||||
FileMeta, FileUpdate, FilesError, FilesService, NewFile, SdkCallCx, Services,
|
||||
};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
|
||||
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
||||
/// plus an owned string).
|
||||
#[derive(Clone)]
|
||||
pub struct FilesHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn FilesService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let files_service = services.files.clone();
|
||||
|
||||
let mut module = Module::new();
|
||||
{
|
||||
let files_service = files_service.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"collection",
|
||||
move |name: &str| -> Result<FilesHandle, Box<EvalAltResult>> {
|
||||
if name.is_empty() {
|
||||
return Err("files::collection name must not be empty".into());
|
||||
}
|
||||
Ok(FilesHandle {
|
||||
collection: name.to_string(),
|
||||
service: files_service.clone(),
|
||||
cx: cx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
engine.register_static_module("files", module.into());
|
||||
|
||||
engine.register_type_with_name::<FilesHandle>("FilesHandle");
|
||||
|
||||
register_create(engine);
|
||||
register_head(engine);
|
||||
register_get(engine);
|
||||
register_update(engine);
|
||||
register_delete(engine);
|
||||
register_list(engine);
|
||||
}
|
||||
|
||||
fn register_create(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"create",
|
||||
|handle: &mut FilesHandle, meta: Map| -> Result<String, Box<EvalAltResult>> {
|
||||
let name = require_string(&meta, "name")?;
|
||||
let content_type = require_string(&meta, "content_type")?;
|
||||
let data = require_blob(&meta, "data")?;
|
||||
let h = handle.clone();
|
||||
let new = NewFile {
|
||||
name,
|
||||
content_type,
|
||||
data,
|
||||
};
|
||||
let id = block_on(async move { h.service.create(&h.cx, &h.collection, new).await })?;
|
||||
Ok(id.to_string())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_head(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"head",
|
||||
|handle: &mut FilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let id = id.to_string();
|
||||
let meta = block_on(async move { h.service.head(&h.cx, &h.collection, &id).await })?;
|
||||
Ok(meta.map_or(Dynamic::UNIT, |m| file_meta_to_map(&m).into()))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_get(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"get",
|
||||
|handle: &mut FilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let id = id.to_string();
|
||||
let bytes = block_on(async move { h.service.get(&h.cx, &h.collection, &id).await })?;
|
||||
Ok(bytes.map_or(Dynamic::UNIT, Dynamic::from_blob))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_update(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"update",
|
||||
|handle: &mut FilesHandle, id: &str, meta: Map| -> Result<(), Box<EvalAltResult>> {
|
||||
let data = require_blob(&meta, "data")?;
|
||||
let name = optional_string(&meta, "name")?;
|
||||
let content_type = optional_string(&meta, "content_type")?;
|
||||
let h = handle.clone();
|
||||
let id = id.to_string();
|
||||
let upd = FileUpdate {
|
||||
data,
|
||||
name,
|
||||
content_type,
|
||||
};
|
||||
block_on(async move { h.service.update(&h.cx, &h.collection, &id, upd).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_delete(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"delete",
|
||||
|handle: &mut FilesHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let id = id.to_string();
|
||||
block_on(async move { h.service.delete(&h.cx, &h.collection, &id).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_list(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut FilesHandle| -> Result<Map, Box<EvalAltResult>> {
|
||||
list_call(handle, None, 0)
|
||||
},
|
||||
);
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut FilesHandle, cursor: &str| -> Result<Map, Box<EvalAltResult>> {
|
||||
list_call(handle, Some(cursor.to_string()), 0)
|
||||
},
|
||||
);
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut FilesHandle, cursor: &str, limit: i64| -> Result<Map, Box<EvalAltResult>> {
|
||||
let limit = u32::try_from(limit.max(0)).unwrap_or(0);
|
||||
list_call(handle, Some(cursor.to_string()), limit)
|
||||
},
|
||||
);
|
||||
// `list(#{ cursor, limit })` — the map form documented in the brief.
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut FilesHandle, opts: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||
let cursor = match opts.get("cursor") {
|
||||
Some(v) if !v.is_unit() => {
|
||||
Some(v.clone().into_string().map_err(|_| -> Box<EvalAltResult> {
|
||||
"files: list cursor must be a string".into()
|
||||
})?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let limit = match opts.get("limit") {
|
||||
Some(v) if !v.is_unit() => {
|
||||
u32::try_from(v.as_int().unwrap_or(0).max(0)).unwrap_or(0)
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
list_call(handle, cursor, limit)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn list_call(
|
||||
handle: &FilesHandle,
|
||||
cursor: Option<String>,
|
||||
limit: u32,
|
||||
) -> Result<Map, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let page = block_on(async move {
|
||||
h.service
|
||||
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
||||
.await
|
||||
})?;
|
||||
let mut m = Map::new();
|
||||
let files: Array = page
|
||||
.files
|
||||
.iter()
|
||||
.map(|meta| Dynamic::from(file_meta_to_map(meta)))
|
||||
.collect();
|
||||
m.insert("files".into(), files.into());
|
||||
m.insert(
|
||||
"next_cursor".into(),
|
||||
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Render a `FileMeta` into the Rhai map shape scripts see from
|
||||
/// `head` / `list`.
|
||||
fn file_meta_to_map(meta: &FileMeta) -> Map {
|
||||
let mut m = Map::new();
|
||||
m.insert("id".into(), meta.id.to_string().into());
|
||||
m.insert("collection".into(), meta.collection.clone().into());
|
||||
m.insert("name".into(), meta.name.clone().into());
|
||||
m.insert("content_type".into(), meta.content_type.clone().into());
|
||||
m.insert(
|
||||
"size".into(),
|
||||
i64::try_from(meta.size).unwrap_or(i64::MAX).into(),
|
||||
);
|
||||
m.insert("checksum".into(), meta.checksum.clone().into());
|
||||
m.insert("created_at".into(), meta.created_at.to_rfc3339().into());
|
||||
m.insert("updated_at".into(), meta.updated_at.to_rfc3339().into());
|
||||
m
|
||||
}
|
||||
|
||||
/// Pull a required string field out of a Rhai map; throw naming the
|
||||
/// field if it's absent or not a string.
|
||||
fn require_string(meta: &Map, field: &'static str) -> Result<String, Box<EvalAltResult>> {
|
||||
match meta.get(field) {
|
||||
Some(v) if v.is_string() => Ok(v.clone().into_string().unwrap_or_default()),
|
||||
Some(_) => Err(format!("files::create: field '{field}' must be a string").into()),
|
||||
None => Err(format!("files::create: missing required field '{field}'").into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull an optional string field; `None` when the key is absent or unit.
|
||||
fn optional_string(meta: &Map, field: &'static str) -> Result<Option<String>, Box<EvalAltResult>> {
|
||||
match meta.get(field) {
|
||||
None => Ok(None),
|
||||
Some(v) if v.is_unit() => Ok(None),
|
||||
Some(v) if v.is_string() => Ok(Some(v.clone().into_string().unwrap_or_default())),
|
||||
Some(_) => Err(format!("files::update: field '{field}' must be a string").into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull a required blob (`data`) out of a Rhai map; throw naming the
|
||||
/// field if it's absent or not a blob.
|
||||
fn require_blob(meta: &Map, field: &'static str) -> Result<Vec<u8>, Box<EvalAltResult>> {
|
||||
match meta.get(field) {
|
||||
Some(v) if v.is_blob() => Ok(v.clone().into_blob().unwrap_or_default()),
|
||||
Some(_) => Err(format!("files: field '{field}' must be a Blob (byte array)").into()),
|
||||
None => Err(format!("files: missing required field '{field}'").into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run an async future inside the synchronous Rhai context. Mirrors
|
||||
/// `kv::block_on`; safe because `LocalExecutorClient` runs the script
|
||||
/// under `spawn_blocking`, so a runtime handle is reachable.
|
||||
fn block_on<F, T>(fut: F) -> Result<T, Box<EvalAltResult>>
|
||||
where
|
||||
F: std::future::Future<Output = Result<T, FilesError>> + Send,
|
||||
T: Send,
|
||||
{
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("files: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("files: {err}").into(), rhai::Position::NONE).into()
|
||||
})
|
||||
}
|
||||
391
crates/executor-core/src/sdk/http.rs
Normal file
391
crates/executor-core/src/sdk/http.rs
Normal file
@@ -0,0 +1,391 @@
|
||||
//! `http::` Rhai bridge — outbound HTTP from scripts (v1.1.4).
|
||||
//!
|
||||
//! ```rhai
|
||||
//! let r = http::get("https://api.example.com/users/123");
|
||||
//! let r = http::get(url, #{ headers: #{ "Authorization": "Bearer x" }, timeout_ms: 5000 });
|
||||
//! let r = http::post(url, #{ text: "hello" }); // Map body → JSON
|
||||
//! let r = http::post(url, "raw", #{ headers: #{ ... } }); // String body → text/plain
|
||||
//! let r = http::post_form(url, #{ a: "1", b: "2" }); // form-encoded
|
||||
//! let r = http::request("OPTIONS", url);
|
||||
//! ```
|
||||
//!
|
||||
//! **Argument shape (v1.1.4 decision):** body and options are separate
|
||||
//! positional arguments — `verb(url, body, opts)` — not body-inside-
|
||||
//! opts. This keeps the unknown-opt-key typo guard intact and resolves
|
||||
//! the brief's internal contradiction (its Slack example passed a bare
|
||||
//! body map). The `opts` vocabulary is exactly
|
||||
//! `{headers, timeout_ms, follow_redirects, max_redirects}`; any other
|
||||
//! key throws.
|
||||
//!
|
||||
//! Body dispatch (positional `body`): Map/Array → JSON +
|
||||
//! `application/json`; String → raw + `text/plain`; Unit `()` → no
|
||||
//! body. GET/HEAD ignore any body.
|
||||
//!
|
||||
//! Response is a Rhai map `#{ status, headers, body, body_raw }`:
|
||||
//! `body` is the parsed JSON when the response is `application/json`
|
||||
//! and parses; `()` for an empty body; otherwise the raw string.
|
||||
//!
|
||||
//! Errors follow `docs/sdk-shape.md`: network/timeout/SSRF/size failures
|
||||
//! throw (`"http: <message>"`); a non-2xx status does NOT throw — the
|
||||
//! response map is returned, fetch-style.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{HttpError, HttpRequest, HttpResponse, HttpService, SdkCallCx, Services};
|
||||
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
|
||||
use super::bridge::{dynamic_to_json, json_to_dynamic};
|
||||
|
||||
/// Bridge-side defaults (the service clamps server-side too). The
|
||||
/// `MAX_*` ceilings stay `i64` because they're compared against the
|
||||
/// raw `i64` the script passed (so an over-limit value is rejected, not
|
||||
/// truncated); the defaults are `u32` to match the `Opts` fields.
|
||||
const DEFAULT_TIMEOUT_MS: u32 = 30_000;
|
||||
const MAX_TIMEOUT_MS: i64 = 60_000;
|
||||
const DEFAULT_MAX_REDIRECTS: u32 = 5;
|
||||
const MAX_REDIRECTS: i64 = 10;
|
||||
|
||||
const ALLOWED_OPT_KEYS: [&str; 4] = ["headers", "timeout_ms", "follow_redirects", "max_redirects"];
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let svc = services.http.clone();
|
||||
let mut module = Module::new();
|
||||
|
||||
// Bodyless verbs: (url) / (url, opts).
|
||||
for verb in ["get", "head"] {
|
||||
register_bodyless(&mut module, verb, &svc, &cx);
|
||||
}
|
||||
// Body verbs: (url) / (url, body) / (url, body, opts).
|
||||
for verb in ["post", "put", "patch", "delete"] {
|
||||
register_body(&mut module, verb, &svc, &cx);
|
||||
}
|
||||
register_post_form(&mut module, &svc, &cx);
|
||||
register_request(&mut module, &svc, &cx);
|
||||
|
||||
engine.register_static_module("http", module.into());
|
||||
}
|
||||
|
||||
fn register_bodyless(
|
||||
module: &mut Module,
|
||||
verb: &'static str,
|
||||
svc: &Arc<dyn HttpService>,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
) {
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn(verb, move |url: &str| {
|
||||
invoke(&svc, &cx, verb, url, None, None)
|
||||
});
|
||||
}
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn(verb, move |url: &str, opts: Map| {
|
||||
invoke(&svc, &cx, verb, url, None, Some(&opts))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn register_body(
|
||||
module: &mut Module,
|
||||
verb: &'static str,
|
||||
svc: &Arc<dyn HttpService>,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
) {
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn(verb, move |url: &str| {
|
||||
invoke(&svc, &cx, verb, url, None, None)
|
||||
});
|
||||
}
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn(verb, move |url: &str, body: Dynamic| {
|
||||
invoke(&svc, &cx, verb, url, Some(body), None)
|
||||
});
|
||||
}
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn(verb, move |url: &str, body: Dynamic, opts: Map| {
|
||||
invoke(&svc, &cx, verb, url, Some(body), Some(&opts))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn register_post_form(module: &mut Module, svc: &Arc<dyn HttpService>, cx: &Arc<SdkCallCx>) {
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn("post_form", move |url: &str, form: Map| {
|
||||
invoke_form(&svc, &cx, url, &form, None)
|
||||
});
|
||||
}
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn("post_form", move |url: &str, form: Map, opts: Map| {
|
||||
invoke_form(&svc, &cx, url, &form, Some(&opts))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn register_request(module: &mut Module, svc: &Arc<dyn HttpService>, cx: &Arc<SdkCallCx>) {
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn("request", move |method: &str, url: &str| {
|
||||
invoke(&svc, &cx, method, url, None, None)
|
||||
});
|
||||
}
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn("request", move |method: &str, url: &str, body: Dynamic| {
|
||||
invoke(&svc, &cx, method, url, Some(body), None)
|
||||
});
|
||||
}
|
||||
{
|
||||
let (svc, cx) = (svc.clone(), cx.clone());
|
||||
module.set_native_fn(
|
||||
"request",
|
||||
move |method: &str, url: &str, body: Dynamic, opts: Map| {
|
||||
invoke(&svc, &cx, method, url, Some(body), Some(&opts))
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed `opts` map.
|
||||
struct Opts {
|
||||
headers: BTreeMap<String, String>,
|
||||
timeout_ms: u32,
|
||||
follow_redirects: bool,
|
||||
max_redirects: u32,
|
||||
}
|
||||
|
||||
impl Default for Opts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
headers: BTreeMap::new(),
|
||||
timeout_ms: DEFAULT_TIMEOUT_MS,
|
||||
follow_redirects: true,
|
||||
max_redirects: DEFAULT_MAX_REDIRECTS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_opts(opts: Option<&Map>) -> Result<Opts, Box<EvalAltResult>> {
|
||||
let mut out = Opts::default();
|
||||
let Some(map) = opts else {
|
||||
return Ok(out);
|
||||
};
|
||||
for key in map.keys() {
|
||||
if !ALLOWED_OPT_KEYS.contains(&key.as_str()) {
|
||||
return Err(err(format!("unknown option key: {key}")));
|
||||
}
|
||||
}
|
||||
if let Some(h) = map.get("headers") {
|
||||
let hm = h
|
||||
.clone()
|
||||
.try_cast::<Map>()
|
||||
.ok_or_else(|| err("headers must be a map".to_string()))?;
|
||||
for (k, v) in hm {
|
||||
out.headers.insert(k.to_string(), dyn_to_string(&v));
|
||||
}
|
||||
}
|
||||
if let Some(t) = map.get("timeout_ms") {
|
||||
let ms = t
|
||||
.as_int()
|
||||
.map_err(|_| err("timeout_ms must be an integer".to_string()))?;
|
||||
if ms > MAX_TIMEOUT_MS {
|
||||
return Err(err(format!(
|
||||
"timeout_ms {ms} exceeds the {MAX_TIMEOUT_MS}ms maximum"
|
||||
)));
|
||||
}
|
||||
if ms > 0 {
|
||||
out.timeout_ms = u32::try_from(ms).unwrap_or(u32::MAX);
|
||||
}
|
||||
}
|
||||
if let Some(f) = map.get("follow_redirects") {
|
||||
out.follow_redirects = f
|
||||
.as_bool()
|
||||
.map_err(|_| err("follow_redirects must be a bool".to_string()))?;
|
||||
}
|
||||
if let Some(m) = map.get("max_redirects") {
|
||||
let n = m
|
||||
.as_int()
|
||||
.map_err(|_| err("max_redirects must be an integer".to_string()))?;
|
||||
if n > MAX_REDIRECTS {
|
||||
return Err(err(format!(
|
||||
"max_redirects {n} exceeds the {MAX_REDIRECTS} maximum"
|
||||
)));
|
||||
}
|
||||
out.max_redirects = u32::try_from(n.max(0)).unwrap_or(0);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Encoded request body + the content-type chosen for it.
|
||||
type EncodedBody = (Option<Vec<u8>>, Option<String>);
|
||||
|
||||
/// Dispatch a positional body by Rhai type. Returns the encoded bytes +
|
||||
/// the chosen content-type. GET/HEAD callers pass `body = None`, so
|
||||
/// this is never reached for them.
|
||||
fn dispatch_body(body: Dynamic) -> Result<EncodedBody, Box<EvalAltResult>> {
|
||||
if body.is_unit() {
|
||||
return Ok((None, None));
|
||||
}
|
||||
if body.is_string() {
|
||||
let s = body.into_string().unwrap_or_default();
|
||||
return Ok((Some(s.into_bytes()), Some("text/plain".to_string())));
|
||||
}
|
||||
if body.is_map() || body.is_array() {
|
||||
let json = dynamic_to_json(&body);
|
||||
let bytes = serde_json::to_vec(&json)
|
||||
.map_err(|e| err(format!("could not encode JSON body: {e}")))?;
|
||||
return Ok((Some(bytes), Some("application/json".to_string())));
|
||||
}
|
||||
// Scalars (int/float/bool) → JSON-encode for consistency.
|
||||
let json = dynamic_to_json(&body);
|
||||
let bytes =
|
||||
serde_json::to_vec(&json).map_err(|e| err(format!("could not encode body: {e}")))?;
|
||||
Ok((Some(bytes), Some("application/json".to_string())))
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
fn invoke(
|
||||
svc: &Arc<dyn HttpService>,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
method: &str,
|
||||
url: &str,
|
||||
body: Option<Dynamic>,
|
||||
opts: Option<&Map>,
|
||||
) -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let opts = parse_opts(opts)?;
|
||||
let method_uc = method.to_ascii_uppercase();
|
||||
let bodyless = matches!(method_uc.as_str(), "GET" | "HEAD");
|
||||
let (encoded, content_type) = if bodyless {
|
||||
(None, None)
|
||||
} else if let Some(b) = body {
|
||||
dispatch_body(b)?
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let req = HttpRequest {
|
||||
method: method_uc,
|
||||
url: url.to_string(),
|
||||
headers: opts.headers,
|
||||
body: encoded,
|
||||
content_type,
|
||||
timeout_ms: opts.timeout_ms,
|
||||
follow_redirects: opts.follow_redirects,
|
||||
max_redirects: opts.max_redirects,
|
||||
script_id: Some(cx.script_id.to_string()),
|
||||
};
|
||||
let resp = block_on(svc, cx, req)?;
|
||||
Ok(response_to_dynamic(&resp))
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
fn invoke_form(
|
||||
svc: &Arc<dyn HttpService>,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
url: &str,
|
||||
form: &Map,
|
||||
opts: Option<&Map>,
|
||||
) -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let opts = parse_opts(opts)?;
|
||||
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
||||
for (k, v) in form {
|
||||
serializer.append_pair(k.as_str(), &dyn_to_string(v));
|
||||
}
|
||||
let encoded = serializer.finish();
|
||||
|
||||
let req = HttpRequest {
|
||||
method: "POST".to_string(),
|
||||
url: url.to_string(),
|
||||
headers: opts.headers,
|
||||
body: Some(encoded.into_bytes()),
|
||||
content_type: Some("application/x-www-form-urlencoded".to_string()),
|
||||
timeout_ms: opts.timeout_ms,
|
||||
follow_redirects: opts.follow_redirects,
|
||||
max_redirects: opts.max_redirects,
|
||||
script_id: Some(cx.script_id.to_string()),
|
||||
};
|
||||
let resp = block_on(svc, cx, req)?;
|
||||
Ok(response_to_dynamic(&resp))
|
||||
}
|
||||
|
||||
fn response_to_dynamic(resp: &HttpResponse) -> Dynamic {
|
||||
let mut m = Map::new();
|
||||
m.insert("status".into(), i64::from(resp.status).into());
|
||||
|
||||
let mut headers = Map::new();
|
||||
let mut content_type = String::new();
|
||||
for (k, v) in &resp.headers {
|
||||
if k == "content-type" {
|
||||
content_type.clone_from(v);
|
||||
}
|
||||
headers.insert(k.clone().into(), v.clone().into());
|
||||
}
|
||||
m.insert("headers".into(), headers.into());
|
||||
|
||||
// `body`: parsed JSON when the response is JSON and parses; () when
|
||||
// empty; otherwise the raw string.
|
||||
let body = if resp.body_raw.is_empty() {
|
||||
Dynamic::UNIT
|
||||
} else if content_type
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("application/json")
|
||||
{
|
||||
match serde_json::from_str::<serde_json::Value>(&resp.body_raw) {
|
||||
Ok(json) => json_to_dynamic(json),
|
||||
Err(_) => resp.body_raw.clone().into(),
|
||||
}
|
||||
} else {
|
||||
resp.body_raw.clone().into()
|
||||
};
|
||||
m.insert("body".into(), body);
|
||||
m.insert("body_raw".into(), resp.body_raw.clone().into());
|
||||
m.into()
|
||||
}
|
||||
|
||||
fn dyn_to_string(v: &Dynamic) -> String {
|
||||
if v.is_string() {
|
||||
v.clone().into_string().unwrap_or_default()
|
||||
} else {
|
||||
v.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// Rhai's native-fn error channel is `Box<EvalAltResult>`, so these
|
||||
// helpers return the boxed form the call sites need.
|
||||
#[allow(clippy::unnecessary_box_returns)]
|
||||
fn err(msg: String) -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("http: {msg}").into(), rhai::Position::NONE).into()
|
||||
}
|
||||
|
||||
/// Run the async service call from the synchronous Rhai context. Same
|
||||
/// pattern as `kv`/`docs`: the script runs under `spawn_blocking`, so a
|
||||
/// runtime handle is reachable and blocking on it is correct.
|
||||
fn block_on(
|
||||
svc: &Arc<dyn HttpService>,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
req: HttpRequest,
|
||||
) -> Result<HttpResponse, Box<EvalAltResult>> {
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("http: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
handle
|
||||
.block_on(async move { svc.request(&cx, req).await })
|
||||
.map_err(map_http_err)
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_box_returns)]
|
||||
fn map_http_err(e: HttpError) -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("http: {e}").into(), rhai::Position::NONE).into()
|
||||
}
|
||||
@@ -15,7 +15,12 @@ pub mod bridge;
|
||||
pub mod cx;
|
||||
pub mod dead_letters;
|
||||
pub mod docs;
|
||||
pub mod email;
|
||||
pub mod files;
|
||||
pub mod http;
|
||||
pub mod kv;
|
||||
pub mod pubsub;
|
||||
pub mod secrets;
|
||||
pub mod stdlib;
|
||||
|
||||
pub use bridge::{dynamic_to_json, json_to_dynamic};
|
||||
@@ -35,5 +40,10 @@ use rhai::Engine as RhaiEngine;
|
||||
pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
kv::register(engine, services, cx.clone());
|
||||
docs::register(engine, services, cx.clone());
|
||||
dead_letters::register(engine, services, cx);
|
||||
dead_letters::register(engine, services, cx.clone());
|
||||
http::register(engine, services, cx.clone());
|
||||
files::register(engine, services, cx.clone());
|
||||
pubsub::register(engine, services, cx.clone());
|
||||
secrets::register(engine, services, cx.clone());
|
||||
email::register(engine, services, cx);
|
||||
}
|
||||
|
||||
176
crates/executor-core/src/sdk/pubsub.rs
Normal file
176
crates/executor-core/src/sdk/pubsub.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
//! `pubsub::` Rhai bridge — durable publish (v1.1.5).
|
||||
//!
|
||||
//! ```rhai
|
||||
//! pubsub::publish_durable("user.created", #{ user_id: "abc" });
|
||||
//! pubsub::publish_durable("metric", 42);
|
||||
//! ```
|
||||
//!
|
||||
//! No handle pattern (topics ARE the grouping unit, so there's no
|
||||
//! `::collection(...)`). The message is any JSON-serializable Rhai value
|
||||
//! — Maps, Arrays, strings, numbers, bools, unit, and **Blobs (which
|
||||
//! encode as base64 strings** so trigger handlers see them as base64 on
|
||||
//! the wire). Nested blobs are encoded at any depth.
|
||||
//!
|
||||
//! `app_id` is derived from `cx.app_id` in the service — it never
|
||||
//! appears in the script-side signature, preserving cross-app
|
||||
//! isolation.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine as _;
|
||||
use picloud_shared::{PubsubError, SdkCallCx, Services};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
use serde_json::Value as Json;
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let svc = services.pubsub.clone();
|
||||
let mut module = Module::new();
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"publish_durable",
|
||||
move |topic: &str, message: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||
let json = message_to_json(&message);
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.publish_durable(&cx, topic, json).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
// `pubsub::subscriber_token(topics)` — uses the configured default
|
||||
// TTL.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"subscriber_token",
|
||||
move |topics: Array| -> Result<String, Box<EvalAltResult>> {
|
||||
mint_token(&svc, &cx, topics, None)
|
||||
},
|
||||
);
|
||||
}
|
||||
// `pubsub::subscriber_token(topics, ttl)` — `ttl` is an integer
|
||||
// (seconds) or `()` for the default.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"subscriber_token",
|
||||
move |topics: Array, ttl: Dynamic| -> Result<String, Box<EvalAltResult>> {
|
||||
let ttl = ttl_from_dynamic(&ttl)?;
|
||||
mint_token(&svc, &cx, topics, ttl)
|
||||
},
|
||||
);
|
||||
}
|
||||
engine.register_static_module("pubsub", module.into());
|
||||
}
|
||||
|
||||
/// Interpret the optional `ttl` argument: `()` → use the default,
|
||||
/// integer → that many seconds, anything else → throw.
|
||||
fn ttl_from_dynamic(ttl: &Dynamic) -> Result<Option<i64>, Box<EvalAltResult>> {
|
||||
if ttl.is_unit() {
|
||||
return Ok(None);
|
||||
}
|
||||
ttl.as_int().map(Some).map_err(|_| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
"pubsub::subscriber_token: ttl must be an integer (seconds) or ()".into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn mint_token(
|
||||
svc: &Arc<dyn picloud_shared::PubsubService>,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
topics: Array,
|
||||
ttl: Option<i64>,
|
||||
) -> Result<String, Box<EvalAltResult>> {
|
||||
// Every element must be a string; surface a clear error otherwise.
|
||||
let mut names = Vec::with_capacity(topics.len());
|
||||
for t in topics {
|
||||
if !t.is_string() {
|
||||
return Err(EvalAltResult::ErrorRuntime(
|
||||
"pubsub::subscriber_token: topics must be an array of strings".into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into());
|
||||
}
|
||||
names.push(t.into_string().unwrap_or_default());
|
||||
}
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("pubsub: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
// SubscriberToken errors already carry the full
|
||||
// "pubsub::subscriber_token: …" wording, so surface them verbatim.
|
||||
handle
|
||||
.block_on(async move { svc.mint_subscriber_token(&cx, names, ttl).await })
|
||||
.map_err(|err| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("{err}").into(), rhai::Position::NONE).into()
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert a Rhai `Dynamic` message into JSON, base64-encoding any
|
||||
/// `Blob` (at any nesting depth). Mirrors `bridge::dynamic_to_json` but
|
||||
/// adds the blob arm the pub/sub wire contract requires.
|
||||
fn message_to_json(value: &Dynamic) -> Json {
|
||||
// Blob must be checked before the generic array path (a Blob is a
|
||||
// `Vec<u8>`, distinct from a Rhai `Array`).
|
||||
if value.is_blob() {
|
||||
let blob = value.clone().into_blob().unwrap_or_default();
|
||||
return Json::String(STANDARD.encode(&blob));
|
||||
}
|
||||
if value.is_unit() {
|
||||
return Json::Null;
|
||||
}
|
||||
if let Ok(b) = value.as_bool() {
|
||||
return Json::Bool(b);
|
||||
}
|
||||
if let Ok(i) = value.as_int() {
|
||||
return Json::Number(i.into());
|
||||
}
|
||||
if let Ok(f) = value.as_float() {
|
||||
return serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number);
|
||||
}
|
||||
if value.is_string() {
|
||||
return Json::String(value.clone().into_string().unwrap_or_default());
|
||||
}
|
||||
if let Some(arr) = value.clone().try_cast::<Array>() {
|
||||
return Json::Array(arr.iter().map(message_to_json).collect());
|
||||
}
|
||||
if let Some(map) = value.clone().try_cast::<Map>() {
|
||||
let mut out = serde_json::Map::new();
|
||||
for (k, v) in map {
|
||||
out.insert(k.to_string(), message_to_json(&v));
|
||||
}
|
||||
return Json::Object(out);
|
||||
}
|
||||
Json::String(value.to_string())
|
||||
}
|
||||
|
||||
/// Run an async future inside the synchronous Rhai context. Mirrors
|
||||
/// `kv::block_on`.
|
||||
fn block_on<F>(fut: F) -> Result<(), Box<EvalAltResult>>
|
||||
where
|
||||
F: std::future::Future<Output = Result<(), PubsubError>> + Send,
|
||||
{
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("pubsub: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("pubsub: {err}").into(), rhai::Position::NONE).into()
|
||||
})
|
||||
}
|
||||
153
crates/executor-core/src/sdk/secrets.rs
Normal file
153
crates/executor-core/src/sdk/secrets.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
//! `secrets::` Rhai bridge — encrypted per-app secrets (v1.1.7).
|
||||
//!
|
||||
//! ```rhai
|
||||
//! secrets::set("stripe_key", "sk_live_xxx");
|
||||
//! secrets::set("oauth", #{ client_id: "abc", client_secret: "xyz" });
|
||||
//! let key = secrets::get("stripe_key"); // value or ()
|
||||
//! let removed = secrets::delete("stripe_key"); // bool
|
||||
//! let page = secrets::list(#{ cursor: (), limit: 100 });
|
||||
//! // page = #{ names: [...], next_cursor: () | "..." }
|
||||
//! ```
|
||||
//!
|
||||
//! Collection-less (secrets are per-app, like pubsub topics) so there's
|
||||
//! no `::collection(...)`. Values are any JSON-serializable Rhai value
|
||||
//! (String/Map/Array/number/bool); a String round-trips back as a
|
||||
//! String. `app_id` is derived from `cx.app_id` in the service — it
|
||||
//! never appears in the script-side signature, preserving cross-app
|
||||
//! isolation.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{SdkCallCx, SecretsError, SecretsListPage, Services};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
|
||||
use super::bridge::{dynamic_to_json, json_to_dynamic};
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let svc = services.secrets.clone();
|
||||
let mut module = Module::new();
|
||||
|
||||
// secrets::set(name, value) — overwrites if present.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"set",
|
||||
move |name: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||
let json = dynamic_to_json(&value);
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.set(&cx, name, json).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// secrets::get(name) — decoded value, or () if missing.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"get",
|
||||
move |name: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let opt = block_on(async move { svc.get(&cx, name).await })?;
|
||||
Ok(opt.map_or(Dynamic::UNIT, json_to_dynamic))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// secrets::delete(name) — bool was-present.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"delete",
|
||||
move |name: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.delete(&cx, name).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// secrets::list(#{ cursor, limit }) — names only, cursor-paginated.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"list",
|
||||
move |opts: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||
let (cursor, limit) = parse_list_opts(&opts)?;
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let page: SecretsListPage =
|
||||
block_on(async move { svc.list(&cx, cursor.as_deref(), limit).await })?;
|
||||
Ok(list_page_to_map(page))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
engine.register_static_module("secrets", module.into());
|
||||
}
|
||||
|
||||
/// Pull `cursor` (string or `()`) and `limit` (int or `()`) out of the
|
||||
/// options map. Unknown/extra keys are ignored.
|
||||
fn parse_list_opts(opts: &Map) -> Result<(Option<String>, u32), Box<EvalAltResult>> {
|
||||
let cursor = match opts.get("cursor") {
|
||||
None => None,
|
||||
Some(d) if d.is_unit() => None,
|
||||
Some(d) if d.is_string() => Some(d.clone().into_string().unwrap_or_default()),
|
||||
Some(_) => return Err(runtime_err("secrets::list: cursor must be a string or ()")),
|
||||
};
|
||||
let limit = match opts.get("limit") {
|
||||
None => 0,
|
||||
Some(d) if d.is_unit() => 0,
|
||||
Some(d) => {
|
||||
let n = d
|
||||
.as_int()
|
||||
.map_err(|_| runtime_err("secrets::list: limit must be an integer or ()"))?;
|
||||
u32::try_from(n.max(0)).unwrap_or(u32::MAX)
|
||||
}
|
||||
};
|
||||
Ok((cursor, limit))
|
||||
}
|
||||
|
||||
fn list_page_to_map(page: SecretsListPage) -> Map {
|
||||
let mut m = Map::new();
|
||||
let names: Array = page.names.into_iter().map(Dynamic::from).collect();
|
||||
m.insert("names".into(), names.into());
|
||||
m.insert(
|
||||
"next_cursor".into(),
|
||||
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
m
|
||||
}
|
||||
|
||||
// Returns the boxed error directly because every caller needs a
|
||||
// `Box<EvalAltResult>` (Rhai's error type), matching the other bridges.
|
||||
#[allow(clippy::unnecessary_box_returns)]
|
||||
fn runtime_err(msg: &str) -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(msg.into(), rhai::Position::NONE).into()
|
||||
}
|
||||
|
||||
/// Run a `SecretsService` future inside the synchronous Rhai context,
|
||||
/// mapping any `SecretsError` to a Rhai runtime error. Mirrors
|
||||
/// `kv::block_on` / `pubsub::block_on`.
|
||||
fn block_on<T, F>(fut: F) -> Result<T, Box<EvalAltResult>>
|
||||
where
|
||||
F: std::future::Future<Output = Result<T, SecretsError>> + Send,
|
||||
T: Send,
|
||||
{
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("secrets: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("secrets: {err}").into(), rhai::Position::NONE).into()
|
||||
})
|
||||
}
|
||||
131
crates/executor-core/tests/module_redaction_logging.rs
Normal file
131
crates/executor-core/tests/module_redaction_logging.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
//! v1.1.4 §10a: the original module backend error MUST be logged at
|
||||
//! error level (so operators can still diagnose), even though it is
|
||||
//! redacted from the script-visible error.
|
||||
//!
|
||||
//! This test owns the process-global tracing subscriber, so it lives in
|
||||
//! its own integration-test binary (one `set_global_default` per
|
||||
//! process). A unique sentinel in the backend error keeps the assertion
|
||||
//! robust against any concurrently-running test's log output.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService,
|
||||
NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId,
|
||||
ScriptSandbox, SdkCallCx, Services,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
const SENTINEL: &str = "connection refused PICLOUD-SENTINEL-9f3a";
|
||||
|
||||
struct FailingSource;
|
||||
|
||||
#[async_trait]
|
||||
impl ModuleSource for FailingSource {
|
||||
async fn lookup(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// `MakeWriter` that appends to a shared buffer.
|
||||
#[derive(Clone)]
|
||||
struct SharedBuf(Arc<Mutex<Vec<u8>>>);
|
||||
|
||||
impl Write for SharedBuf {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().unwrap().extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for SharedBuf {
|
||||
type Writer = SharedBuf;
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn req(app_id: AppId) -> ExecRequest {
|
||||
let execution_id = ExecutionId::new();
|
||||
ExecRequest {
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
script_name: "redaction-test".into(),
|
||||
invocation_type: InvocationType::Http,
|
||||
path: "/x".into(),
|
||||
headers: BTreeMap::new(),
|
||||
body: Value::Null,
|
||||
params: BTreeMap::new(),
|
||||
query: BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn original_backend_error_is_logged_at_error_level() {
|
||||
let buf = Arc::new(Mutex::new(Vec::<u8>::new()));
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_writer(SharedBuf(buf.clone()))
|
||||
.with_max_level(tracing::Level::ERROR)
|
||||
.with_ansi(false)
|
||||
.finish();
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
.expect("this test owns the global subscriber for its binary");
|
||||
|
||||
let services = Services::new(
|
||||
Arc::new(NoopKvService),
|
||||
Arc::new(NoopDocsService),
|
||||
Arc::new(NoopDeadLetterService),
|
||||
Arc::new(NoopEventEmitter),
|
||||
Arc::new(FailingSource),
|
||||
Arc::new(NoopHttpService),
|
||||
Arc::new(picloud_shared::NoopFilesService),
|
||||
Arc::new(picloud_shared::NoopPubsubService),
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
Arc::new(picloud_shared::NoopEmailService),
|
||||
);
|
||||
let engine = Engine::new(Limits::default(), services);
|
||||
|
||||
let err = engine
|
||||
.execute(r#"import "x" as x; 1"#, req(AppId::new()))
|
||||
.expect_err("backend error should surface");
|
||||
|
||||
// Script-visible: redacted.
|
||||
let msg = format!("{err:?}");
|
||||
assert!(msg.contains("module backend unavailable"), "got {msg}");
|
||||
assert!(
|
||||
!msg.contains("PICLOUD-SENTINEL"),
|
||||
"script error leaked the original: {msg}"
|
||||
);
|
||||
|
||||
// Operator log: the original sentinel IS present, at ERROR level.
|
||||
let logged = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
|
||||
assert!(
|
||||
logged.contains(SENTINEL),
|
||||
"original backend error should be logged; captured: {logged}"
|
||||
);
|
||||
assert!(
|
||||
logged.contains("ERROR"),
|
||||
"should be logged at error level; captured: {logged}"
|
||||
);
|
||||
}
|
||||
597
crates/executor-core/tests/modules.rs
Normal file
597
crates/executor-core/tests/modules.rs
Normal file
@@ -0,0 +1,597 @@
|
||||
//! v1.1.3 — `PicloudModuleResolver` integration tests.
|
||||
#![allow(clippy::needless_raw_string_hashes)] // r#""# is more uniform when many tests embed Rhai sources
|
||||
//!
|
||||
//! Each test wires an `Engine` with a `CountingModuleSource` (an
|
||||
//! in-memory fake), a `Services` bundle, and an `ExecRequest` whose
|
||||
//! `app_id` controls the cross-app boundary. The resolver is
|
||||
//! exercised end-to-end through `Engine::execute`, so these tests
|
||||
//! verify the same code path the `picloud` binary runs at request
|
||||
//! time.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService,
|
||||
NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId,
|
||||
ScriptSandbox, SdkCallCx, Services,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// In-memory `ModuleSource` backed by a `HashMap<(AppId, name)>`.
|
||||
/// Tracks total lookup count so tests can assert cache hit/miss.
|
||||
#[derive(Default)]
|
||||
struct CountingModuleSource {
|
||||
table: Mutex<HashMap<(AppId, String), ModuleScript>>,
|
||||
lookups: AtomicUsize,
|
||||
/// When `Some`, every lookup returns this error instead of the
|
||||
/// table — used by the backend-error test.
|
||||
fail_with: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl CountingModuleSource {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
async fn put(self: &Arc<Self>, app_id: AppId, name: &str, source: &str) -> ScriptId {
|
||||
self.put_with_updated_at(app_id, name, source, Utc::now())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn put_with_updated_at(
|
||||
self: &Arc<Self>,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
source: &str,
|
||||
updated_at: DateTime<Utc>,
|
||||
) -> ScriptId {
|
||||
let script_id = ScriptId::new();
|
||||
self.table.lock().await.insert(
|
||||
(app_id, name.to_string()),
|
||||
ModuleScript {
|
||||
script_id,
|
||||
app_id,
|
||||
name: name.to_string(),
|
||||
source: source.to_string(),
|
||||
updated_at,
|
||||
},
|
||||
);
|
||||
script_id
|
||||
}
|
||||
|
||||
fn lookup_count(&self) -> usize {
|
||||
self.lookups.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModuleSource for CountingModuleSource {
|
||||
async fn lookup(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
self.lookups.fetch_add(1, Ordering::SeqCst);
|
||||
if let Some(err) = self.fail_with.lock().await.as_ref() {
|
||||
return Err(ModuleSourceError::Backend(err.clone()));
|
||||
}
|
||||
Ok(self
|
||||
.table
|
||||
.lock()
|
||||
.await
|
||||
.get(&(cx.app_id, name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
}
|
||||
|
||||
fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
|
||||
Services::new(
|
||||
Arc::new(NoopKvService),
|
||||
Arc::new(NoopDocsService),
|
||||
Arc::new(NoopDeadLetterService),
|
||||
Arc::new(NoopEventEmitter),
|
||||
modules,
|
||||
Arc::new(NoopHttpService),
|
||||
Arc::new(picloud_shared::NoopFilesService),
|
||||
Arc::new(picloud_shared::NoopPubsubService),
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
Arc::new(picloud_shared::NoopEmailService),
|
||||
)
|
||||
}
|
||||
|
||||
fn engine_with(modules: Arc<dyn ModuleSource>) -> Engine {
|
||||
Engine::new(Limits::default(), services_with(modules))
|
||||
}
|
||||
|
||||
fn req(app_id: AppId) -> ExecRequest {
|
||||
let execution_id = ExecutionId::new();
|
||||
ExecRequest {
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
script_name: "test".into(),
|
||||
invocation_type: InvocationType::Http,
|
||||
path: "/test".into(),
|
||||
headers: BTreeMap::new(),
|
||||
body: serde_json::Value::Null,
|
||||
params: BTreeMap::new(),
|
||||
query: BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn resolver_loads_simple_module() {
|
||||
let source = CountingModuleSource::new();
|
||||
let app_id = AppId::new();
|
||||
source.put(app_id, "math", "fn add(a, b) { a + b }").await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let resp = engine
|
||||
.execute(r#"import "math" as m; m::add(2, 3)"#, req(app_id))
|
||||
.expect("should execute");
|
||||
assert_eq!(resp.status_code, 200);
|
||||
assert_eq!(resp.body, serde_json::json!(5));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn resolver_cross_app_blocked() {
|
||||
let source = CountingModuleSource::new();
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
source
|
||||
.put(app_a, "secrets", "fn token() { \"A-token\" }")
|
||||
.await;
|
||||
source
|
||||
.put(app_b, "secrets", "fn token() { \"B-token\" }")
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
|
||||
// App A sees A's module.
|
||||
let resp = engine
|
||||
.execute(r#"import "secrets" as s; s::token()"#, req(app_a))
|
||||
.unwrap();
|
||||
assert_eq!(resp.body, serde_json::json!("A-token"));
|
||||
|
||||
// App B sees B's module — same name, completely separate value.
|
||||
let resp = engine
|
||||
.execute(r#"import "secrets" as s; s::token()"#, req(app_b))
|
||||
.unwrap();
|
||||
assert_eq!(resp.body, serde_json::json!("B-token"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn resolver_cross_app_module_not_found() {
|
||||
let source = CountingModuleSource::new();
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
// Only app A has the module.
|
||||
source.put(app_a, "lonely", "fn ping() { \"pong\" }").await;
|
||||
|
||||
// App B's lookup should return None → resolver surfaces
|
||||
// ErrorModuleNotFound.
|
||||
let engine = engine_with(source.clone());
|
||||
let err = engine
|
||||
.execute(r#"import "lonely" as l; l::ping()"#, req(app_b))
|
||||
.expect_err("cross-app import should fail");
|
||||
let msg = format!("{err:?}");
|
||||
assert!(
|
||||
msg.to_lowercase().contains("module")
|
||||
|| msg.to_lowercase().contains("not found")
|
||||
|| msg.to_lowercase().contains("lonely"),
|
||||
"expected module-not-found-flavoured error, got {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn resolver_module_not_found() {
|
||||
let source = CountingModuleSource::new();
|
||||
let app_id = AppId::new();
|
||||
let engine = engine_with(source);
|
||||
|
||||
let err = engine
|
||||
.execute(r#"import "doesnotexist" as x; 1"#, req(app_id))
|
||||
.expect_err("unknown module should fail");
|
||||
let msg = format!("{err:?}").to_lowercase();
|
||||
assert!(
|
||||
msg.contains("doesnotexist") || msg.contains("not found"),
|
||||
"expected ErrorModuleNotFound-flavoured error, got {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn resolver_self_import_detected() {
|
||||
let source = CountingModuleSource::new();
|
||||
let app_id = AppId::new();
|
||||
// a imports itself
|
||||
source
|
||||
.put(app_id, "a", r#"import "a" as a; fn nope() { 0 }"#)
|
||||
.await;
|
||||
let engine = engine_with(source);
|
||||
|
||||
let err = engine
|
||||
.execute(r#"import "a" as a; a::nope()"#, req(app_id))
|
||||
.expect_err("self-import should detect cycle");
|
||||
let msg = format!("{err:?}").to_lowercase();
|
||||
assert!(
|
||||
msg.contains("circular") || msg.contains("cycle"),
|
||||
"expected circular-import error, got {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn resolver_circular_detected() {
|
||||
let source = CountingModuleSource::new();
|
||||
let app_id = AppId::new();
|
||||
// a imports b; b imports a; both then declare a fn.
|
||||
source
|
||||
.put(app_id, "a", r#"import "b" as b; fn x() { 0 }"#)
|
||||
.await;
|
||||
source
|
||||
.put(app_id, "b", r#"import "a" as a; fn y() { 0 }"#)
|
||||
.await;
|
||||
let engine = engine_with(source);
|
||||
|
||||
let err = engine
|
||||
.execute(r#"import "a" as a; a::x()"#, req(app_id))
|
||||
.expect_err("circular import should fail");
|
||||
let msg = format!("{err:?}").to_lowercase();
|
||||
assert!(
|
||||
msg.contains("circular") || msg.contains("cycle"),
|
||||
"expected circular-import error, got {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn resolver_depth_limit_enforced() {
|
||||
let source = CountingModuleSource::new();
|
||||
let app_id = AppId::new();
|
||||
// Chain `m0 -> m1 -> ... -> m9` (10 levels). Default depth limit is 8.
|
||||
for i in 0..9 {
|
||||
let next = format!("m{}", i + 1);
|
||||
source
|
||||
.put(
|
||||
app_id,
|
||||
&format!("m{i}"),
|
||||
&format!(r#"import "{next}" as nxt; fn x() {{ 0 }}"#),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
source.put(app_id, "m9", "fn x() { 0 }").await;
|
||||
|
||||
let engine = engine_with(source);
|
||||
let err = engine
|
||||
.execute(r#"import "m0" as m0; m0::x()"#, req(app_id))
|
||||
.expect_err("chain exceeding depth limit should fail");
|
||||
let msg = format!("{err:?}").to_lowercase();
|
||||
assert!(
|
||||
msg.contains("depth"),
|
||||
"expected depth-exceeded error, got {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn resolver_depth_limit_just_under_succeeds() {
|
||||
let source = CountingModuleSource::new();
|
||||
let app_id = AppId::new();
|
||||
// Chain depth 7 (under default 8). m0 -> m1 -> ... -> m6 (terminal).
|
||||
for i in 0..6 {
|
||||
let next = format!("m{}", i + 1);
|
||||
source
|
||||
.put(
|
||||
app_id,
|
||||
&format!("m{i}"),
|
||||
&format!(r#"import "{next}" as nxt; fn x() {{ nxt::x() }}"#),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
source.put(app_id, "m6", "fn x() { 42 }").await;
|
||||
|
||||
let engine = engine_with(source);
|
||||
let resp = engine
|
||||
.execute(r#"import "m0" as m0; m0::x()"#, req(app_id))
|
||||
.expect("chain under depth limit should succeed");
|
||||
assert_eq!(resp.body, serde_json::json!(42));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn resolver_runtime_validation_rejects_top_level_expr() {
|
||||
let source = CountingModuleSource::new();
|
||||
let app_id = AppId::new();
|
||||
// Module has a top-level expression — bypassed the admin gate,
|
||||
// but the resolver re-validates and rejects.
|
||||
source.put(app_id, "bad", r#"42; fn x() { 1 }"#).await;
|
||||
let engine = engine_with(source);
|
||||
|
||||
let err = engine
|
||||
.execute(r#"import "bad" as b; b::x()"#, req(app_id))
|
||||
.expect_err("top-level expr in module should be rejected at resolve");
|
||||
let msg = format!("{err:?}").to_lowercase();
|
||||
assert!(
|
||||
msg.contains("top-level") || msg.contains("module"),
|
||||
"expected module-shape error, got {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// v1.1.4 §10a regression: the backend error must be REDACTED before
|
||||
/// it reaches a script. The verbatim message (which can leak internal
|
||||
/// infrastructure shape, e.g. "connection refused") must not appear;
|
||||
/// the script sees only a stable generic.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn resolver_backend_error_is_redacted_from_script() {
|
||||
let source = CountingModuleSource::new();
|
||||
let app_id = AppId::new();
|
||||
*source.fail_with.lock().await = Some("connection refused to 10.1.2.3:5432".into());
|
||||
let engine = engine_with(source);
|
||||
|
||||
let err = engine
|
||||
.execute(r#"import "x" as x; 1"#, req(app_id))
|
||||
.expect_err("backend error should propagate");
|
||||
let msg = format!("{err:?}");
|
||||
assert!(
|
||||
msg.contains("module backend unavailable"),
|
||||
"expected redacted generic message, got {msg}"
|
||||
);
|
||||
assert!(
|
||||
!msg.contains("connection refused") && !msg.contains("10.1.2.3"),
|
||||
"redacted message must not leak the backend error, got {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn module_cache_hit_reuses_compiled_module() {
|
||||
let source = CountingModuleSource::new();
|
||||
let app_id = AppId::new();
|
||||
source.put(app_id, "u", "fn ping() { 1 }").await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
|
||||
// First execution compiles and caches.
|
||||
engine
|
||||
.execute(r#"import "u" as u; u::ping()"#, req(app_id))
|
||||
.unwrap();
|
||||
let lookups_after_first = source.lookup_count();
|
||||
assert_eq!(
|
||||
lookups_after_first, 1,
|
||||
"first invocation should look up once"
|
||||
);
|
||||
|
||||
// Second execution should re-lookup (to compare updated_at) but
|
||||
// serve from cache without recompiling. We can't directly observe
|
||||
// compile-vs-cache here, but we can assert lookup count grew by
|
||||
// one (no spurious extra calls).
|
||||
engine
|
||||
.execute(r#"import "u" as u; u::ping()"#, req(app_id))
|
||||
.unwrap();
|
||||
assert_eq!(source.lookup_count(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn module_cache_stale_invalidated_on_updated_at_change() {
|
||||
let source = CountingModuleSource::new();
|
||||
let app_id = AppId::new();
|
||||
let t0 = Utc::now() - chrono::Duration::seconds(10);
|
||||
source
|
||||
.put_with_updated_at(app_id, "u", r#"fn v() { 1 }"#, t0)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
|
||||
let resp = engine
|
||||
.execute(r#"import "u" as u; u::v()"#, req(app_id))
|
||||
.unwrap();
|
||||
assert_eq!(resp.body, serde_json::json!(1));
|
||||
|
||||
// Replace with newer updated_at — cache should refresh.
|
||||
let t1 = Utc::now();
|
||||
source
|
||||
.put_with_updated_at(app_id, "u", r#"fn v() { 99 }"#, t1)
|
||||
.await;
|
||||
|
||||
let resp = engine
|
||||
.execute(r#"import "u" as u; u::v()"#, req(app_id))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.body,
|
||||
serde_json::json!(99),
|
||||
"edited module should be visible on next invocation"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn module_cache_keyed_by_app() {
|
||||
let source = CountingModuleSource::new();
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
source.put(app_a, "u", "fn id() { 1 }").await;
|
||||
source.put(app_b, "u", "fn id() { 2 }").await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
|
||||
// Both apps should compile + cache independently; neither sees
|
||||
// the other's compiled module.
|
||||
let resp = engine
|
||||
.execute(r#"import "u" as u; u::id()"#, req(app_a))
|
||||
.unwrap();
|
||||
assert_eq!(resp.body, serde_json::json!(1));
|
||||
let resp = engine
|
||||
.execute(r#"import "u" as u; u::id()"#, req(app_b))
|
||||
.unwrap();
|
||||
assert_eq!(resp.body, serde_json::json!(2));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn module_cache_lru_evicts_when_capacity_exceeded() {
|
||||
let source = CountingModuleSource::new();
|
||||
let app_id = AppId::new();
|
||||
source.put(app_id, "a", "fn v() { 1 }").await;
|
||||
source.put(app_id, "b", "fn v() { 2 }").await;
|
||||
source.put(app_id, "c", "fn v() { 3 }").await;
|
||||
|
||||
// Capacity 1 — only the most recently used entry stays cached.
|
||||
let engine =
|
||||
Engine::with_module_cache_capacity(Limits::default(), services_with(source.clone()), 1);
|
||||
|
||||
engine
|
||||
.execute(r#"import "a" as m; m::v()"#, req(app_id))
|
||||
.unwrap();
|
||||
engine
|
||||
.execute(r#"import "b" as m; m::v()"#, req(app_id))
|
||||
.unwrap();
|
||||
engine
|
||||
.execute(r#"import "c" as m; m::v()"#, req(app_id))
|
||||
.unwrap();
|
||||
|
||||
// Cache should hold at most one entry.
|
||||
let cache = engine.module_cache().lock().unwrap();
|
||||
assert!(
|
||||
cache.len() <= 1,
|
||||
"cache size {} exceeded capacity 1",
|
||||
cache.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn endpoint_can_import_module() {
|
||||
let source = CountingModuleSource::new();
|
||||
let app_id = AppId::new();
|
||||
source
|
||||
.put(app_id, "helpers", r#"fn greet(name) { `hello, ${name}` }"#)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source);
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "helpers" as h; #{ statusCode: 200, body: h::greet("world") }"#,
|
||||
req(app_id),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resp.status_code, 200);
|
||||
assert_eq!(resp.body, serde_json::json!("hello, world"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn module_can_import_module() {
|
||||
let source = CountingModuleSource::new();
|
||||
let app_id = AppId::new();
|
||||
source.put(app_id, "inner", "fn three() { 3 }").await;
|
||||
source
|
||||
.put(
|
||||
app_id,
|
||||
"outer",
|
||||
r#"import "inner" as i; fn nine() { i::three() * 3 }"#,
|
||||
)
|
||||
.await;
|
||||
let engine = engine_with(source);
|
||||
|
||||
let resp = engine
|
||||
.execute(r#"import "outer" as o; o::nine()"#, req(app_id))
|
||||
.unwrap();
|
||||
assert_eq!(resp.body, serde_json::json!(9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_module_accepts_fn_const_import_only() {
|
||||
let engine = Engine::new(Limits::default(), Services::default());
|
||||
let valid = r#"
|
||||
const PI = 3.14;
|
||||
import "other" as o;
|
||||
fn area(r) { PI * r * r }
|
||||
"#;
|
||||
let v = engine.validate_module(valid).expect("valid module body");
|
||||
assert_eq!(v.imports, vec!["other".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_module_rejects_top_level_let() {
|
||||
let engine = Engine::new(Limits::default(), Services::default());
|
||||
let bad = "let x = 1; fn f() { x }";
|
||||
let err = engine
|
||||
.validate_module(bad)
|
||||
.expect_err("top-level let should be rejected");
|
||||
let msg = format!("{err:?}").to_lowercase();
|
||||
assert!(msg.contains("top-level") || msg.contains("module"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_module_rejects_top_level_expr() {
|
||||
let engine = Engine::new(Limits::default(), Services::default());
|
||||
let bad = "42";
|
||||
let err = engine
|
||||
.validate_module(bad)
|
||||
.expect_err("top-level expr should be rejected");
|
||||
let msg = format!("{err:?}").to_lowercase();
|
||||
assert!(msg.contains("top-level") || msg.contains("module"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_module_rejects_top_level_while() {
|
||||
// Avoid `if true { ... }` — Rhai folds constant-condition `if`s
|
||||
// at optimize time, leaving an empty statement list that passes
|
||||
// module-shape validation vacuously. A `while` with a variable
|
||||
// condition isn't folded.
|
||||
let engine = Engine::new(Limits::default(), Services::default());
|
||||
let bad = r#"let i = 0; while i < 1 { i += 1; }"#;
|
||||
let err = engine
|
||||
.validate_module(bad)
|
||||
.expect_err("top-level loop should be rejected");
|
||||
let msg = format!("{err:?}").to_lowercase();
|
||||
assert!(msg.contains("top-level") || msg.contains("module"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_endpoint_extracts_literal_imports() {
|
||||
let engine = Engine::new(Limits::default(), Services::default());
|
||||
let src = r#"
|
||||
import "a" as a;
|
||||
import "b" as b;
|
||||
a::run() + b::run()
|
||||
"#;
|
||||
let v = engine
|
||||
.validate(src)
|
||||
.expect("endpoint with imports should parse");
|
||||
assert_eq!(v.imports, vec!["a".to_string(), "b".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_endpoint_top_level_expr_still_allowed() {
|
||||
// Endpoints can have arbitrary top-level statements — only
|
||||
// modules are restricted. Confirm v1.1.3 didn't tighten endpoints.
|
||||
let engine = Engine::new(Limits::default(), Services::default());
|
||||
let src = r#"let x = 1; #{ statusCode: 200, body: x }"#;
|
||||
engine
|
||||
.validate(src)
|
||||
.expect("endpoints may have top-level statements");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_endpoint_skips_dynamic_imports_in_imports_list() {
|
||||
// `import some_var as y;` parses but is not a literal-path
|
||||
// import — the dep graph cannot track it. The imports list
|
||||
// should be empty for such a script.
|
||||
let engine = Engine::new(Limits::default(), Services::default());
|
||||
let src = r#"
|
||||
let name = "x";
|
||||
import name as y;
|
||||
y::run()
|
||||
"#;
|
||||
let v = engine.validate(src).expect("dynamic import should parse");
|
||||
assert!(
|
||||
v.imports.is_empty(),
|
||||
"dynamic imports should not appear in the dep-graph imports list, got {:?}",
|
||||
v.imports
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,8 @@ use chrono::Utc;
|
||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{
|
||||
AppId, DocId, DocRow, DocsError, DocsListPage, DocsService, ExecutionId, NoopDeadLetterService,
|
||||
NoopEventEmitter, NoopKvService, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services,
|
||||
NoopEventEmitter, NoopHttpService, NoopKvService, NoopModuleSource, RequestId, ScriptId,
|
||||
ScriptSandbox, SdkCallCx, Services,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::Mutex;
|
||||
@@ -225,6 +226,12 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(InMemoryDocs::default()),
|
||||
Arc::new(NoopDeadLetterService),
|
||||
Arc::new(NoopEventEmitter),
|
||||
Arc::new(NoopModuleSource),
|
||||
Arc::new(NoopHttpService),
|
||||
Arc::new(picloud_shared::NoopFilesService),
|
||||
Arc::new(picloud_shared::NoopPubsubService),
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
Arc::new(picloud_shared::NoopEmailService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
209
crates/executor-core/tests/sdk_email.rs
Normal file
209
crates/executor-core/tests/sdk_email.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
//! `email::` SDK bridge integration tests — runs a real Rhai engine
|
||||
//! against a recording `EmailService`. Verifies the Rhai map → DTO
|
||||
//! plumbing (address coercion, the text-only vs multipart split). The
|
||||
//! SMTP transport, validation, and authz are unit-tested at the service
|
||||
//! layer in `manager-core::email_service`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{
|
||||
AppId, EmailError, EmailService, ExecutionId, NoopDeadLetterService, NoopDocsService,
|
||||
NoopEventEmitter, NoopHttpService, NoopKvService, NoopModuleSource, OutboundEmail, RequestId,
|
||||
ScriptId, ScriptSandbox, SdkCallCx, Services, TriggerEvent,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingEmail {
|
||||
sent: Mutex<Vec<OutboundEmail>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EmailService for RecordingEmail {
|
||||
async fn send(&self, _cx: &SdkCallCx, email: OutboundEmail) -> Result<(), EmailError> {
|
||||
self.sent.lock().unwrap().push(email);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn engine_with(rec: Arc<RecordingEmail>) -> Arc<Engine> {
|
||||
let services = Services::new(
|
||||
Arc::new(NoopKvService),
|
||||
Arc::new(NoopDocsService),
|
||||
Arc::new(NoopDeadLetterService),
|
||||
Arc::new(NoopEventEmitter),
|
||||
Arc::new(NoopModuleSource),
|
||||
Arc::new(NoopHttpService),
|
||||
Arc::new(picloud_shared::NoopFilesService),
|
||||
Arc::new(picloud_shared::NoopPubsubService),
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
rec,
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
let execution_id = ExecutionId::new();
|
||||
ExecRequest {
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
script_name: "email-test".into(),
|
||||
invocation_type: InvocationType::Http,
|
||||
path: "/email-test".into(),
|
||||
headers: BTreeMap::new(),
|
||||
body: Value::Null,
|
||||
params: BTreeMap::new(),
|
||||
query: BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(engine: Arc<Engine>, src: &str) -> Result<(), ()> {
|
||||
let src = src.to_string();
|
||||
let app = AppId::new();
|
||||
tokio::task::spawn_blocking(move || engine.execute(&src, baseline_request(app)))
|
||||
.await
|
||||
.expect("spawn_blocking")
|
||||
.map(|_| ())
|
||||
.map_err(|_| ())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn send_parses_single_recipient_text() {
|
||||
let rec = Arc::new(RecordingEmail::default());
|
||||
let engine = engine_with(rec.clone());
|
||||
run(
|
||||
engine,
|
||||
r#"
|
||||
email::send(#{
|
||||
to: "alice@example.com",
|
||||
from: "alerts@myapp.com",
|
||||
subject: "Build complete",
|
||||
text: "done"
|
||||
});
|
||||
#{ ok: true }
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let g = rec.sent.lock().unwrap();
|
||||
let e = g.last().unwrap();
|
||||
assert_eq!(e.to, vec!["alice@example.com".to_string()]);
|
||||
assert_eq!(e.from, "alerts@myapp.com");
|
||||
assert_eq!(e.subject, "Build complete");
|
||||
assert_eq!(e.text.as_deref(), Some("done"));
|
||||
// email::send forces text-only even if html were present.
|
||||
assert!(e.html.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn send_html_carries_both_parts_and_lists() {
|
||||
let rec = Arc::new(RecordingEmail::default());
|
||||
let engine = engine_with(rec.clone());
|
||||
run(
|
||||
engine,
|
||||
r#"
|
||||
email::send_html(#{
|
||||
to: ["alice@x.com", "bob@y.com"],
|
||||
cc: ["dave@z.com"],
|
||||
bcc: ["audit@myapp.com"],
|
||||
from: "alerts@myapp.com",
|
||||
reply_to: "support@myapp.com",
|
||||
subject: "hi",
|
||||
text: "plain",
|
||||
html: "<p>rich</p>"
|
||||
});
|
||||
#{ ok: true }
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let g = rec.sent.lock().unwrap();
|
||||
let e = g.last().unwrap();
|
||||
assert_eq!(
|
||||
e.to,
|
||||
vec!["alice@x.com".to_string(), "bob@y.com".to_string()]
|
||||
);
|
||||
assert_eq!(e.cc, vec!["dave@z.com".to_string()]);
|
||||
assert_eq!(e.bcc, vec!["audit@myapp.com".to_string()]);
|
||||
assert_eq!(e.reply_to.as_deref(), Some("support@myapp.com"));
|
||||
assert_eq!(e.text.as_deref(), Some("plain"));
|
||||
assert_eq!(e.html.as_deref(), Some("<p>rich</p>"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn inbound_email_event_visible_to_handler() {
|
||||
// A handler invoked by an email:receive trigger sees the normalized
|
||||
// message at ctx.event.email (built by the engine's ctx renderer).
|
||||
let rec = Arc::new(RecordingEmail::default());
|
||||
let engine = engine_with(rec);
|
||||
let mut req = baseline_request(AppId::new());
|
||||
req.event = Some(TriggerEvent::Email {
|
||||
from: "sender@external.com".into(),
|
||||
to: vec!["alice@myapp.com".into()],
|
||||
cc: vec!["bob@myapp.com".into()],
|
||||
subject: "Re: question".into(),
|
||||
text: Some("hello".into()),
|
||||
html: None,
|
||||
received_at: chrono::DateTime::parse_from_rfc3339("2026-08-15T12:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Utc),
|
||||
message_id: Some("<abc@external.com>".into()),
|
||||
});
|
||||
let src = r#"
|
||||
let e = ctx.event;
|
||||
#{
|
||||
source: e.source,
|
||||
op: e.op,
|
||||
from: e.email.from,
|
||||
to0: e.email.to[0],
|
||||
cc0: e.email.cc[0],
|
||||
subject: e.email.subject,
|
||||
text: e.email.text,
|
||||
html_is_unit: type_of(e.email.html) == "()",
|
||||
message_id: e.email.message_id
|
||||
}
|
||||
"#;
|
||||
let src = src.to_string();
|
||||
let body = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.body;
|
||||
assert_eq!(body["source"], json!("email"));
|
||||
assert_eq!(body["op"], json!("receive"));
|
||||
assert_eq!(body["from"], json!("sender@external.com"));
|
||||
assert_eq!(body["to0"], json!("alice@myapp.com"));
|
||||
assert_eq!(body["cc0"], json!("bob@myapp.com"));
|
||||
assert_eq!(body["subject"], json!("Re: question"));
|
||||
assert_eq!(body["text"], json!("hello"));
|
||||
assert_eq!(body["html_is_unit"], json!(true));
|
||||
assert_eq!(body["message_id"], json!("<abc@external.com>"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn send_html_without_html_throws() {
|
||||
let rec = Arc::new(RecordingEmail::default());
|
||||
let engine = engine_with(rec.clone());
|
||||
let res = run(
|
||||
engine,
|
||||
r#"
|
||||
email::send_html(#{ to: "a@b.com", from: "c@d.com", subject: "x", text: "y" });
|
||||
#{ ok: true }
|
||||
"#,
|
||||
)
|
||||
.await;
|
||||
assert!(res.is_err(), "send_html without html must throw");
|
||||
assert!(rec.sent.lock().unwrap().is_empty());
|
||||
}
|
||||
336
crates/executor-core/tests/sdk_files.rs
Normal file
336
crates/executor-core/tests/sdk_files.rs
Normal file
@@ -0,0 +1,336 @@
|
||||
//! `files::` SDK bridge integration tests — runs a real Rhai engine
|
||||
//! against an in-memory `FilesService` impl. Mirrors `tests/sdk_kv.rs`:
|
||||
//! `tokio::task::spawn_blocking` so the bridge's `block_on` has a
|
||||
//! reachable runtime. Exercises the actual Rhai surface — blob in/out,
|
||||
//! the metadata map shape, and the missing-required-field throw.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, FileMeta, FileUpdate, FilesError, FilesListPage, FilesService, NewFile,
|
||||
NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService,
|
||||
NoopModuleSource, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Default)]
|
||||
struct InMemoryFiles {
|
||||
#[allow(clippy::type_complexity)]
|
||||
data: Mutex<BTreeMap<(AppId, String, Uuid), (FileMeta, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
/// The in-memory fake doesn't exercise the real checksum path (the
|
||||
/// `FsFilesRepo` tempdir tests in manager-core cover SHA-256); a stable
|
||||
/// placeholder keeps the metadata map non-empty.
|
||||
fn fake_checksum(bytes: &[u8]) -> String {
|
||||
format!("len-{}", bytes.len())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FilesService for InMemoryFiles {
|
||||
async fn create(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
new: NewFile,
|
||||
) -> Result<Uuid, FilesError> {
|
||||
if collection.is_empty() {
|
||||
return Err(FilesError::InvalidCollection("empty".into()));
|
||||
}
|
||||
new.validate(100 * 1024 * 1024)?;
|
||||
let id = Uuid::new_v4();
|
||||
let now = chrono::Utc::now();
|
||||
let meta = FileMeta {
|
||||
id,
|
||||
collection: collection.to_string(),
|
||||
name: new.name.clone(),
|
||||
content_type: new.content_type.clone(),
|
||||
size: new.data.len() as u64,
|
||||
checksum: fake_checksum(&new.data),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
self.data
|
||||
.lock()
|
||||
.await
|
||||
.insert((cx.app_id, collection.to_string(), id), (meta, new.data));
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn head(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<Option<FileMeta>, FilesError> {
|
||||
let Ok(uuid) = Uuid::parse_str(id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(cx.app_id, collection.to_string(), uuid))
|
||||
.map(|(m, _)| m.clone()))
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<Option<Vec<u8>>, FilesError> {
|
||||
let Ok(uuid) = Uuid::parse_str(id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(cx.app_id, collection.to_string(), uuid))
|
||||
.map(|(_, b)| b.clone()))
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
upd: FileUpdate,
|
||||
) -> Result<(), FilesError> {
|
||||
upd.validate(100 * 1024 * 1024)?;
|
||||
let Ok(uuid) = Uuid::parse_str(id) else {
|
||||
return Err(FilesError::NotFound);
|
||||
};
|
||||
let mut data = self.data.lock().await;
|
||||
let key = (cx.app_id, collection.to_string(), uuid);
|
||||
let Some((meta, _)) = data.get(&key).cloned() else {
|
||||
return Err(FilesError::NotFound);
|
||||
};
|
||||
let mut meta = meta;
|
||||
if let Some(n) = upd.name {
|
||||
meta.name = n;
|
||||
}
|
||||
if let Some(ct) = upd.content_type {
|
||||
meta.content_type = ct;
|
||||
}
|
||||
meta.size = upd.data.len() as u64;
|
||||
meta.checksum = fake_checksum(&upd.data);
|
||||
data.insert(key, (meta, upd.data));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, cx: &SdkCallCx, collection: &str, id: &str) -> Result<bool, FilesError> {
|
||||
let Ok(uuid) = Uuid::parse_str(id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.remove(&(cx.app_id, collection.to_string(), uuid))
|
||||
.is_some())
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
_cursor: Option<&str>,
|
||||
_limit: u32,
|
||||
) -> Result<FilesListPage, FilesError> {
|
||||
let data = self.data.lock().await;
|
||||
let files: Vec<FileMeta> = data
|
||||
.iter()
|
||||
.filter(|((a, c, _), _)| *a == cx.app_id && c == collection)
|
||||
.map(|(_, (m, _))| m.clone())
|
||||
.collect();
|
||||
Ok(FilesListPage {
|
||||
files,
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn make_engine() -> Arc<Engine> {
|
||||
let services = Services::new(
|
||||
Arc::new(NoopKvService),
|
||||
Arc::new(NoopDocsService),
|
||||
Arc::new(NoopDeadLetterService),
|
||||
Arc::new(NoopEventEmitter),
|
||||
Arc::new(NoopModuleSource),
|
||||
Arc::new(NoopHttpService),
|
||||
Arc::new(InMemoryFiles::default()),
|
||||
Arc::new(picloud_shared::NoopPubsubService),
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
Arc::new(picloud_shared::NoopEmailService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
let execution_id = ExecutionId::new();
|
||||
ExecRequest {
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
script_name: "files-test".into(),
|
||||
invocation_type: InvocationType::Http,
|
||||
path: "/files-test".into(),
|
||||
headers: BTreeMap::new(),
|
||||
body: Value::Null,
|
||||
params: BTreeMap::new(),
|
||||
query: BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_script(engine: Arc<Engine>, src: &str, req: ExecRequest) -> Value {
|
||||
let src = src.to_string();
|
||||
tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||
.await
|
||||
.expect("spawn_blocking should not panic")
|
||||
.expect("script execution should succeed")
|
||||
.body
|
||||
}
|
||||
|
||||
async fn run_script_err(engine: Arc<Engine>, src: &str, req: ExecRequest) -> String {
|
||||
let src = src.to_string();
|
||||
let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||
.await
|
||||
.expect("spawn_blocking should not panic");
|
||||
format!("{:?}", res.expect_err("script should error"))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn files_create_get_round_trip_via_blob() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
// base64("hello") = "aGVsbG8="; decode → blob; create; get back; encode.
|
||||
let src = r#"
|
||||
let c = files::collection("avatars");
|
||||
let data = base64::decode("aGVsbG8=");
|
||||
let id = c.create(#{ name: "a.txt", content_type: "text/plain", data: data });
|
||||
let back = c.get(id);
|
||||
base64::encode(back)
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(body, json!("aGVsbG8="));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn files_head_returns_metadata_map() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = files::collection("avatars");
|
||||
let data = base64::decode("aGVsbG8=");
|
||||
let id = c.create(#{ name: "a.txt", content_type: "text/plain", data: data });
|
||||
let meta = c.head(id);
|
||||
#{ name: meta.name, content_type: meta.content_type, size: meta.size, has_checksum: meta.checksum != () }
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({ "name": "a.txt", "content_type": "text/plain", "size": 5, "has_checksum": true })
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn files_get_and_head_missing_return_unit() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = files::collection("avatars");
|
||||
let g = c.get("00000000-0000-0000-0000-000000000000");
|
||||
let h = c.head("00000000-0000-0000-0000-000000000000");
|
||||
#{ g: g == (), h: h == () }
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(body, json!({ "g": true, "h": true }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn files_update_then_delete() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = files::collection("avatars");
|
||||
let id = c.create(#{ name: "a", content_type: "text/plain", data: base64::decode("YQ==") });
|
||||
c.update(id, #{ data: base64::decode("YmM=") }); // "bc"
|
||||
let after = base64::encode(c.get(id));
|
||||
let removed = c.delete(id);
|
||||
let gone = c.delete(id);
|
||||
#{ after: after, removed: removed, gone: gone }
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({ "after": "YmM=", "removed": true, "gone": false })
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn files_create_missing_data_throws_naming_field() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = files::collection("avatars");
|
||||
c.create(#{ name: "a", content_type: "text/plain" })
|
||||
"#;
|
||||
let err = run_script_err(engine, src, baseline_request(app)).await;
|
||||
assert!(
|
||||
err.contains("data"),
|
||||
"error should name the missing field: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn files_create_missing_name_throws_naming_field() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = files::collection("avatars");
|
||||
c.create(#{ content_type: "text/plain", data: base64::decode("YQ==") })
|
||||
"#;
|
||||
let err = run_script_err(engine, src, baseline_request(app)).await;
|
||||
assert!(
|
||||
err.contains("name"),
|
||||
"error should name the missing field: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn files_empty_collection_name_throws() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let err = run_script_err(engine, r#"files::collection("")"#, baseline_request(app)).await;
|
||||
assert!(err.to_lowercase().contains("empty"), "got {err}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn files_list_returns_files_array() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = files::collection("avatars");
|
||||
c.create(#{ name: "a", content_type: "text/plain", data: base64::decode("YQ==") });
|
||||
c.create(#{ name: "b", content_type: "text/plain", data: base64::decode("Yg==") });
|
||||
let page = c.list();
|
||||
page.files.len()
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(body, json!(2));
|
||||
}
|
||||
338
crates/executor-core/tests/sdk_http.rs
Normal file
338
crates/executor-core/tests/sdk_http.rs
Normal file
@@ -0,0 +1,338 @@
|
||||
//! Bridge integration for the `http::*` SDK (v1.1.4).
|
||||
//!
|
||||
//! Runs a real Rhai engine under `spawn_blocking` against an in-memory
|
||||
//! `HttpService` fake that records the last request and returns a
|
||||
//! configured response (or error). This exercises the full bridge:
|
||||
//! option parsing, body dispatch, response→map projection, the
|
||||
//! throw-on-network-error / no-throw-on-non-2xx convention, and that
|
||||
//! `cx.app_id` / `cx.script_id` are forwarded for attribution.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, HttpError, HttpRequest, HttpResponse, HttpService, NoopDeadLetterService,
|
||||
NoopDocsService, NoopEventEmitter, NoopKvService, NoopModuleSource, RequestId, ScriptId,
|
||||
ScriptSandbox, Services,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// What the fake returns. Either a canned response or an error.
|
||||
#[derive(Clone)]
|
||||
enum Behavior {
|
||||
Respond(HttpResponse),
|
||||
Fail(String), // becomes HttpError::Network
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Recorded {
|
||||
last: Option<HttpRequest>,
|
||||
last_app: Option<AppId>,
|
||||
last_script: Option<String>,
|
||||
}
|
||||
|
||||
struct FakeHttp {
|
||||
behavior: Behavior,
|
||||
recorded: Mutex<Recorded>,
|
||||
}
|
||||
|
||||
impl FakeHttp {
|
||||
fn responding(status: u16, content_type: &str, body: &str) -> Arc<Self> {
|
||||
let mut headers = BTreeMap::new();
|
||||
headers.insert("content-type".into(), content_type.into());
|
||||
Arc::new(Self {
|
||||
behavior: Behavior::Respond(HttpResponse {
|
||||
status,
|
||||
headers,
|
||||
body_raw: body.into(),
|
||||
}),
|
||||
recorded: Mutex::new(Recorded::default()),
|
||||
})
|
||||
}
|
||||
|
||||
fn failing(msg: &str) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
behavior: Behavior::Fail(msg.into()),
|
||||
recorded: Mutex::new(Recorded::default()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HttpService for FakeHttp {
|
||||
async fn request(
|
||||
&self,
|
||||
cx: &picloud_shared::SdkCallCx,
|
||||
req: HttpRequest,
|
||||
) -> Result<HttpResponse, HttpError> {
|
||||
{
|
||||
let mut r = self.recorded.lock().unwrap();
|
||||
r.last = Some(req.clone());
|
||||
r.last_app = Some(cx.app_id);
|
||||
r.last_script = Some(cx.script_id.to_string());
|
||||
}
|
||||
match &self.behavior {
|
||||
Behavior::Respond(resp) => Ok(resp.clone()),
|
||||
Behavior::Fail(msg) => Err(HttpError::Network(msg.clone())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn engine_with(http: Arc<dyn HttpService>) -> Arc<Engine> {
|
||||
let services = Services::new(
|
||||
Arc::new(NoopKvService),
|
||||
Arc::new(NoopDocsService),
|
||||
Arc::new(NoopDeadLetterService),
|
||||
Arc::new(NoopEventEmitter),
|
||||
Arc::new(NoopModuleSource),
|
||||
http,
|
||||
Arc::new(picloud_shared::NoopFilesService),
|
||||
Arc::new(picloud_shared::NoopPubsubService),
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
Arc::new(picloud_shared::NoopEmailService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
fn baseline_request(app_id: AppId, script_id: ScriptId) -> ExecRequest {
|
||||
let execution_id = ExecutionId::new();
|
||||
ExecRequest {
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id,
|
||||
script_name: "http-test".into(),
|
||||
invocation_type: InvocationType::Http,
|
||||
path: "/http-test".into(),
|
||||
headers: BTreeMap::new(),
|
||||
body: Value::Null,
|
||||
params: BTreeMap::new(),
|
||||
query: BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(engine: Arc<Engine>, src: &str, req: ExecRequest) -> Value {
|
||||
let src = src.to_string();
|
||||
tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||
.await
|
||||
.expect("spawn_blocking should not panic")
|
||||
.expect("script execution should succeed")
|
||||
.body
|
||||
}
|
||||
|
||||
async fn run_err(engine: Arc<Engine>, src: &str, req: ExecRequest) -> String {
|
||||
let src = src.to_string();
|
||||
let err = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||
.await
|
||||
.unwrap()
|
||||
.expect_err("script should throw");
|
||||
format!("{err:?}")
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_returns_status_and_json_body() {
|
||||
let http = FakeHttp::responding(200, "application/json", r#"{"ok":true,"n":7}"#);
|
||||
let engine = engine_with(http.clone());
|
||||
let src = r#"
|
||||
let r = http::get("https://api.example.com/x");
|
||||
#{ status: r.status, ok: r.body.ok, n: r.body.n }
|
||||
"#;
|
||||
let body = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await;
|
||||
assert_eq!(body, json!({ "status": 200, "ok": true, "n": 7 }));
|
||||
// GET carries no body.
|
||||
assert!(http
|
||||
.recorded
|
||||
.lock()
|
||||
.unwrap()
|
||||
.last
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.body
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn non_json_body_stays_string() {
|
||||
let http = FakeHttp::responding(200, "text/plain", "plain text");
|
||||
let engine = engine_with(http);
|
||||
let src = r#"http::get("https://x/").body"#;
|
||||
let body = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await;
|
||||
assert_eq!(body, json!("plain text"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn empty_body_is_unit() {
|
||||
let http = FakeHttp::responding(204, "text/plain", "");
|
||||
let engine = engine_with(http);
|
||||
let src = r#"
|
||||
let r = http::get("https://x/");
|
||||
#{ is_unit: r.body == (), raw: r.body_raw }
|
||||
"#;
|
||||
let body = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await;
|
||||
assert_eq!(body, json!({ "is_unit": true, "raw": "" }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn post_map_body_is_json_encoded() {
|
||||
let http = FakeHttp::responding(200, "application/json", "{}");
|
||||
let engine = engine_with(http.clone());
|
||||
let src = r#"http::post("https://hooks/x", #{ text: "hello", n: 3 }).status"#;
|
||||
let _ = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await;
|
||||
let rec = http.recorded.lock().unwrap();
|
||||
let req = rec.last.as_ref().unwrap();
|
||||
assert_eq!(req.method, "POST");
|
||||
assert_eq!(req.content_type.as_deref(), Some("application/json"));
|
||||
let sent: Value = serde_json::from_slice(req.body.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(sent, json!({ "text": "hello", "n": 3 }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn post_string_body_is_text_plain() {
|
||||
let http = FakeHttp::responding(200, "text/plain", "ok");
|
||||
let engine = engine_with(http.clone());
|
||||
let src = r#"http::post("https://x/", "raw payload").status"#;
|
||||
let _ = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await;
|
||||
let rec = http.recorded.lock().unwrap();
|
||||
let req = rec.last.as_ref().unwrap();
|
||||
assert_eq!(req.content_type.as_deref(), Some("text/plain"));
|
||||
assert_eq!(req.body.as_deref(), Some(&b"raw payload"[..]));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn post_unit_body_sends_nothing() {
|
||||
let http = FakeHttp::responding(200, "text/plain", "ok");
|
||||
let engine = engine_with(http.clone());
|
||||
let src = r#"http::post("https://x/", ()).status"#;
|
||||
let _ = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await;
|
||||
assert!(http
|
||||
.recorded
|
||||
.lock()
|
||||
.unwrap()
|
||||
.last
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.body
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn custom_headers_and_timeout_forwarded() {
|
||||
let http = FakeHttp::responding(200, "text/plain", "ok");
|
||||
let engine = engine_with(http.clone());
|
||||
let src = r#"
|
||||
http::get("https://x/", #{
|
||||
headers: #{ "Authorization": "Bearer t0ken" },
|
||||
timeout_ms: 4200,
|
||||
}).status
|
||||
"#;
|
||||
let _ = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await;
|
||||
let rec = http.recorded.lock().unwrap();
|
||||
let req = rec.last.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
req.headers.get("Authorization").map(String::as_str),
|
||||
Some("Bearer t0ken")
|
||||
);
|
||||
assert_eq!(req.timeout_ms, 4200);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unknown_option_key_throws() {
|
||||
let http = FakeHttp::responding(200, "text/plain", "ok");
|
||||
let engine = engine_with(http);
|
||||
let src = r#"http::get("https://x/", #{ timeoutms: 1000 })"#; // typo
|
||||
let err = run_err(engine, src, baseline_request(AppId::new(), ScriptId::new())).await;
|
||||
assert!(err.contains("unknown option key"), "got {err}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn timeout_above_max_throws() {
|
||||
let http = FakeHttp::responding(200, "text/plain", "ok");
|
||||
let engine = engine_with(http);
|
||||
let src = r#"http::get("https://x/", #{ timeout_ms: 99999 })"#;
|
||||
let err = run_err(engine, src, baseline_request(AppId::new(), ScriptId::new())).await;
|
||||
assert!(err.contains("maximum"), "got {err}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn non_2xx_does_not_throw() {
|
||||
let http = FakeHttp::responding(503, "text/plain", "down");
|
||||
let engine = engine_with(http);
|
||||
let src = r#"http::get("https://x/").status"#;
|
||||
let body = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await;
|
||||
assert_eq!(body, json!(503));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn network_error_throws_with_http_prefix() {
|
||||
let http = FakeHttp::failing("connection refused");
|
||||
let engine = engine_with(http);
|
||||
let src = r#"http::get("https://x/")"#;
|
||||
let err = run_err(engine, src, baseline_request(AppId::new(), ScriptId::new())).await;
|
||||
assert!(err.contains("http:"), "expected http: prefix, got {err}");
|
||||
assert!(err.contains("connection refused"), "got {err}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn post_form_url_encodes() {
|
||||
let http = FakeHttp::responding(200, "text/plain", "ok");
|
||||
let engine = engine_with(http.clone());
|
||||
let src = r#"http::post_form("https://x/login", #{ user: "alice", pw: "p@ss word" }).status"#;
|
||||
let _ = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await;
|
||||
let rec = http.recorded.lock().unwrap();
|
||||
let req = rec.last.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
req.content_type.as_deref(),
|
||||
Some("application/x-www-form-urlencoded")
|
||||
);
|
||||
let body = String::from_utf8(req.body.clone().unwrap()).unwrap();
|
||||
// order is map iteration order; assert both pairs present, encoded.
|
||||
assert!(body.contains("user=alice"), "got {body}");
|
||||
assert!(body.contains("pw=p%40ss+word"), "got {body}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn request_escape_hatch_arbitrary_method() {
|
||||
let http = FakeHttp::responding(200, "text/plain", "ok");
|
||||
let engine = engine_with(http.clone());
|
||||
let src = r#"http::request("OPTIONS", "https://x/").status"#;
|
||||
let _ = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await;
|
||||
assert_eq!(
|
||||
http.recorded.lock().unwrap().last.as_ref().unwrap().method,
|
||||
"OPTIONS"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn default_user_agent_carries_script_id() {
|
||||
let http = FakeHttp::responding(200, "text/plain", "ok");
|
||||
let engine = engine_with(http.clone());
|
||||
let script_id = ScriptId::new();
|
||||
let src = r#"http::get("https://x/").status"#;
|
||||
let _ = run(engine, src, baseline_request(AppId::new(), script_id)).await;
|
||||
let rec = http.recorded.lock().unwrap();
|
||||
// The bridge forwards script_id on the request; the manager-core
|
||||
// impl turns it into the User-Agent. Here we assert the forward.
|
||||
assert_eq!(
|
||||
rec.last.as_ref().unwrap().script_id.as_deref(),
|
||||
Some(script_id.to_string().as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn cx_app_id_forwarded_for_attribution() {
|
||||
let http = FakeHttp::responding(200, "text/plain", "ok");
|
||||
let engine = engine_with(http.clone());
|
||||
let app = AppId::new();
|
||||
let src = r#"http::get("https://x/").status"#;
|
||||
let _ = run(engine, src, baseline_request(app, ScriptId::new())).await;
|
||||
assert_eq!(http.recorded.lock().unwrap().last_app, Some(app));
|
||||
}
|
||||
@@ -11,7 +11,8 @@ use async_trait::async_trait;
|
||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, KvError, KvListPage, KvService, NoopDeadLetterService, NoopDocsService,
|
||||
NoopEventEmitter, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services,
|
||||
NoopEventEmitter, NoopHttpService, NoopModuleSource, RequestId, ScriptId, ScriptSandbox,
|
||||
SdkCallCx, Services,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::Mutex;
|
||||
@@ -104,6 +105,12 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(NoopDocsService),
|
||||
Arc::new(NoopDeadLetterService),
|
||||
Arc::new(NoopEventEmitter),
|
||||
Arc::new(NoopModuleSource),
|
||||
Arc::new(NoopHttpService),
|
||||
Arc::new(picloud_shared::NoopFilesService),
|
||||
Arc::new(picloud_shared::NoopPubsubService),
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
Arc::new(picloud_shared::NoopEmailService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
159
crates/executor-core/tests/sdk_pubsub.rs
Normal file
159
crates/executor-core/tests/sdk_pubsub.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
//! `pubsub::` SDK bridge integration tests — runs a real Rhai engine
|
||||
//! against an in-memory `PubsubService` that records the published
|
||||
//! `(topic, message)`. Verifies the message JSON encoding the wire
|
||||
//! contract requires: Maps, Arrays, strings, numbers, bool, null, and
|
||||
//! **Blob → base64**, including nesting.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopFilesService,
|
||||
NoopHttpService, NoopKvService, NoopModuleSource, PubsubError, PubsubService, RequestId,
|
||||
ScriptId, ScriptSandbox, SdkCallCx, Services,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingPubsub {
|
||||
last: Mutex<Option<(String, Value)>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PubsubService for RecordingPubsub {
|
||||
async fn publish_durable(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
topic: &str,
|
||||
message: Value,
|
||||
) -> Result<(), PubsubError> {
|
||||
if topic.trim().is_empty() {
|
||||
return Err(PubsubError::EmptyTopic);
|
||||
}
|
||||
*self.last.lock().unwrap() = Some((topic.to_string(), message));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn make_engine(svc: Arc<RecordingPubsub>) -> Arc<Engine> {
|
||||
let services = Services::new(
|
||||
Arc::new(NoopKvService),
|
||||
Arc::new(NoopDocsService),
|
||||
Arc::new(NoopDeadLetterService),
|
||||
Arc::new(NoopEventEmitter),
|
||||
Arc::new(NoopModuleSource),
|
||||
Arc::new(NoopHttpService),
|
||||
Arc::new(NoopFilesService),
|
||||
svc,
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
Arc::new(picloud_shared::NoopEmailService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
let execution_id = ExecutionId::new();
|
||||
ExecRequest {
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
script_name: "pubsub-test".into(),
|
||||
invocation_type: InvocationType::Http,
|
||||
path: "/pubsub-test".into(),
|
||||
headers: BTreeMap::new(),
|
||||
body: Value::Null,
|
||||
params: BTreeMap::new(),
|
||||
query: BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(engine: Arc<Engine>, src: &str, req: ExecRequest) {
|
||||
let src = src.to_string();
|
||||
tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||
.await
|
||||
.expect("spawn_blocking should not panic")
|
||||
.expect("script execution should succeed");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn publish_map_message() {
|
||||
let svc = Arc::new(RecordingPubsub::default());
|
||||
let engine = make_engine(svc.clone());
|
||||
run(
|
||||
engine,
|
||||
r#"pubsub::publish_durable("user.created", #{ user_id: "abc", n: 7, ok: true });"#,
|
||||
baseline_request(AppId::new()),
|
||||
)
|
||||
.await;
|
||||
let (topic, msg) = svc.last.lock().unwrap().clone().unwrap();
|
||||
assert_eq!(topic, "user.created");
|
||||
assert_eq!(msg, json!({ "user_id": "abc", "n": 7, "ok": true }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn publish_scalar_and_array_and_null() {
|
||||
let svc = Arc::new(RecordingPubsub::default());
|
||||
let engine = make_engine(svc.clone());
|
||||
run(
|
||||
engine,
|
||||
r#"pubsub::publish_durable("a", [1, "two", false, ()]);"#,
|
||||
baseline_request(AppId::new()),
|
||||
)
|
||||
.await;
|
||||
let (_t, msg) = svc.last.lock().unwrap().clone().unwrap();
|
||||
assert_eq!(msg, json!([1, "two", false, null]));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn publish_number_scalar() {
|
||||
let svc = Arc::new(RecordingPubsub::default());
|
||||
let engine = make_engine(svc.clone());
|
||||
run(
|
||||
engine,
|
||||
r#"pubsub::publish_durable("metric", 42);"#,
|
||||
baseline_request(AppId::new()),
|
||||
)
|
||||
.await;
|
||||
let (_t, msg) = svc.last.lock().unwrap().clone().unwrap();
|
||||
assert_eq!(msg, json!(42));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn publish_blob_encodes_base64_including_nested() {
|
||||
let svc = Arc::new(RecordingPubsub::default());
|
||||
let engine = make_engine(svc.clone());
|
||||
// base64("hello") = "aGVsbG8=" (STANDARD, padded).
|
||||
run(
|
||||
engine,
|
||||
r#"
|
||||
let data = base64::decode("aGVsbG8=");
|
||||
pubsub::publish_durable("blobs", #{ raw: data, list: [data] });
|
||||
"#,
|
||||
baseline_request(AppId::new()),
|
||||
)
|
||||
.await;
|
||||
let (_t, msg) = svc.last.lock().unwrap().clone().unwrap();
|
||||
assert_eq!(msg, json!({ "raw": "aGVsbG8=", "list": ["aGVsbG8="] }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn publish_empty_topic_throws() {
|
||||
let svc = Arc::new(RecordingPubsub::default());
|
||||
let engine = make_engine(svc.clone());
|
||||
let src = r#"pubsub::publish_durable("", 1);"#.to_string();
|
||||
let req = baseline_request(AppId::new());
|
||||
let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||
.await
|
||||
.expect("spawn_blocking should not panic");
|
||||
assert!(res.is_err(), "empty topic should throw");
|
||||
}
|
||||
213
crates/executor-core/tests/sdk_secrets.rs
Normal file
213
crates/executor-core/tests/sdk_secrets.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
//! `secrets::` SDK bridge integration tests — runs a real Rhai engine
|
||||
//! against an in-memory `SecretsService` impl. Mirrors `sdk_kv.rs`: the
|
||||
//! engine runs under `spawn_blocking` so the bridge's `block_on` has a
|
||||
//! reachable runtime.
|
||||
//!
|
||||
//! This exercises the Rhai⇄JSON plumbing + the static `secrets` module
|
||||
//! (set/get/delete/list, the missing→() contract, and the
|
||||
//! String/Map/Array type round-trip). Encryption + authz + the
|
||||
//! cross-app boundary are unit-tested at the service layer in
|
||||
//! `manager-core::secrets_service`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopHttpService,
|
||||
NoopKvService, NoopModuleSource, RequestId, ScriptId, ScriptSandbox, SdkCallCx, SecretsError,
|
||||
SecretsListPage, SecretsService, Services,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// In-memory secrets store keyed by `(app_id, name)`. Stores the JSON
|
||||
/// value directly — the bridge test only cares about the Rhai plumbing,
|
||||
/// not the at-rest encryption (which the service layer owns).
|
||||
#[derive(Default)]
|
||||
struct InMemorySecrets {
|
||||
data: Mutex<BTreeMap<(AppId, String), Value>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SecretsService for InMemorySecrets {
|
||||
async fn get(&self, cx: &SdkCallCx, name: &str) -> Result<Option<Value>, SecretsError> {
|
||||
picloud_shared::validate_secret_name(name)?;
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(cx.app_id, name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn set(&self, cx: &SdkCallCx, name: &str, value: Value) -> Result<(), SecretsError> {
|
||||
picloud_shared::validate_secret_name(name)?;
|
||||
self.data
|
||||
.lock()
|
||||
.await
|
||||
.insert((cx.app_id, name.to_string()), value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result<bool, SecretsError> {
|
||||
picloud_shared::validate_secret_name(name)?;
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.remove(&(cx.app_id, name.to_string()))
|
||||
.is_some())
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsListPage, SecretsError> {
|
||||
let data = self.data.lock().await;
|
||||
let mut names: Vec<String> = data
|
||||
.iter()
|
||||
.filter(|((a, _), _)| *a == cx.app_id)
|
||||
.map(|((_, n), _)| n.clone())
|
||||
.filter(|n| cursor.is_none_or(|c| n.as_str() > c))
|
||||
.collect();
|
||||
names.sort();
|
||||
let take = if limit == 0 {
|
||||
usize::MAX
|
||||
} else {
|
||||
limit as usize
|
||||
};
|
||||
let next_cursor = if names.len() > take {
|
||||
names.truncate(take);
|
||||
names.last().cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(SecretsListPage { names, next_cursor })
|
||||
}
|
||||
}
|
||||
|
||||
fn make_engine() -> Arc<Engine> {
|
||||
let services = Services::new(
|
||||
Arc::new(NoopKvService),
|
||||
Arc::new(NoopDocsService),
|
||||
Arc::new(NoopDeadLetterService),
|
||||
Arc::new(NoopEventEmitter),
|
||||
Arc::new(NoopModuleSource),
|
||||
Arc::new(NoopHttpService),
|
||||
Arc::new(picloud_shared::NoopFilesService),
|
||||
Arc::new(picloud_shared::NoopPubsubService),
|
||||
Arc::new(InMemorySecrets::default()),
|
||||
Arc::new(picloud_shared::NoopEmailService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
let execution_id = ExecutionId::new();
|
||||
ExecRequest {
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
script_name: "secrets-test".into(),
|
||||
invocation_type: InvocationType::Http,
|
||||
path: "/secrets-test".into(),
|
||||
headers: BTreeMap::new(),
|
||||
body: Value::Null,
|
||||
params: BTreeMap::new(),
|
||||
query: BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_script(engine: Arc<Engine>, src: &str, req: ExecRequest) -> Value {
|
||||
let src = src.to_string();
|
||||
tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||
.await
|
||||
.expect("spawn_blocking should not panic")
|
||||
.expect("script execution should succeed")
|
||||
.body
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn set_then_get_string_round_trips() {
|
||||
let engine = make_engine();
|
||||
let src = r#"
|
||||
secrets::set("stripe_key", "sk_live_xxx");
|
||||
secrets::get("stripe_key")
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(AppId::new())).await;
|
||||
// A String comes back a String, not a JSON-quoted "\"sk_live_xxx\"".
|
||||
assert_eq!(body, json!("sk_live_xxx"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn set_then_get_map_round_trips() {
|
||||
let engine = make_engine();
|
||||
let src = r#"
|
||||
secrets::set("oauth", #{ client_id: "abc", client_secret: "xyz" });
|
||||
secrets::get("oauth")
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(AppId::new())).await;
|
||||
assert_eq!(body, json!({ "client_id": "abc", "client_secret": "xyz" }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_missing_returns_unit() {
|
||||
let engine = make_engine();
|
||||
let src = r#"
|
||||
let v = secrets::get("nope");
|
||||
#{ is_unit: type_of(v) == "()" }
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(AppId::new())).await;
|
||||
assert_eq!(body, json!({ "is_unit": true }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn delete_returns_was_present() {
|
||||
let engine = make_engine();
|
||||
let src = r#"
|
||||
secrets::set("k", "v");
|
||||
let first = secrets::delete("k");
|
||||
let second = secrets::delete("k");
|
||||
#{ first: first, second: second }
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(AppId::new())).await;
|
||||
assert_eq!(body, json!({ "first": true, "second": false }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn list_returns_names_and_cursor() {
|
||||
let engine = make_engine();
|
||||
let src = r#"
|
||||
secrets::set("a", 1);
|
||||
secrets::set("b", 2);
|
||||
secrets::set("c", 3);
|
||||
let page = secrets::list(#{ cursor: (), limit: 2 });
|
||||
page
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(AppId::new())).await;
|
||||
assert_eq!(body["names"], json!(["a", "b"]));
|
||||
assert_eq!(body["next_cursor"], json!("b"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn empty_name_throws() {
|
||||
let engine = make_engine();
|
||||
let src = r#" secrets::set("", "v"); #{ ok: true } "#;
|
||||
let app = AppId::new();
|
||||
let out = tokio::task::spawn_blocking(move || engine.execute(src, baseline_request(app)))
|
||||
.await
|
||||
.expect("spawn_blocking");
|
||||
assert!(out.is_err(), "empty secret name must throw");
|
||||
}
|
||||
244
crates/executor-core/tests/sdk_subscriber_token.rs
Normal file
244
crates/executor-core/tests/sdk_subscriber_token.rs
Normal file
@@ -0,0 +1,244 @@
|
||||
//! `pubsub::subscriber_token` SDK bridge integration tests (v1.1.6).
|
||||
//!
|
||||
//! Runs a real Rhai engine against a fake `PubsubService` whose
|
||||
//! `mint_subscriber_token` mirrors the production validation (principal
|
||||
//! required, non-empty topics, ttl clamp, externally-subscribable check)
|
||||
//! and signs a real token. These cover the bridge surface: array →
|
||||
//! `Vec<String>` forwarding, the omitted/`()`/integer ttl handling, and
|
||||
//! errors surfacing as thrown Rhai errors. The authoritative validation
|
||||
//! logic is unit-tested in `manager-core::pubsub_service`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::subscriber_token::{self, TokenClaims};
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, ExecutionId, InstanceRole, NoopDeadLetterService, NoopDocsService,
|
||||
NoopEventEmitter, NoopFilesService, NoopHttpService, NoopKvService, NoopModuleSource,
|
||||
Principal, PubsubError, PubsubService, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
const FAKE_KEY: [u8; 32] = [7u8; 32];
|
||||
const MIN_TTL: i64 = 10;
|
||||
const MAX_TTL: i64 = 86_400;
|
||||
const DEFAULT_TTL: i64 = 3_600;
|
||||
|
||||
/// Fake that mirrors the production mint rules and signs with FAKE_KEY.
|
||||
#[derive(Default)]
|
||||
struct FakeMintPubsub;
|
||||
|
||||
#[async_trait]
|
||||
impl PubsubService for FakeMintPubsub {
|
||||
async fn publish_durable(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_topic: &str,
|
||||
_message: Value,
|
||||
) -> Result<(), PubsubError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mint_subscriber_token(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
topics: Vec<String>,
|
||||
ttl_seconds: Option<i64>,
|
||||
) -> Result<String, PubsubError> {
|
||||
if cx.principal.is_none() {
|
||||
return Err(PubsubError::SubscriberToken(
|
||||
"pubsub::subscriber_token: requires an authenticated principal".into(),
|
||||
));
|
||||
}
|
||||
if topics.is_empty() {
|
||||
return Err(PubsubError::SubscriberToken(
|
||||
"pubsub::subscriber_token: topics list must not be empty".into(),
|
||||
));
|
||||
}
|
||||
let ttl = ttl_seconds.unwrap_or(DEFAULT_TTL);
|
||||
if !(MIN_TTL..=MAX_TTL).contains(&ttl) {
|
||||
return Err(PubsubError::SubscriberToken(format!(
|
||||
"pubsub::subscriber_token: ttl_seconds must be between {MIN_TTL} and {MAX_TTL}"
|
||||
)));
|
||||
}
|
||||
for name in &topics {
|
||||
// Only "chat" and "notify" are "registered" in this fake.
|
||||
if name != "chat" && name != "notify" {
|
||||
return Err(PubsubError::SubscriberToken(format!(
|
||||
"pubsub::subscriber_token: topic {name} is not externally subscribable"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let now = 1_000_000;
|
||||
Ok(subscriber_token::sign(
|
||||
&FAKE_KEY,
|
||||
&TokenClaims {
|
||||
app_id: cx.app_id,
|
||||
topics,
|
||||
exp: now + ttl,
|
||||
iat: now,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn make_engine() -> Arc<Engine> {
|
||||
let services = Services::new(
|
||||
Arc::new(NoopKvService),
|
||||
Arc::new(NoopDocsService),
|
||||
Arc::new(NoopDeadLetterService),
|
||||
Arc::new(NoopEventEmitter),
|
||||
Arc::new(NoopModuleSource),
|
||||
Arc::new(NoopHttpService),
|
||||
Arc::new(NoopFilesService),
|
||||
Arc::new(FakeMintPubsub),
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
Arc::new(picloud_shared::NoopEmailService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
fn request(app_id: AppId, with_principal: bool) -> ExecRequest {
|
||||
let execution_id = ExecutionId::new();
|
||||
ExecRequest {
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
script_name: "token-test".into(),
|
||||
invocation_type: InvocationType::Http,
|
||||
path: "/token-test".into(),
|
||||
headers: BTreeMap::new(),
|
||||
body: Value::Null,
|
||||
params: BTreeMap::new(),
|
||||
query: BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
principal: with_principal.then(|| Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_ok(engine: Arc<Engine>, src: &str, req: ExecRequest) -> Value {
|
||||
let src = src.to_string();
|
||||
tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||
.await
|
||||
.expect("spawn_blocking should not panic")
|
||||
.expect("script execution should succeed")
|
||||
.body
|
||||
}
|
||||
|
||||
async fn run_err(engine: Arc<Engine>, src: &str, req: ExecRequest) {
|
||||
let src = src.to_string();
|
||||
let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||
.await
|
||||
.expect("spawn_blocking should not panic");
|
||||
assert!(res.is_err(), "expected script to throw");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn token_contains_topics_and_expiry() {
|
||||
let app = AppId::new();
|
||||
let body = run_ok(
|
||||
make_engine(),
|
||||
r#"#{ token: pubsub::subscriber_token(["chat", "notify"], 120) }"#,
|
||||
request(app, true),
|
||||
)
|
||||
.await;
|
||||
let token = body["token"].as_str().expect("token string");
|
||||
let claims = subscriber_token::verify(&FAKE_KEY, token, 1_000_001).unwrap();
|
||||
assert_eq!(claims.app_id, app);
|
||||
assert_eq!(
|
||||
claims.topics,
|
||||
vec!["chat".to_string(), "notify".to_string()]
|
||||
);
|
||||
assert_eq!(claims.exp - claims.iat, 120);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn omitted_ttl_uses_default() {
|
||||
let app = AppId::new();
|
||||
let body = run_ok(
|
||||
make_engine(),
|
||||
r#"#{ token: pubsub::subscriber_token(["chat"]) }"#,
|
||||
request(app, true),
|
||||
)
|
||||
.await;
|
||||
let token = body["token"].as_str().unwrap();
|
||||
let claims = subscriber_token::verify(&FAKE_KEY, token, 1_000_001).unwrap();
|
||||
assert_eq!(claims.exp - claims.iat, DEFAULT_TTL);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unit_ttl_uses_default() {
|
||||
let app = AppId::new();
|
||||
let body = run_ok(
|
||||
make_engine(),
|
||||
r#"#{ token: pubsub::subscriber_token(["chat"], ()) }"#,
|
||||
request(app, true),
|
||||
)
|
||||
.await;
|
||||
let token = body["token"].as_str().unwrap();
|
||||
let claims = subscriber_token::verify(&FAKE_KEY, token, 1_000_001).unwrap();
|
||||
assert_eq!(claims.exp - claims.iat, DEFAULT_TTL);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn empty_topics_throws() {
|
||||
run_err(
|
||||
make_engine(),
|
||||
r"pubsub::subscriber_token([], 60)",
|
||||
request(AppId::new(), true),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ttl_below_min_throws() {
|
||||
run_err(
|
||||
make_engine(),
|
||||
r#"pubsub::subscriber_token(["chat"], 5)"#,
|
||||
request(AppId::new(), true),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ttl_above_max_throws() {
|
||||
run_err(
|
||||
make_engine(),
|
||||
r#"pubsub::subscriber_token(["chat"], 90000)"#,
|
||||
request(AppId::new(), true),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn anonymous_principal_throws() {
|
||||
run_err(
|
||||
make_engine(),
|
||||
r#"pubsub::subscriber_token(["chat"], 60)"#,
|
||||
request(AppId::new(), false),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unregistered_topic_throws() {
|
||||
run_err(
|
||||
make_engine(),
|
||||
r#"pubsub::subscriber_token(["chat", "secret"], 60)"#,
|
||||
request(AppId::new(), true),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -23,13 +23,21 @@ tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
chrono-tz.workspace = true
|
||||
cron.workspace = true
|
||||
sqlx.workspace = true
|
||||
url.workspace = true
|
||||
reqwest.workspace = true
|
||||
|
||||
argon2.workspace = true
|
||||
sha2.workspace = true
|
||||
# HMAC-SHA256 verification of inbound-email provider signatures (v1.1.7).
|
||||
hmac.workspace = true
|
||||
hex.workspace = true
|
||||
base64.workspace = true
|
||||
data-encoding.workspace = true
|
||||
# Outbound SMTP email (v1.1.7 email::send / send_html).
|
||||
lettre.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
|
||||
31
crates/manager-core/migrations/0015_scripts_kind.sql
Normal file
31
crates/manager-core/migrations/0015_scripts_kind.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- v1.1.3: distinguish endpoint scripts (HTTP / trigger entry points) from
|
||||
-- module scripts (libraries `import`ed by other scripts). The Rhai module
|
||||
-- resolver added in v1.1.3 looks up `kind = 'module'` rows by
|
||||
-- `(app_id, name)`; route bind and trigger create reject `kind = 'module'`
|
||||
-- targets.
|
||||
--
|
||||
-- Backfill: existing rows take the DEFAULT clause on column add. Every
|
||||
-- script that existed in v1.0 / v1.1.0 / v1.1.1 / v1.1.2 was an endpoint
|
||||
-- (the only kind those versions supported), which matches the default.
|
||||
ALTER TABLE scripts
|
||||
ADD COLUMN kind TEXT NOT NULL DEFAULT 'endpoint'
|
||||
CHECK (kind IN ('endpoint', 'module'));
|
||||
|
||||
-- Composite index on (app_id, kind) so the resolver's per-app module
|
||||
-- lookup ("modules in app X named Y") is one index scan. The existing
|
||||
-- per-app UNIQUE on `name` already serves name-based lookups, but it
|
||||
-- doesn't help when filtering specifically for `kind = 'module'`.
|
||||
CREATE INDEX idx_scripts_app_kind ON scripts (app_id, kind);
|
||||
|
||||
-- Modules are imported by exact string name; arbitrary spaces / control
|
||||
-- characters would make `import "<name>"` fragile. We constrain module
|
||||
-- names to a conservative identifier shape (letters, digits, underscore;
|
||||
-- starts with a non-digit; up to 64 chars). Endpoint scripts keep the
|
||||
-- looser pre-v1.1.3 name rules — the dashboard generates endpoint names
|
||||
-- (and some users may already have spaces in them; we don't break those).
|
||||
ALTER TABLE scripts
|
||||
ADD CONSTRAINT scripts_module_name_shape
|
||||
CHECK (
|
||||
kind <> 'module'
|
||||
OR name ~ '^[a-zA-Z_][a-zA-Z0-9_]{0,63}$'
|
||||
);
|
||||
35
crates/manager-core/migrations/0016_script_imports.sql
Normal file
35
crates/manager-core/migrations/0016_script_imports.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- v1.1.3: dep graph between scripts and the modules they `import`.
|
||||
--
|
||||
-- Populated at script save-time. The validator extracts literal-path
|
||||
-- `import "<name>"` declarations from the AST; the script repo writes
|
||||
-- one row per resolved (importer, imported) pair inside the same
|
||||
-- transaction as the INSERT/UPDATE on `scripts`. Unresolved names
|
||||
-- (imported module doesn't exist yet) are silently skipped — the
|
||||
-- resolver returns ErrorModuleNotFound at runtime, and a later save
|
||||
-- of either script re-resolves and writes the edge.
|
||||
--
|
||||
-- Dynamic imports (`import some_var as alias;`) are not tracked
|
||||
-- here — the resolver still honors them at runtime, but the graph
|
||||
-- only captures names known at compile time. Document as a known
|
||||
-- v1.1.3 limitation.
|
||||
--
|
||||
-- Purpose: drives a future "Used by" panel on a module's detail page
|
||||
-- (v1.2+) and is the foundation for cluster-mode eager cache
|
||||
-- invalidation (v1.3+). v1.1.3 only persists the rows; no admin
|
||||
-- endpoint surfaces them yet.
|
||||
CREATE TABLE script_imports (
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
importer_script_id UUID NOT NULL REFERENCES scripts(id) ON DELETE CASCADE,
|
||||
imported_script_id UUID NOT NULL REFERENCES scripts(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (importer_script_id, imported_script_id)
|
||||
);
|
||||
|
||||
-- Reverse-edge index: "list scripts that import module X". The PK
|
||||
-- covers (importer, imported) so forward lookups by importer are
|
||||
-- already free; the reverse direction needs its own index.
|
||||
CREATE INDEX idx_script_imports_imported ON script_imports (imported_script_id);
|
||||
|
||||
-- App-scoped scan ("all imports in this app") — used by the schema
|
||||
-- snapshot tests and (eventually) the admin "audit" view.
|
||||
CREATE INDEX idx_script_imports_app ON script_imports (app_id);
|
||||
43
crates/manager-core/migrations/0017_cron_triggers.sql
Normal file
43
crates/manager-core/migrations/0017_cron_triggers.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- v1.1.4: Extend the triggers framework to recognise `cron` as the
|
||||
-- fourth concrete kind (after `kv` v1.1.1, `dead_letter` v1.1.1, `docs`
|
||||
-- v1.1.2). Mirrors the 0014 docs extension: two CHECK constraints widen
|
||||
-- (strictly gaining `'cron'`), one new detail table.
|
||||
--
|
||||
-- Cron rows route through the SAME generic dispatcher path as kv/docs/
|
||||
-- dead_letter (single match-arm extension on the Rust side). The only
|
||||
-- new machinery is a scheduler task that enqueues due cron triggers
|
||||
-- into the outbox; dispatch itself is unchanged.
|
||||
|
||||
-- Extend triggers.kind to include 'cron'. No existing row carries a
|
||||
-- value outside the widened set, so the drop+add is safe.
|
||||
ALTER TABLE triggers DROP CONSTRAINT triggers_kind_check;
|
||||
ALTER TABLE triggers ADD CONSTRAINT triggers_kind_check
|
||||
CHECK (kind IN ('kv', 'dead_letter', 'docs', 'cron'));
|
||||
|
||||
-- Extend outbox.source_kind to include 'cron'. v1.1.x's existing
|
||||
-- source_kinds ('http', 'kv', 'dead_letter', 'docs') stay.
|
||||
ALTER TABLE outbox DROP CONSTRAINT outbox_source_kind_check;
|
||||
ALTER TABLE outbox ADD CONSTRAINT outbox_source_kind_check
|
||||
CHECK (source_kind IN ('http', 'kv', 'dead_letter', 'docs', 'cron'));
|
||||
|
||||
-- One row per cron trigger.
|
||||
-- schedule — 6-field cron expression (with seconds), validated
|
||||
-- at insert time by the `cron` crate.
|
||||
-- timezone — IANA tz name (e.g. "America/Los_Angeles"), validated
|
||||
-- via chrono-tz. Required so schedules like "every
|
||||
-- weekday at 9am" are unambiguous. Defaults to UTC.
|
||||
-- last_fired_at — set transactionally with each enqueue. NULL until
|
||||
-- the trigger first fires. The scheduler computes the
|
||||
-- next fire time in-process from
|
||||
-- (schedule, timezone, last_fired_at); there is no
|
||||
-- stored next_fire column (kept stateless on purpose).
|
||||
CREATE TABLE cron_trigger_details (
|
||||
trigger_id UUID PRIMARY KEY REFERENCES triggers(id) ON DELETE CASCADE,
|
||||
schedule TEXT NOT NULL,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
last_fired_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Hot lookup for the scheduler: "all enabled cron triggers due now"
|
||||
-- scans by last_fired_at.
|
||||
CREATE INDEX idx_cron_triggers_due ON cron_trigger_details (last_fired_at);
|
||||
25
crates/manager-core/migrations/0018_files.sql
Normal file
25
crates/manager-core/migrations/0018_files.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- v1.1.5: filesystem-backed blob storage. The row holds metadata +
|
||||
-- the SHA-256 checksum; the blob bytes live on disk at
|
||||
-- <PICLOUD_FILES_ROOT>/files/<app_id>/<collection>/<id[0:2]>/<id>
|
||||
-- (never in Postgres). Identity tuple is (app_id, collection, id) per
|
||||
-- docs/sdk-shape.md, matching KV/docs collection scoping.
|
||||
--
|
||||
-- The checksum is computed in a single pass during the atomic write and
|
||||
-- re-verified on read (FilesError::Corrupted on mismatch). Per-app
|
||||
-- quotas are deferred to v1.2; only the per-file size cap is enforced
|
||||
-- (in the service, not the schema).
|
||||
CREATE TABLE files (
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
collection TEXT NOT NULL,
|
||||
id UUID NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
checksum_sha256 TEXT NOT NULL, -- hex, 64 chars, lowercase
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (app_id, collection, id)
|
||||
);
|
||||
|
||||
-- List + cursor pagination scans by (app_id, collection).
|
||||
CREATE INDEX idx_files_app_collection ON files (app_id, collection);
|
||||
29
crates/manager-core/migrations/0019_files_triggers.sql
Normal file
29
crates/manager-core/migrations/0019_files_triggers.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- v1.1.5: extend the triggers framework to recognise `files` as the
|
||||
-- fifth concrete kind (after `kv`/`dead_letter` v1.1.1, `docs` v1.1.2,
|
||||
-- `cron` v1.1.4). Mirrors the 0014/0017 extensions exactly: two CHECK
|
||||
-- constraints widen (strictly gaining `'files'`), one new detail table.
|
||||
--
|
||||
-- Files rows route through the SAME generic dispatcher path as the
|
||||
-- other event kinds (single match-arm extension on the Rust side). The
|
||||
-- only new machinery is the FilesServiceImpl emitting ServiceEvents
|
||||
-- that the OutboxEventEmitter fans out — identical to KV/docs.
|
||||
|
||||
-- Extend triggers.kind to include 'files'. No existing row carries a
|
||||
-- value outside the widened set, so the drop+add is safe.
|
||||
ALTER TABLE triggers DROP CONSTRAINT triggers_kind_check;
|
||||
ALTER TABLE triggers ADD CONSTRAINT triggers_kind_check
|
||||
CHECK (kind IN ('kv', 'dead_letter', 'docs', 'cron', 'files'));
|
||||
|
||||
-- Extend outbox.source_kind to include 'files'.
|
||||
ALTER TABLE outbox DROP CONSTRAINT outbox_source_kind_check;
|
||||
ALTER TABLE outbox ADD CONSTRAINT outbox_source_kind_check
|
||||
CHECK (source_kind IN ('http', 'kv', 'dead_letter', 'docs', 'cron', 'files'));
|
||||
|
||||
-- One row per files trigger. Mirrors kv_trigger_details:
|
||||
-- collection_glob — "*", "exact", or "prefix*"
|
||||
-- ops — subset of {create, update, delete}, empty = any
|
||||
CREATE TABLE files_trigger_details (
|
||||
trigger_id UUID PRIMARY KEY REFERENCES triggers(id) ON DELETE CASCADE,
|
||||
collection_glob TEXT NOT NULL,
|
||||
ops TEXT[] NOT NULL
|
||||
);
|
||||
34
crates/manager-core/migrations/0020_pubsub_triggers.sql
Normal file
34
crates/manager-core/migrations/0020_pubsub_triggers.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- v1.1.5: extend the triggers framework to recognise `pubsub` as the
|
||||
-- sixth concrete kind. Same Layout-E shape as files (0019): two CHECK
|
||||
-- constraints widen, one new detail table.
|
||||
--
|
||||
-- Pub/sub fans out at PUBLISH time (one outbox row per matching trigger,
|
||||
-- written by the PubsubServiceImpl), so the dispatcher needs no pubsub-
|
||||
-- specific branching — a pubsub outbox row dispatches like any other
|
||||
-- async trigger.
|
||||
|
||||
-- Extend triggers.kind to include 'pubsub'.
|
||||
ALTER TABLE triggers DROP CONSTRAINT triggers_kind_check;
|
||||
ALTER TABLE triggers ADD CONSTRAINT triggers_kind_check
|
||||
CHECK (kind IN ('kv', 'dead_letter', 'docs', 'cron', 'files', 'pubsub'));
|
||||
|
||||
-- Extend outbox.source_kind to include 'pubsub'.
|
||||
ALTER TABLE outbox DROP CONSTRAINT outbox_source_kind_check;
|
||||
ALTER TABLE outbox ADD CONSTRAINT outbox_source_kind_check
|
||||
CHECK (source_kind IN ('http', 'kv', 'dead_letter', 'docs',
|
||||
'cron', 'files', 'pubsub'));
|
||||
|
||||
-- One row per pubsub trigger. `topic_pattern` is "exact", "prefix.*",
|
||||
-- or "*" — validated in Rust at trigger creation. Topics are implicit
|
||||
-- on first publish; the external-subscribable `topics` table is v1.1.6.
|
||||
CREATE TABLE pubsub_trigger_details (
|
||||
trigger_id UUID PRIMARY KEY REFERENCES triggers(id) ON DELETE CASCADE,
|
||||
topic_pattern TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Hot lookup for fan-out: "all enabled pubsub triggers in app X".
|
||||
-- Third partial index of its kind (after v1.1.1's idx_triggers_app_kind_
|
||||
-- enabled); partial indexes are tiny and the planner picks the narrowest.
|
||||
CREATE INDEX idx_triggers_app_pubsub_enabled
|
||||
ON triggers (app_id, kind)
|
||||
WHERE enabled = TRUE AND kind = 'pubsub';
|
||||
31
crates/manager-core/migrations/0021_topics.sql
Normal file
31
crates/manager-core/migrations/0021_topics.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- v1.1.6: Explicit registration for externally-subscribable topics.
|
||||
--
|
||||
-- Internal-only topics remain implicit per the §5 design-notes
|
||||
-- decision: anyone can publish_durable("any.topic", msg) and triggers
|
||||
-- can subscribe without a row here. This table only holds topics that
|
||||
-- have been explicitly externalized — external SSE subscribers can
|
||||
-- only subscribe to topics with a row here AND external_subscribable
|
||||
-- = TRUE.
|
||||
--
|
||||
-- The publish path (v1.1.5's publish_durable) does NOT consult this
|
||||
-- table: publishing to a topic with no row still fans out to triggers
|
||||
-- and to any in-process external subscribers (none exist for an
|
||||
-- unregistered topic, since external subscribers can't subscribe to
|
||||
-- one). The topics table is read by the SSE subscribe path only.
|
||||
--
|
||||
-- auth_mode values: 'public' + 'token' in v1.1.6. 'session' arrives in
|
||||
-- v1.1.8 (users-SDK); 'script' arrives in v1.2 (script-mediated auth).
|
||||
-- The CHECK constraint extends in those releases.
|
||||
CREATE TABLE topics (
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
external_subscribable BOOL NOT NULL DEFAULT FALSE,
|
||||
auth_mode TEXT NOT NULL DEFAULT 'public'
|
||||
CHECK (auth_mode IN ('public', 'token')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (app_id, name)
|
||||
);
|
||||
|
||||
-- Hot lookup: "is topic T in app X externally subscribable?" The PK
|
||||
-- (app_id, name) already covers this; an explicit index is redundant.
|
||||
19
crates/manager-core/migrations/0022_app_secrets.sql
Normal file
19
crates/manager-core/migrations/0022_app_secrets.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- v1.1.6: per-app secret material. Currently holds the HMAC signing key
|
||||
-- used to mint + verify realtime subscriber tokens
|
||||
-- (pubsub::subscriber_token → SSE /realtime/topics handshake).
|
||||
--
|
||||
-- The key is:
|
||||
-- * stable across restarts (issued tokens stay valid until expiry),
|
||||
-- * per-app (a token signed by app A is rejected by app B),
|
||||
-- * never script-accessible (scripts can't print/exfiltrate it — the
|
||||
-- SDK only mints tokens, it never returns the key).
|
||||
--
|
||||
-- The row is created lazily on the first pubsub::subscriber_token call
|
||||
-- for an app (32 random bytes). This table is the natural home for
|
||||
-- v1.1.7's encrypted per-app secrets work.
|
||||
CREATE TABLE app_secrets (
|
||||
app_id UUID PRIMARY KEY REFERENCES apps(id) ON DELETE CASCADE,
|
||||
realtime_signing_key BYTEA NOT NULL, -- 32 random bytes
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
24
crates/manager-core/migrations/0023_secrets.sql
Normal file
24
crates/manager-core/migrations/0023_secrets.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- v1.1.7: encrypted per-app secrets.
|
||||
--
|
||||
-- Operational config (API keys, OAuth tokens, webhook signing keys)
|
||||
-- encrypted at rest with the process master key (AES-256-GCM). Both the
|
||||
-- ciphertext (16-byte GCM auth tag appended) and the 12-byte nonce are
|
||||
-- stored; the master key itself never lives in the database. See
|
||||
-- `picloud_shared::crypto` + `manager-core::secrets_service`.
|
||||
--
|
||||
-- This is the user-facing `secrets::*` store. It is intentionally
|
||||
-- separate from `app_secrets` (the one-row-per-app realtime signing
|
||||
-- key, 0022): different cardinality (many named rows per app), and the
|
||||
-- realtime key is encrypted in place by migration 0025.
|
||||
|
||||
CREATE TABLE secrets (
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
encrypted_value BYTEA NOT NULL, -- ciphertext incl. 16-byte GCM auth tag
|
||||
nonce BYTEA NOT NULL, -- 12 bytes
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (app_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_secrets_app ON secrets (app_id);
|
||||
32
crates/manager-core/migrations/0024_email_triggers.sql
Normal file
32
crates/manager-core/migrations/0024_email_triggers.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- v1.1.7: inbound email triggers (email:receive).
|
||||
--
|
||||
-- A configured provider (Mailgun / Postmark / SendGrid / SES) POSTs
|
||||
-- inbound email to POST /api/v1/email-inbound/{app_id}/{trigger_id};
|
||||
-- the receiver normalizes it into a TriggerEvent::Email and enqueues an
|
||||
-- outbox row for the trigger's handler. v1.1.7 ships the webhook path;
|
||||
-- a native SMTP listener is v1.3+.
|
||||
|
||||
-- Widen the trigger-kind + outbox-source CHECK constraints to admit
|
||||
-- 'email'.
|
||||
ALTER TABLE triggers DROP CONSTRAINT triggers_kind_check;
|
||||
ALTER TABLE triggers ADD CONSTRAINT triggers_kind_check
|
||||
CHECK (kind IN ('kv', 'dead_letter', 'docs', 'cron',
|
||||
'files', 'pubsub', 'email'));
|
||||
|
||||
ALTER TABLE outbox DROP CONSTRAINT outbox_source_kind_check;
|
||||
ALTER TABLE outbox ADD CONSTRAINT outbox_source_kind_check
|
||||
CHECK (source_kind IN ('http', 'kv', 'dead_letter', 'docs',
|
||||
'cron', 'files', 'pubsub', 'email'));
|
||||
|
||||
-- Per-trigger inbound config. The HMAC secret used to verify provider
|
||||
-- signatures is stored ENCRYPTED at rest (AES-256-GCM under the process
|
||||
-- master key) — a deviation from the original brief's plaintext column,
|
||||
-- chosen to keep all operationally-secret material encrypted. The
|
||||
-- receiver decrypts it per inbound request. NULL columns mean the
|
||||
-- trigger has no signature verification (accepts any POST to its URL —
|
||||
-- relies on URL secrecy).
|
||||
CREATE TABLE email_trigger_details (
|
||||
trigger_id UUID PRIMARY KEY REFERENCES triggers(id) ON DELETE CASCADE,
|
||||
inbound_secret_encrypted BYTEA, -- ciphertext incl. GCM auth tag (NULL = unsigned)
|
||||
inbound_secret_nonce BYTEA -- 12 bytes (NULL = unsigned)
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- v1.1.7: encrypt the realtime signing key at rest (two-phase).
|
||||
--
|
||||
-- Phase 1 (this migration + the v1.1.7 startup task):
|
||||
-- * add NULL-able encrypted columns,
|
||||
-- * drop the NOT NULL on the plaintext column so newly-generated keys
|
||||
-- can be stored encrypted-only,
|
||||
-- * the application startup task `migrate_plaintext_keys` encrypts each
|
||||
-- existing plaintext key into the new columns (plaintext is LEFT in
|
||||
-- place during the compat window for rollback safety).
|
||||
--
|
||||
-- The `RealtimeAuthorityImpl` read path prefers the encrypted columns and
|
||||
-- falls back to plaintext, so SSE keeps working throughout.
|
||||
--
|
||||
-- Phase 2 (v1.1.8): once all rows are migrated, a follow-up migration
|
||||
-- drops the plaintext `realtime_signing_key` column.
|
||||
|
||||
ALTER TABLE app_secrets
|
||||
ADD COLUMN realtime_signing_key_encrypted BYTEA,
|
||||
ADD COLUMN realtime_signing_key_nonce BYTEA;
|
||||
|
||||
-- New keys (post-v1.1.7) are stored encrypted-only, so the plaintext
|
||||
-- column must accept NULL.
|
||||
ALTER TABLE app_secrets
|
||||
ALTER COLUMN realtime_signing_key DROP NOT NULL;
|
||||
@@ -12,8 +12,8 @@ use axum::{
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptSandbox, ScriptValidator,
|
||||
ValidationError,
|
||||
AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptKind, ScriptSandbox,
|
||||
ScriptValidator, ValidatedScript, ValidationError,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -88,6 +88,11 @@ pub struct CreateScriptRequest {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub source: String,
|
||||
/// v1.1.3: `endpoint` (default — handles HTTP routes / trigger
|
||||
/// targets) or `module` (library of fn/const imported by other
|
||||
/// scripts). Modules reject route binding and trigger creation.
|
||||
#[serde(default)]
|
||||
pub kind: ScriptKind,
|
||||
pub timeout_seconds: Option<i32>,
|
||||
pub memory_limit_mb: Option<i32>,
|
||||
/// Sandbox overrides; absent or empty `{}` means "use platform
|
||||
@@ -120,6 +125,10 @@ pub struct UpdateScriptRequest {
|
||||
/// `Some(ScriptSandbox::empty())` to clear them). Absent leaves
|
||||
/// the stored value unchanged.
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
/// v1.1.3: `Some(kind)` changes the script's role. Transitions to
|
||||
/// `Module` are rejected if any routes or triggers still reference
|
||||
/// the script. `module → endpoint` is always allowed.
|
||||
pub kind: Option<ScriptKind>,
|
||||
}
|
||||
|
||||
#[allow(clippy::option_option)]
|
||||
@@ -202,7 +211,20 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
Capability::AppWriteScript(input.app_id),
|
||||
)
|
||||
.await?;
|
||||
state.validator.validate(&input.source)?;
|
||||
// v1.1.3: dispatch to the right validator based on declared kind.
|
||||
// Module bodies have stricter rules (no top-level statements) so
|
||||
// they need a separate gate; endpoints retain the parse-only path.
|
||||
let validated: ValidatedScript = if input.kind == ScriptKind::Module {
|
||||
if RESERVED_MODULE_NAMES.contains(&input.name.as_str()) {
|
||||
return Err(ApiError::Invalid(ValidationError::ModuleShape(format!(
|
||||
"{:?} is a reserved module name (shadows a built-in SDK namespace)",
|
||||
input.name
|
||||
))));
|
||||
}
|
||||
state.validator.validate_module(&input.source)?
|
||||
} else {
|
||||
state.validator.validate(&input.source)?
|
||||
};
|
||||
state.sandbox_ceiling.check(&input.sandbox)?;
|
||||
// Refuse early if the app_id doesn't exist — a clean 422 beats a
|
||||
// raw FK violation surfacing as 500.
|
||||
@@ -216,6 +238,7 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
source: input.source,
|
||||
kind: input.kind,
|
||||
timeout_seconds: input.timeout_seconds,
|
||||
memory_limit_mb: input.memory_limit_mb,
|
||||
sandbox: if input.sandbox.is_empty() {
|
||||
@@ -223,11 +246,39 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
} else {
|
||||
Some(input.sandbox)
|
||||
},
|
||||
imports: validated.imports,
|
||||
})
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
/// Module names that would shadow a built-in stdlib / service namespace.
|
||||
/// Rejected at create time so `import "kv" as foo` can never resolve to
|
||||
/// a user-supplied module instead of (in a hypothetical future) the
|
||||
/// real KV bridge — defense against author confusion, not a security
|
||||
/// boundary (stdlib namespaces and module imports already live in
|
||||
/// disjoint Rhai scopes).
|
||||
const RESERVED_MODULE_NAMES: &[&str] = &[
|
||||
"log",
|
||||
"regex",
|
||||
"random",
|
||||
"time",
|
||||
"json",
|
||||
"base64",
|
||||
"hex",
|
||||
"url",
|
||||
"kv",
|
||||
"docs",
|
||||
"dead_letters",
|
||||
"http",
|
||||
"files",
|
||||
"pubsub",
|
||||
"secrets",
|
||||
"email",
|
||||
"users",
|
||||
"queue",
|
||||
];
|
||||
|
||||
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
@@ -241,9 +292,44 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
Capability::AppWriteScript(script.app_id),
|
||||
)
|
||||
.await?;
|
||||
if let Some(src) = input.source.as_deref() {
|
||||
state.validator.validate(src)?;
|
||||
|
||||
// Effective post-update kind: explicit override > existing kind.
|
||||
let effective_kind = input.kind.unwrap_or(script.kind);
|
||||
|
||||
// v1.1.3: reject `endpoint → module` if the script still has
|
||||
// routes or triggers bound to it. The reverse direction is always
|
||||
// allowed (a module can't have routes/triggers anyway, so the
|
||||
// transition can never strand users).
|
||||
if effective_kind == ScriptKind::Module && script.kind != ScriptKind::Module {
|
||||
let routes = state.repo.count_routes_for_script(id).await?;
|
||||
let triggers = state.repo.count_triggers_for_script(id).await?;
|
||||
if routes + triggers > 0 {
|
||||
return Err(ApiError::Invalid(ValidationError::ModuleShape(format!(
|
||||
"cannot change kind to module: script is referenced by {routes} route(s) and {triggers} trigger(s); detach them first"
|
||||
))));
|
||||
}
|
||||
if RESERVED_MODULE_NAMES.contains(&script.name.as_str()) {
|
||||
return Err(ApiError::Invalid(ValidationError::ModuleShape(format!(
|
||||
"{:?} is a reserved module name (shadows a built-in SDK namespace)",
|
||||
script.name
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
// v1.1.3: re-validate using the effective kind so endpoint → module
|
||||
// transitions with a fresh source enforce the module shape rules.
|
||||
// Source-less edits (name/description only) don't re-validate.
|
||||
let imports_for_patch: Option<Vec<String>> = if let Some(src) = input.source.as_deref() {
|
||||
let validated = if effective_kind == ScriptKind::Module {
|
||||
state.validator.validate_module(src)?
|
||||
} else {
|
||||
state.validator.validate(src)?
|
||||
};
|
||||
Some(validated.imports)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(sb) = input.sandbox.as_ref() {
|
||||
state.sandbox_ceiling.check(sb)?;
|
||||
}
|
||||
@@ -258,6 +344,8 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
timeout_seconds: input.timeout_seconds,
|
||||
memory_limit_mb: input.memory_limit_mb,
|
||||
sandbox: input.sandbox,
|
||||
kind: input.kind,
|
||||
imports: imports_for_patch,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -64,9 +64,11 @@ async fn seed_into(
|
||||
name: "hello".to_string(),
|
||||
description: Some("Reference example: returns a greeting at GET /hello.".to_string()),
|
||||
source: HELLO_RHAI_SOURCE.to_string(),
|
||||
kind: picloud_shared::ScriptKind::Endpoint,
|
||||
timeout_seconds: Some(5),
|
||||
memory_limit_mb: None,
|
||||
sandbox: None,
|
||||
imports: Vec::new(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
241
crates/manager-core/src/app_secrets_repo.rs
Normal file
241
crates/manager-core/src/app_secrets_repo.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
//! `AppSecretsRepo` — per-app secret material (v1.1.6, encrypted v1.1.7).
|
||||
//!
|
||||
//! Holds the HMAC signing key for realtime subscriber tokens. The key is
|
||||
//! generated lazily (32 random bytes) on the first
|
||||
//! `pubsub::subscriber_token` call for an app and never changes
|
||||
//! thereafter (no rotation API yet). The key is never exposed to
|
||||
//! scripts: the SDK mints tokens, it never returns the key.
|
||||
//!
|
||||
//! **v1.1.7 at-rest encryption (two-phase).** The key is now sealed with
|
||||
//! the process master key (AES-256-GCM). New keys are written
|
||||
//! encrypted-only; the startup task [`PostgresAppSecretsRepo::migrate_plaintext_keys`]
|
||||
//! encrypts any pre-existing plaintext rows. The read path prefers the
|
||||
//! encrypted columns and falls back to the plaintext column during the
|
||||
//! compat window (migration 0025 made it NULL-able; v1.1.8 drops it).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{crypto, AppId, MasterKey};
|
||||
use rand::RngCore;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Length of a freshly-generated realtime signing key.
|
||||
pub const SIGNING_KEY_LEN: usize = 32;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppSecretsRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
/// A stored encrypted signing key could not be decrypted — corrupted
|
||||
/// row or a master-key mismatch (e.g. `PICLOUD_SECRET_KEY` changed).
|
||||
#[error("realtime signing key could not be decrypted (corrupted row or master-key mismatch)")]
|
||||
Crypto,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AppSecretsRepo: Send + Sync {
|
||||
/// Fetch the app's realtime signing key, generating + persisting one
|
||||
/// (32 random bytes, encrypted) if absent. Idempotent under
|
||||
/// concurrency: a racing creator's `ON CONFLICT DO NOTHING` insert is
|
||||
/// a no-op and the existing key is returned.
|
||||
async fn get_or_create_signing_key(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
) -> Result<Vec<u8>, AppSecretsRepoError>;
|
||||
|
||||
/// Fetch the signing key if it exists, WITHOUT creating one. The SSE
|
||||
/// verify path uses this: a missing key means no token was ever
|
||||
/// minted for the app, so any presented token must be rejected.
|
||||
async fn signing_key(&self, app_id: AppId) -> Result<Option<Vec<u8>>, AppSecretsRepoError>;
|
||||
}
|
||||
|
||||
pub struct PostgresAppSecretsRepo {
|
||||
pool: PgPool,
|
||||
master_key: MasterKey,
|
||||
}
|
||||
|
||||
impl PostgresAppSecretsRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool, master_key: MasterKey) -> Self {
|
||||
Self { pool, master_key }
|
||||
}
|
||||
|
||||
/// Startup task (v1.1.7): encrypt every row that still has a
|
||||
/// plaintext key but no encrypted key. Plaintext is left in place
|
||||
/// (the read path prefers the encrypted columns); the plaintext
|
||||
/// column is dropped in v1.1.8. Returns the number of rows migrated.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates database errors.
|
||||
pub async fn migrate_plaintext_keys(&self) -> Result<usize, AppSecretsRepoError> {
|
||||
let rows: Vec<(Uuid, Vec<u8>)> = sqlx::query_as(
|
||||
"SELECT app_id, realtime_signing_key FROM app_secrets \
|
||||
WHERE realtime_signing_key_encrypted IS NULL \
|
||||
AND realtime_signing_key IS NOT NULL",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut migrated = 0;
|
||||
for (app_id, plaintext) in rows {
|
||||
let enc = crypto::encrypt(&plaintext, self.master_key.as_bytes());
|
||||
sqlx::query(
|
||||
"UPDATE app_secrets \
|
||||
SET realtime_signing_key_encrypted = $2, \
|
||||
realtime_signing_key_nonce = $3, \
|
||||
updated_at = NOW() \
|
||||
WHERE app_id = $1 AND realtime_signing_key_encrypted IS NULL",
|
||||
)
|
||||
.bind(app_id)
|
||||
.bind(&enc.ciphertext)
|
||||
.bind(&enc.nonce[..])
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
migrated += 1;
|
||||
}
|
||||
Ok(migrated)
|
||||
}
|
||||
|
||||
fn decode(
|
||||
&self,
|
||||
encrypted: Option<Vec<u8>>,
|
||||
nonce: Option<Vec<u8>>,
|
||||
plaintext: Option<Vec<u8>>,
|
||||
) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
|
||||
decode_signing_key(&self.master_key, encrypted, nonce, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the signing key from a row's three columns. **Encrypted wins**
|
||||
/// when present; otherwise fall back to the plaintext column (compat for
|
||||
/// un-migrated rows / the post-v1.1.8 dropped-plaintext state).
|
||||
fn decode_signing_key(
|
||||
master_key: &MasterKey,
|
||||
encrypted: Option<Vec<u8>>,
|
||||
nonce: Option<Vec<u8>>,
|
||||
plaintext: Option<Vec<u8>>,
|
||||
) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
|
||||
match (encrypted, nonce) {
|
||||
(Some(ct), Some(n)) => {
|
||||
let key = crypto::decrypt(&ct, &n, master_key.as_bytes())
|
||||
.map_err(|_| AppSecretsRepoError::Crypto)?;
|
||||
Ok(Some(key))
|
||||
}
|
||||
_ => Ok(plaintext),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AppSecretsRepo for PostgresAppSecretsRepo {
|
||||
async fn get_or_create_signing_key(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
) -> Result<Vec<u8>, AppSecretsRepoError> {
|
||||
let mut fresh = vec![0u8; SIGNING_KEY_LEN];
|
||||
rand::thread_rng().fill_bytes(&mut fresh);
|
||||
let enc = crypto::encrypt(&fresh, self.master_key.as_bytes());
|
||||
|
||||
// Insert-if-absent (encrypted-only). The racing-creator's insert
|
||||
// is a no-op; the SELECT always returns the winning row.
|
||||
sqlx::query(
|
||||
"INSERT INTO app_secrets \
|
||||
(app_id, realtime_signing_key_encrypted, realtime_signing_key_nonce) \
|
||||
VALUES ($1, $2, $3) ON CONFLICT (app_id) DO NOTHING",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(&enc.ciphertext)
|
||||
.bind(&enc.nonce[..])
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
let row: (Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>) = sqlx::query_as(
|
||||
"SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce, \
|
||||
realtime_signing_key \
|
||||
FROM app_secrets WHERE app_id = $1",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
// A row exists by construction, so a key must decode.
|
||||
self.decode(row.0, row.1, row.2)?
|
||||
.ok_or(AppSecretsRepoError::Crypto)
|
||||
}
|
||||
|
||||
async fn signing_key(&self, app_id: AppId) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
|
||||
let row: Option<(Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>)> = sqlx::query_as(
|
||||
"SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce, \
|
||||
realtime_signing_key \
|
||||
FROM app_secrets WHERE app_id = $1",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
match row {
|
||||
Some((e, n, p)) => self.decode(e, n, p),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key() -> MasterKey {
|
||||
MasterKey::from_bytes([9u8; 32])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_wins_over_plaintext() {
|
||||
let mk = key();
|
||||
let secret = vec![1u8, 2, 3, 4];
|
||||
let enc = crypto::encrypt(&secret, mk.as_bytes());
|
||||
// Both present → the encrypted value is returned (not the bogus
|
||||
// plaintext).
|
||||
let got = decode_signing_key(
|
||||
&mk,
|
||||
Some(enc.ciphertext),
|
||||
Some(enc.nonce.to_vec()),
|
||||
Some(vec![0xff; 32]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(got, Some(secret));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_plaintext_when_encrypted_absent() {
|
||||
let mk = key();
|
||||
let plaintext = vec![7u8; 32];
|
||||
let got = decode_signing_key(&mk, None, None, Some(plaintext.clone())).unwrap();
|
||||
assert_eq!(got, Some(plaintext));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_present_plaintext_null_works() {
|
||||
// Post-v1.1.8 state: only the encrypted columns are populated.
|
||||
let mk = key();
|
||||
let secret = vec![5u8; 32];
|
||||
let enc = crypto::encrypt(&secret, mk.as_bytes());
|
||||
let got =
|
||||
decode_signing_key(&mk, Some(enc.ciphertext), Some(enc.nonce.to_vec()), None).unwrap();
|
||||
assert_eq!(got, Some(secret));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_everything_is_none() {
|
||||
let got = decode_signing_key(&key(), None, None, None).unwrap();
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_master_key_is_crypto_error() {
|
||||
let secret = vec![3u8; 32];
|
||||
let enc = crypto::encrypt(&secret, key().as_bytes());
|
||||
let other = MasterKey::from_bytes([1u8; 32]);
|
||||
let err = decode_signing_key(&other, Some(enc.ciphertext), Some(enc.nonce.to_vec()), None)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppSecretsRepoError::Crypto));
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,35 @@ pub enum Capability {
|
||||
/// shape as KV write — granted to `editor`+, maps to
|
||||
/// `script:write` on API keys.
|
||||
AppDocsWrite(AppId),
|
||||
/// Make an outbound HTTP request from a script in this app
|
||||
/// (v1.1.4). Maps to `script:write` on API keys: any outbound
|
||||
/// request can exfiltrate data — including read methods like GET —
|
||||
/// so the conservative write mapping is correct. Splitting
|
||||
/// read/write is a v1.2+ refinement. Granted to `editor`+.
|
||||
AppHttpRequest(AppId),
|
||||
/// Read blobs from this app's files store (v1.1.5). Same trust
|
||||
/// shape as KV/docs read — granted to `viewer`+, maps to
|
||||
/// `script:read` on API keys. Honors the seven-scope commitment.
|
||||
AppFilesRead(AppId),
|
||||
/// Write blobs to this app's files store (v1.1.5). Granted to
|
||||
/// `editor`+, maps to `script:write` on API keys.
|
||||
AppFilesWrite(AppId),
|
||||
/// Publish a durable pub/sub message from a script in this app
|
||||
/// (v1.1.5). Maps to `script:write` on API keys (a publish is a
|
||||
/// write that fans out to subscribers). Granted to `editor`+.
|
||||
AppPubsubPublish(AppId),
|
||||
/// Read a decrypted secret from this app's secrets store (v1.1.7).
|
||||
/// Same trust shape as KV/docs/files read — granted to `viewer`+,
|
||||
/// maps to `script:read` on API keys. Honors the seven-scope
|
||||
/// commitment.
|
||||
AppSecretsRead(AppId),
|
||||
/// Write (set/delete) a secret in this app's secrets store (v1.1.7).
|
||||
/// Granted to `editor`+, maps to `script:write` on API keys.
|
||||
AppSecretsWrite(AppId),
|
||||
/// Send an outbound email from a script in this app (v1.1.7). Maps
|
||||
/// to `script:write` on API keys (sending mail is an outbound
|
||||
/// side-effect like an HTTP request). Granted to `editor`+.
|
||||
AppEmailSend(AppId),
|
||||
/// Create / list / delete triggers for this app (v1.1.1). Maps to
|
||||
/// `app:admin` on API keys — triggers are app-configuration acts
|
||||
/// rather than data-plane access. Granted to `app_admin`+.
|
||||
@@ -80,6 +109,12 @@ pub enum Capability {
|
||||
/// to `app:admin` on API keys. Public-HTTP scripts (principal None)
|
||||
/// fail this check — managing dead letters is an admin act.
|
||||
AppDeadLetterManage(AppId),
|
||||
/// Register / list / update / delete externally-subscribable topics
|
||||
/// for this app (v1.1.6). Maps to `app:admin` on API keys —
|
||||
/// externalizing a topic is an app-configuration act with security
|
||||
/// weight (it opens an internal pub/sub topic to outside SSE
|
||||
/// subscribers). Granted to `app_admin`+.
|
||||
AppTopicManage(AppId),
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
@@ -101,8 +136,16 @@ impl Capability {
|
||||
| Self::AppKvWrite(id)
|
||||
| Self::AppDocsRead(id)
|
||||
| Self::AppDocsWrite(id)
|
||||
| Self::AppHttpRequest(id)
|
||||
| Self::AppFilesRead(id)
|
||||
| Self::AppFilesWrite(id)
|
||||
| Self::AppPubsubPublish(id)
|
||||
| Self::AppSecretsRead(id)
|
||||
| Self::AppSecretsWrite(id)
|
||||
| Self::AppEmailSend(id)
|
||||
| Self::AppManageTriggers(id)
|
||||
| Self::AppDeadLetterManage(id) => Some(id),
|
||||
| Self::AppDeadLetterManage(id)
|
||||
| Self::AppTopicManage(id) => Some(id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,15 +160,25 @@ impl Capability {
|
||||
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||
Scope::InstanceAdmin
|
||||
}
|
||||
Self::AppRead(_) | Self::AppKvRead(_) | Self::AppDocsRead(_) => Scope::ScriptRead,
|
||||
Self::AppWriteScript(_) | Self::AppKvWrite(_) | Self::AppDocsWrite(_) => {
|
||||
Scope::ScriptWrite
|
||||
}
|
||||
Self::AppRead(_)
|
||||
| Self::AppKvRead(_)
|
||||
| Self::AppDocsRead(_)
|
||||
| Self::AppFilesRead(_)
|
||||
| Self::AppSecretsRead(_) => Scope::ScriptRead,
|
||||
Self::AppWriteScript(_)
|
||||
| Self::AppKvWrite(_)
|
||||
| Self::AppDocsWrite(_)
|
||||
| Self::AppHttpRequest(_)
|
||||
| Self::AppFilesWrite(_)
|
||||
| Self::AppPubsubPublish(_)
|
||||
| Self::AppSecretsWrite(_)
|
||||
| Self::AppEmailSend(_) => Scope::ScriptWrite,
|
||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||
Self::AppAdmin(_) | Self::AppManageTriggers(_) | Self::AppDeadLetterManage(_) => {
|
||||
Scope::AppAdmin
|
||||
}
|
||||
Self::AppAdmin(_)
|
||||
| Self::AppManageTriggers(_)
|
||||
| Self::AppDeadLetterManage(_)
|
||||
| Self::AppTopicManage(_) => Scope::AppAdmin,
|
||||
Self::AppLogRead(_) => Scope::LogRead,
|
||||
}
|
||||
}
|
||||
@@ -269,6 +322,8 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||
| Capability::AppLogRead(_)
|
||||
| Capability::AppKvRead(_)
|
||||
| Capability::AppDocsRead(_)
|
||||
| Capability::AppFilesRead(_)
|
||||
| Capability::AppSecretsRead(_)
|
||||
);
|
||||
let in_editor = in_viewer
|
||||
|| matches!(
|
||||
@@ -277,6 +332,11 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||
| Capability::AppWriteRoute(_)
|
||||
| Capability::AppKvWrite(_)
|
||||
| Capability::AppDocsWrite(_)
|
||||
| Capability::AppHttpRequest(_)
|
||||
| Capability::AppFilesWrite(_)
|
||||
| Capability::AppPubsubPublish(_)
|
||||
| Capability::AppSecretsWrite(_)
|
||||
| Capability::AppEmailSend(_)
|
||||
);
|
||||
let in_app_admin = in_editor
|
||||
|| matches!(
|
||||
@@ -285,6 +345,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||
| Capability::AppAdmin(_)
|
||||
| Capability::AppManageTriggers(_)
|
||||
| Capability::AppDeadLetterManage(_)
|
||||
| Capability::AppTopicManage(_)
|
||||
);
|
||||
match role {
|
||||
AppRole::Viewer => in_viewer,
|
||||
@@ -628,6 +689,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn topic_manage_requires_app_admin() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let app = AppId::new();
|
||||
// Maps to the app:admin scope, not a new one.
|
||||
assert_eq!(
|
||||
Capability::AppTopicManage(app).required_scope(),
|
||||
Scope::AppAdmin
|
||||
);
|
||||
|
||||
// Member with only Editor role cannot manage topics.
|
||||
let p = principal(InstanceRole::Member);
|
||||
repo.grant(p.user_id, app, AppRole::Editor).await;
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppTopicManage(app))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny,
|
||||
);
|
||||
|
||||
// App-admin role can.
|
||||
let admin = principal(InstanceRole::Member);
|
||||
repo.grant(admin.user_id, app, AppRole::AppAdmin).await;
|
||||
assert!(can(&repo, &admin, Capability::AppTopicManage(app))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_app_id_extraction() {
|
||||
let app = AppId::new();
|
||||
|
||||
297
crates/manager-core/src/cron_scheduler.rs
Normal file
297
crates/manager-core/src/cron_scheduler.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
//! Cron scheduler — the v1.1.4 time-based trigger source.
|
||||
//!
|
||||
//! A single tokio task polls `cron_trigger_details` on a tick (default
|
||||
//! 30s; `PICLOUD_CRON_TICK_INTERVAL_MS`). For each enabled cron trigger
|
||||
//! whose next scheduled fire is due, it enqueues ONE outbox row
|
||||
//! (`source_kind = 'cron'`) and updates `last_fired_at` — both in the
|
||||
//! same transaction, claimed via `FOR UPDATE SKIP LOCKED` so a future
|
||||
//! multi-node deploy can't double-fire.
|
||||
//!
|
||||
//! The scheduler does NOT dispatch or touch the `ExecutionGate`: it only
|
||||
//! enqueues. The existing dispatcher picks the row up and acquires the
|
||||
//! gate exactly as it does for kv/docs/dead_letter rows.
|
||||
//!
|
||||
//! **Catch-up policy (matches the brief):** a trigger that missed N fire
|
||||
//! windows since `last_fired_at` fires exactly ONCE on the next tick,
|
||||
//! not N times. This falls out of the design: [`next_due`] returns a
|
||||
//! single canonical scheduled time (the first slot after the reference
|
||||
//! point), and after firing we set `last_fired_at = now`, so the next
|
||||
//! tick computes from `now` and sees only future slots. Backfilling
|
||||
//! missed windows is intentionally out of scope (an explicit replay
|
||||
//! action is the v1.2+ escape hatch).
|
||||
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use cron::Schedule;
|
||||
use picloud_shared::TriggerEvent;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Validate a 6-field cron expression. Returns the parse error message
|
||||
/// on failure.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns the underlying parse error string when `schedule` is not a
|
||||
/// valid cron expression.
|
||||
pub fn validate_schedule(schedule: &str) -> Result<(), String> {
|
||||
Schedule::from_str(schedule)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Validate an IANA timezone name (e.g. `America/Los_Angeles`).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error string when `timezone` is not a known IANA name.
|
||||
pub fn validate_timezone(timezone: &str) -> Result<(), String> {
|
||||
Tz::from_str(timezone)
|
||||
.map(|_| ())
|
||||
.map_err(|_| format!("unknown IANA timezone: {timezone}"))
|
||||
}
|
||||
|
||||
/// Compute whether a cron trigger is due, and if so its canonical
|
||||
/// scheduled-at moment (UTC).
|
||||
///
|
||||
/// Returns `Some(scheduled_at)` when the first scheduled slot after the
|
||||
/// reference point (`last_fired_at`, or `created_at` if never fired) is
|
||||
/// at/before `now`; `None` otherwise. Returns `None` if the schedule or
|
||||
/// timezone fails to parse (the row is skipped — it should never have
|
||||
/// been inserted, since the admin endpoint validates).
|
||||
#[must_use]
|
||||
pub fn next_due(
|
||||
schedule: &str,
|
||||
timezone: &str,
|
||||
last_fired_at: Option<DateTime<Utc>>,
|
||||
created_at: DateTime<Utc>,
|
||||
now: DateTime<Utc>,
|
||||
) -> Option<DateTime<Utc>> {
|
||||
let sched = Schedule::from_str(schedule).ok()?;
|
||||
let tz = Tz::from_str(timezone).ok()?;
|
||||
// Reference: the last actual fire, or creation if never fired. A
|
||||
// never-fired trigger fires at its first slot at/after creation.
|
||||
let base = last_fired_at.unwrap_or(created_at);
|
||||
let base_tz = base.with_timezone(&tz);
|
||||
let next = sched.after(&base_tz).next()?;
|
||||
let next_utc = next.with_timezone(&Utc);
|
||||
(next_utc <= now).then_some(next_utc)
|
||||
}
|
||||
|
||||
/// Spawn the scheduler loop. Runs for the process lifetime.
|
||||
pub fn spawn_cron_scheduler(pool: PgPool, tick_interval_ms: u32) {
|
||||
// Floor the tick at 1s so a misconfigured 0 can't spin.
|
||||
let interval = Duration::from_millis(u64::from(tick_interval_ms).max(1_000));
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(interval);
|
||||
// Skip the immediate first fire so we don't race startup.
|
||||
ticker.tick().await;
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
if let Err(e) = tick(&pool, Utc::now()).await {
|
||||
tracing::warn!(?e, "cron scheduler tick errored");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DueRow {
|
||||
id: Uuid,
|
||||
app_id: Uuid,
|
||||
script_id: Uuid,
|
||||
registered_by_principal: Uuid,
|
||||
created_at: DateTime<Utc>,
|
||||
schedule: String,
|
||||
timezone: String,
|
||||
last_fired_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// One scheduler tick: claim enabled cron rows, enqueue the due ones,
|
||||
/// bump `last_fired_at`. Returns the number of triggers fired.
|
||||
async fn tick(pool: &PgPool, now: DateTime<Utc>) -> Result<usize, sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let rows: Vec<DueRow> = sqlx::query_as(
|
||||
"SELECT t.id, t.app_id, t.script_id, t.registered_by_principal, t.created_at, \
|
||||
d.schedule, d.timezone, d.last_fired_at \
|
||||
FROM cron_trigger_details d \
|
||||
JOIN triggers t ON t.id = d.trigger_id \
|
||||
WHERE t.enabled = TRUE \
|
||||
FOR UPDATE OF d SKIP LOCKED",
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let mut fired = 0usize;
|
||||
for r in rows {
|
||||
let Some(scheduled_at) =
|
||||
next_due(&r.schedule, &r.timezone, r.last_fired_at, r.created_at, now)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let event = TriggerEvent::Cron {
|
||||
schedule: r.schedule.clone(),
|
||||
timezone: r.timezone.clone(),
|
||||
scheduled_at,
|
||||
fired_at: now,
|
||||
};
|
||||
let payload = serde_json::to_value(&event)
|
||||
.map_err(|e| sqlx::Error::Decode(Box::new(std::io::Error::other(e))))?;
|
||||
|
||||
// Enqueue exactly one outbox row. Relies on the same column
|
||||
// defaults the OutboxEventEmitter uses (next_attempt_at = NOW(),
|
||||
// attempt_count = 0, claimed_at NULL → immediately due).
|
||||
sqlx::query(
|
||||
"INSERT INTO outbox \
|
||||
(app_id, source_kind, trigger_id, script_id, payload, \
|
||||
origin_principal, trigger_depth) \
|
||||
VALUES ($1, 'cron', $2, $3, $4, $5, 0)",
|
||||
)
|
||||
.bind(r.app_id)
|
||||
.bind(r.id)
|
||||
.bind(r.script_id)
|
||||
.bind(payload)
|
||||
.bind(r.registered_by_principal)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query("UPDATE cron_trigger_details SET last_fired_at = $2 WHERE trigger_id = $1")
|
||||
.bind(r.id)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
fired += 1;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(fired)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
#[test]
|
||||
fn valid_six_field_schedule_accepted() {
|
||||
// sec min hour dom mon dow — "every weekday at 9am".
|
||||
validate_schedule("0 0 9 * * MON-FRI").unwrap();
|
||||
validate_schedule("*/5 * * * * *").unwrap();
|
||||
validate_schedule("0 0 0 1 1 *").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_schedules_rejected() {
|
||||
// 5-field (no seconds) is not the format we accept.
|
||||
assert!(validate_schedule("* * * * *").is_err());
|
||||
// Gibberish.
|
||||
assert!(validate_schedule("not a cron").is_err());
|
||||
assert!(validate_schedule("").is_err());
|
||||
// Out-of-range hour.
|
||||
assert!(validate_schedule("0 0 99 * * *").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_timezones_accepted() {
|
||||
validate_timezone("UTC").unwrap();
|
||||
validate_timezone("America/Los_Angeles").unwrap();
|
||||
validate_timezone("Europe/Berlin").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_timezones_rejected() {
|
||||
assert!(validate_timezone("Mars/Phobos").is_err());
|
||||
assert!(validate_timezone("PST").is_err()); // abbreviations aren't IANA names
|
||||
assert!(validate_timezone("").is_err());
|
||||
}
|
||||
|
||||
fn ts(s: &str) -> DateTime<Utc> {
|
||||
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn due_when_next_slot_is_at_or_before_now() {
|
||||
// Every minute at second 0. Last fired 90s ago → the next slot
|
||||
// after that is due now.
|
||||
let created = ts("2026-06-01T00:00:00Z");
|
||||
let last = Some(ts("2026-06-15T11:58:10Z"));
|
||||
let now = ts("2026-06-15T12:00:05Z");
|
||||
let due = next_due("0 * * * * *", "UTC", last, created, now);
|
||||
assert_eq!(due, Some(ts("2026-06-15T11:59:00Z")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_due_when_next_slot_is_in_the_future() {
|
||||
let created = ts("2026-06-01T00:00:00Z");
|
||||
let last = Some(ts("2026-06-15T12:00:00Z"));
|
||||
let now = ts("2026-06-15T12:00:30Z");
|
||||
// Next minute slot is 12:01:00 — still in the future.
|
||||
assert_eq!(next_due("0 * * * * *", "UTC", last, created, now), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_fired_uses_created_at_as_reference() {
|
||||
let created = ts("2026-06-15T12:00:10Z");
|
||||
let now = ts("2026-06-15T12:01:30Z");
|
||||
// First slot after creation is 12:01:00, which is <= now → due.
|
||||
let due = next_due("0 * * * * *", "UTC", None, created, now);
|
||||
assert_eq!(due, Some(ts("2026-06-15T12:01:00Z")));
|
||||
}
|
||||
|
||||
/// Catch-up policy: a trigger that missed many windows fires exactly
|
||||
/// ONCE. We simulate two consecutive scheduler ticks the way the DB
|
||||
/// loop does — fire once, set last_fired = now, then re-evaluate.
|
||||
#[test]
|
||||
fn catch_up_fires_exactly_once_after_missed_windows() {
|
||||
let created = ts("2026-06-15T09:00:00Z");
|
||||
// Last fired over 5 minutes (5 windows) ago.
|
||||
let mut last_fired = Some(ts("2026-06-15T11:54:30Z"));
|
||||
let now = ts("2026-06-15T12:00:05Z");
|
||||
|
||||
// Tick 1: due → fire once, advance last_fired to `now`.
|
||||
let first = next_due("0 * * * * *", "UTC", last_fired, created, now);
|
||||
assert!(first.is_some(), "should be due after missing windows");
|
||||
last_fired = Some(now);
|
||||
|
||||
// Tick 2 (same wall-clock): NOT due again — only one fire total,
|
||||
// not one-per-missed-window.
|
||||
let second = next_due("0 * * * * *", "UTC", last_fired, created, now);
|
||||
assert_eq!(second, None, "catch-up must fire exactly once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timezone_affects_fire_time() {
|
||||
// "9am every day" in Los Angeles. On 2026-06-15, PDT = UTC-7, so
|
||||
// 09:00 local = 16:00 UTC.
|
||||
let created = ts("2026-06-15T00:00:00Z");
|
||||
let last = Some(ts("2026-06-15T15:59:00Z"));
|
||||
let now = ts("2026-06-15T16:00:30Z");
|
||||
let due = next_due("0 0 9 * * *", "America/Los_Angeles", last, created, now);
|
||||
assert_eq!(due, Some(ts("2026-06-15T16:00:00Z")));
|
||||
// Sanity: the same expression in UTC would NOT be due at 16:00.
|
||||
assert_eq!(next_due("0 0 9 * * *", "UTC", last, created, now), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_schedule_or_tz_yields_none() {
|
||||
let created = ts("2026-06-15T00:00:00Z");
|
||||
let now = ts("2026-06-15T12:00:00Z");
|
||||
assert_eq!(next_due("garbage", "UTC", None, created, now), None);
|
||||
assert_eq!(
|
||||
next_due("0 * * * * *", "Mars/Phobos", None, created, now),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utc_offset_constructor_smoke() {
|
||||
// Guard the chrono TimeZone import is actually exercised.
|
||||
let dt = Utc.with_ymd_and_hms(2026, 6, 15, 12, 0, 0).unwrap();
|
||||
assert_eq!(dt, ts("2026-06-15T12:00:00Z"));
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,9 @@ async fn resolve(
|
||||
fn admin_cx(app_id: AppId, principal: &Principal) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
// Admin-plane cx (dead-letter replay/resolve) — no script is
|
||||
// executing, so this attribution id is a fresh sentinel.
|
||||
script_id: picloud_shared::ScriptId::new(),
|
||||
principal: Some(principal.clone()),
|
||||
execution_id: picloud_shared::ExecutionId::new(),
|
||||
request_id: picloud_shared::RequestId::new(),
|
||||
|
||||
@@ -23,19 +23,19 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
|
||||
use picloud_orchestrator_core::{ExecutionGate, ExecutorClient};
|
||||
use picloud_shared::{
|
||||
ExecResponseSummary, ExecutionId, HttpDispatchPayload, InboxDeliveryOutcome, InboxFailureKind,
|
||||
InboxResolver, InboxResult, RequestId, ScriptId, ScriptSandbox, TriggerEvent,
|
||||
DeadLetterId, ExecResponseSummary, ExecutionId, HttpDispatchPayload, InboxDeliveryOutcome,
|
||||
InboxFailureKind, InboxResolver, InboxResult, RequestId, ScriptId, ScriptSandbox, TriggerEvent,
|
||||
};
|
||||
use rand::Rng;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::abandoned_repo::{AbandonedRepo, NewAbandonedExecution};
|
||||
use crate::dead_letter_repo::{DeadLetterRepo, NewDeadLetter};
|
||||
use crate::outbox_repo::{OutboxRepo, OutboxRow, OutboxSourceKind};
|
||||
use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxRow, OutboxSourceKind};
|
||||
use crate::principal_resolver::PrincipalResolver;
|
||||
use crate::repo::ScriptRepository;
|
||||
use crate::trigger_config::{BackoffShape, TriggerConfig};
|
||||
@@ -163,7 +163,13 @@ impl Dispatcher {
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
OutboxSourceKind::Kv | OutboxSourceKind::Docs | OutboxSourceKind::DeadLetter => {
|
||||
OutboxSourceKind::Kv
|
||||
| OutboxSourceKind::Docs
|
||||
| OutboxSourceKind::DeadLetter
|
||||
| OutboxSourceKind::Cron
|
||||
| OutboxSourceKind::Files
|
||||
| OutboxSourceKind::Pubsub
|
||||
| OutboxSourceKind::Email => {
|
||||
let resolved = self.resolve_trigger(&row).await?;
|
||||
let req = match self.build_exec_request(&row, &resolved).await {
|
||||
Ok(req) => req,
|
||||
@@ -186,9 +192,13 @@ impl Dispatcher {
|
||||
// wait synchronously here — sync HTTP and dispatcher share the
|
||||
// semaphore so this is intentional.
|
||||
let source = resolved.script_source.clone();
|
||||
let identity = picloud_orchestrator_core::ScriptIdentity {
|
||||
script_id: resolved.script_id,
|
||||
updated_at: resolved.script_updated_at,
|
||||
};
|
||||
let outcome = self
|
||||
.executor
|
||||
.execute(&source, exec_req, ASYNC_EXEC_TIMEOUT)
|
||||
.execute_with_identity(identity, &source, exec_req, ASYNC_EXEC_TIMEOUT)
|
||||
.await;
|
||||
drop(permit);
|
||||
|
||||
@@ -230,6 +240,7 @@ impl Dispatcher {
|
||||
script_id: script.id,
|
||||
script_source: script.source,
|
||||
script_name: script.name,
|
||||
script_updated_at: script.updated_at,
|
||||
sandbox_overrides: script.sandbox,
|
||||
registered_by_principal: trigger.registered_by_principal,
|
||||
retry_max_attempts: trigger.retry_max_attempts,
|
||||
@@ -335,6 +346,7 @@ impl Dispatcher {
|
||||
script_id,
|
||||
script_source: script.source,
|
||||
script_name: payload.script_name,
|
||||
script_updated_at: script.updated_at,
|
||||
sandbox_overrides: script.sandbox,
|
||||
// HTTP outbox rows don't carry a registered_by_principal
|
||||
// — use a sentinel zero UUID since this field isn't used
|
||||
@@ -451,12 +463,12 @@ impl Dispatcher {
|
||||
// Exhausted retries → dead-letter.
|
||||
let (op, source) = describe_event(&row.payload);
|
||||
let now = Utc::now();
|
||||
if let Err(e) = self
|
||||
let dl_id = match self
|
||||
.dead_letters
|
||||
.insert(NewDeadLetter {
|
||||
app_id: row.app_id,
|
||||
original_event_id: row.id,
|
||||
source,
|
||||
source: source.clone(),
|
||||
op,
|
||||
trigger_id: row.trigger_id,
|
||||
script_id: Some(resolved.script_id),
|
||||
@@ -468,8 +480,26 @@ impl Dispatcher {
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(id) => Some(id),
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "failed to write dead-letter row");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// v1.1.7 fix: fan the dead-letter out to matching handler triggers.
|
||||
// This was missing since v1.1.1 — the row was written but
|
||||
// `list_matching_dead_letter` had no production caller, so
|
||||
// registered dead_letter handlers never fired. The recursion-stop
|
||||
// (a dead-letter handler's own failure is not re-dead-lettered)
|
||||
// is upheld by the `is_dead_letter_handler` short-circuit at the
|
||||
// top of this function, so this fan-out is only reached for
|
||||
// non-handler executions.
|
||||
if let Some(dl_id) = dl_id {
|
||||
self.fan_out_dead_letter(row, resolved, dl_id, &source, attempt, &err, now)
|
||||
.await;
|
||||
}
|
||||
|
||||
self.outbox
|
||||
.delete(row.id)
|
||||
.await
|
||||
@@ -477,6 +507,82 @@ impl Dispatcher {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enqueue one outbox row per matching `dead_letter` trigger so its
|
||||
/// handler script runs with the dead-letter event as `ctx.event`.
|
||||
/// Best-effort: a lookup/insert failure is logged, not propagated
|
||||
/// (the dead-letter row itself is already durably written).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn fan_out_dead_letter(
|
||||
&self,
|
||||
row: &OutboxRow,
|
||||
resolved: &ResolvedTrigger,
|
||||
dead_letter_id: DeadLetterId,
|
||||
source: &str,
|
||||
attempt: u32,
|
||||
err: &ExecError,
|
||||
now: DateTime<Utc>,
|
||||
) {
|
||||
// The DL event nests the original verbatim; if the payload can't
|
||||
// be decoded back into a TriggerEvent we can't build the nested
|
||||
// `original`, so skip the fan-out (the DL row is still written).
|
||||
let Ok(original) = serde_json::from_value::<TriggerEvent>(row.payload.clone()) else {
|
||||
tracing::warn!(
|
||||
outbox_id = %row.id,
|
||||
"dead-letter payload is not a TriggerEvent; skipping handler fan-out"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let matches = match self
|
||||
.triggers
|
||||
.list_matching_dead_letter(row.app_id, source, row.trigger_id, Some(resolved.script_id))
|
||||
.await
|
||||
{
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "dead-letter trigger lookup failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for m in matches {
|
||||
let event = TriggerEvent::DeadLetter {
|
||||
dead_letter_id,
|
||||
original: Box::new(original.clone()),
|
||||
attempts: attempt,
|
||||
last_error: err.to_string(),
|
||||
trigger_id: row.trigger_id,
|
||||
script_id: Some(resolved.script_id),
|
||||
first_attempt_at: row.created_at,
|
||||
last_attempt_at: now,
|
||||
};
|
||||
let payload = match serde_json::to_value(&event) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "failed to serialize dead-letter event");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(e) = self
|
||||
.outbox
|
||||
.insert(NewOutboxRow {
|
||||
app_id: row.app_id,
|
||||
source_kind: OutboxSourceKind::DeadLetter,
|
||||
trigger_id: Some(m.trigger_id),
|
||||
script_id: Some(m.script_id),
|
||||
reply_to: None,
|
||||
payload,
|
||||
origin_principal: Some(m.registered_by_principal),
|
||||
trigger_depth: row.trigger_depth.saturating_add(1),
|
||||
root_execution_id: row.root_execution_id,
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::error!(?e, "failed to enqueue dead-letter handler delivery");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn deliver_inbox(&self, row: &OutboxRow, inbox_id: Uuid, result: InboxResult) {
|
||||
match self.inbox.deliver(inbox_id, result.clone()).await {
|
||||
InboxDeliveryOutcome::Delivered => {}
|
||||
@@ -516,6 +622,11 @@ pub struct ResolvedTrigger {
|
||||
pub script_id: ScriptId,
|
||||
pub script_source: String,
|
||||
pub script_name: String,
|
||||
/// v1.1.3: freshness comparator for the orchestrator's top-level
|
||||
/// script cache. The dispatcher hands `(script_id, updated_at)`
|
||||
/// in alongside the source so cached ASTs can be reused across
|
||||
/// triggered invocations.
|
||||
pub script_updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub sandbox_overrides: ScriptSandbox,
|
||||
pub registered_by_principal: picloud_shared::AdminUserId,
|
||||
pub retry_max_attempts: u32,
|
||||
|
||||
@@ -272,7 +272,7 @@ mod tests {
|
||||
use chrono::Utc;
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, NoopEventEmitter, Principal,
|
||||
RequestId, UserId,
|
||||
RequestId, ScriptId, UserId,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -507,6 +507,7 @@ mod tests {
|
||||
fn anon_cx(app_id: AppId) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
script_id: ScriptId::new(),
|
||||
principal: None,
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: RequestId::new(),
|
||||
@@ -520,6 +521,7 @@ mod tests {
|
||||
fn owner_cx(app_id: AppId) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
script_id: ScriptId::new(),
|
||||
principal: Some(Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
@@ -538,6 +540,7 @@ mod tests {
|
||||
fn member_no_role_cx(app_id: AppId) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
script_id: ScriptId::new(),
|
||||
principal: Some(Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Member,
|
||||
|
||||
307
crates/manager-core/src/email_inbound_api.rs
Normal file
307
crates/manager-core/src/email_inbound_api.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
//! `POST /api/v1/email-inbound/{app_id}/{trigger_id}` — the inbound-email
|
||||
//! webhook receiver (v1.1.7).
|
||||
//!
|
||||
//! A configured provider (Mailgun / Postmark / SendGrid / SES) POSTs a
|
||||
//! normalized JSON message here; the receiver verifies the optional HMAC
|
||||
//! signature, builds a `TriggerEvent::Email`, and enqueues an outbox row
|
||||
//! the dispatcher picks up like any other async trigger.
|
||||
//!
|
||||
//! This is a PUBLIC endpoint (no admin auth) — the trigger URL itself,
|
||||
//! plus the per-trigger HMAC secret, are the security boundary. It is
|
||||
//! mounted OUTSIDE the `require_authenticated` layer.
|
||||
//!
|
||||
//! Status codes:
|
||||
//! * 202 — accepted + enqueued
|
||||
//! * 401 — HMAC required but missing/invalid
|
||||
//! * 404 — trigger missing, disabled, not `kind=email`, or app mismatch
|
||||
//! * 422 — body is not the expected JSON shape
|
||||
//!
|
||||
//! Only the generic provider-agnostic JSON shape is accepted in v1.1.7
|
||||
//! (see [`InboundPayload`]); provider-specific unmarshallers are v1.2.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::post;
|
||||
use axum::Router;
|
||||
use hmac::{Hmac, Mac};
|
||||
use picloud_shared::{AppId, MasterKey, TriggerEvent, TriggerId};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxSourceKind};
|
||||
use crate::secrets_repo::StoredSecret;
|
||||
use crate::secrets_service::open;
|
||||
use crate::trigger_repo::TriggerRepo;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Header the provider's HMAC signature is read from. The signature is
|
||||
/// the lowercase hex of `HMAC-SHA256(inbound_secret, raw_body)`.
|
||||
const SIGNATURE_HEADER: &str = "x-picloud-signature";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EmailInboundState {
|
||||
pub triggers: Arc<dyn TriggerRepo>,
|
||||
pub outbox: Arc<dyn OutboxRepo>,
|
||||
pub master_key: MasterKey,
|
||||
}
|
||||
|
||||
pub fn email_inbound_router(state: EmailInboundState) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/email-inbound/{app_id}/{trigger_id}",
|
||||
post(receive_inbound_email),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// The generic provider-agnostic inbound shape. Users configure their
|
||||
/// provider's webhook templating to POST this. `from` is required;
|
||||
/// everything else defaults.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InboundPayload {
|
||||
from: String,
|
||||
#[serde(default)]
|
||||
to: Vec<String>,
|
||||
#[serde(default)]
|
||||
cc: Vec<String>,
|
||||
#[serde(default)]
|
||||
subject: String,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
#[serde(default)]
|
||||
html: Option<String>,
|
||||
#[serde(default)]
|
||||
message_id: Option<String>,
|
||||
}
|
||||
|
||||
async fn receive_inbound_email(
|
||||
State(s): State<EmailInboundState>,
|
||||
Path((app_id, trigger_id)): Path<(AppId, TriggerId)>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<StatusCode, EmailInboundError> {
|
||||
// Resolve the trigger. 404 covers missing / wrong-kind / cross-app /
|
||||
// disabled — all "this URL doesn't address a live email trigger".
|
||||
let target = s
|
||||
.triggers
|
||||
.email_inbound_target(trigger_id)
|
||||
.await
|
||||
.map_err(|e| EmailInboundError::Backend(e.to_string()))?
|
||||
.ok_or(EmailInboundError::NotFound)?;
|
||||
if target.app_id != app_id || !target.enabled {
|
||||
return Err(EmailInboundError::NotFound);
|
||||
}
|
||||
|
||||
// HMAC verification (only when the trigger has a secret configured).
|
||||
if let (Some(ct), Some(nonce)) = (
|
||||
target.inbound_secret_encrypted.as_ref(),
|
||||
target.inbound_secret_nonce.as_ref(),
|
||||
) {
|
||||
let secret = decrypt_secret(&s.master_key, ct, nonce)?;
|
||||
verify_signature(&headers, &body, secret.as_bytes())?;
|
||||
}
|
||||
|
||||
// Parse the generic JSON shape. Malformed → 422.
|
||||
let payload: InboundPayload =
|
||||
serde_json::from_slice(&body).map_err(|e| EmailInboundError::Malformed(e.to_string()))?;
|
||||
|
||||
let event = TriggerEvent::Email {
|
||||
from: payload.from,
|
||||
to: payload.to,
|
||||
cc: payload.cc,
|
||||
subject: payload.subject,
|
||||
text: payload.text,
|
||||
html: payload.html,
|
||||
received_at: chrono::Utc::now(),
|
||||
message_id: payload.message_id,
|
||||
};
|
||||
let payload_json = serde_json::to_value(&event)
|
||||
.map_err(|e| EmailInboundError::Backend(format!("serialize event: {e}")))?;
|
||||
|
||||
s.outbox
|
||||
.insert(NewOutboxRow {
|
||||
app_id,
|
||||
source_kind: OutboxSourceKind::Email,
|
||||
trigger_id: Some(trigger_id),
|
||||
script_id: Some(target.script_id),
|
||||
reply_to: None,
|
||||
payload: payload_json,
|
||||
origin_principal: Some(target.registered_by_principal),
|
||||
// Inbound email is the root of a trigger chain (depth 1).
|
||||
trigger_depth: 1,
|
||||
root_execution_id: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| EmailInboundError::Backend(e.to_string()))?;
|
||||
|
||||
Ok(StatusCode::ACCEPTED)
|
||||
}
|
||||
|
||||
/// Decrypt the stored inbound secret back to its raw string. It was
|
||||
/// sealed as a JSON string by the admin layer, so `open` yields a
|
||||
/// `Value::String`.
|
||||
fn decrypt_secret(
|
||||
master_key: &MasterKey,
|
||||
ciphertext: &[u8],
|
||||
nonce: &[u8],
|
||||
) -> Result<String, EmailInboundError> {
|
||||
let stored = StoredSecret {
|
||||
encrypted_value: ciphertext.to_vec(),
|
||||
nonce: nonce.to_vec(),
|
||||
};
|
||||
let value = open(master_key, &stored).map_err(|_| {
|
||||
// Corrupted secret means we can't verify — fail closed (401).
|
||||
EmailInboundError::Unauthorized
|
||||
})?;
|
||||
value
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.ok_or(EmailInboundError::Unauthorized)
|
||||
}
|
||||
|
||||
/// Constant-time HMAC-SHA256 verification of the body against the
|
||||
/// `X-Picloud-Signature` header (lowercase hex).
|
||||
fn verify_signature(
|
||||
headers: &HeaderMap,
|
||||
body: &[u8],
|
||||
secret: &[u8],
|
||||
) -> Result<(), EmailInboundError> {
|
||||
let provided_hex = headers
|
||||
.get(SIGNATURE_HEADER)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or(EmailInboundError::Unauthorized)?;
|
||||
let provided = hex::decode(provided_hex.trim()).map_err(|_| EmailInboundError::Unauthorized)?;
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(secret).map_err(|_| EmailInboundError::Unauthorized)?;
|
||||
mac.update(body);
|
||||
mac.verify_slice(&provided)
|
||||
.map_err(|_| EmailInboundError::Unauthorized)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EmailInboundError {
|
||||
#[error("trigger not found")]
|
||||
NotFound,
|
||||
#[error("invalid signature")]
|
||||
Unauthorized,
|
||||
#[error("malformed body: {0}")]
|
||||
Malformed(String),
|
||||
#[error("backend: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for EmailInboundError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::NotFound => (
|
||||
StatusCode::NOT_FOUND,
|
||||
json!({ "error": "trigger not found" }),
|
||||
),
|
||||
Self::Unauthorized => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
json!({ "error": "invalid or missing signature" }),
|
||||
),
|
||||
Self::Malformed(m) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": format!("malformed inbound email body: {m}") }),
|
||||
),
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "inbound email receiver backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Unit tests for the security-critical helpers (HMAC verify, secret
|
||||
//! round-trip, payload parsing). The full request flow — 202 / 401 /
|
||||
//! 404 / 422 / cross-app — is exercised end-to-end against a real
|
||||
//! Postgres in `crates/picloud/tests/email_inbound.rs`.
|
||||
|
||||
use super::*;
|
||||
use crate::secrets_service::seal;
|
||||
use crate::secrets_service::DEFAULT_SECRET_MAX_VALUE_BYTES;
|
||||
|
||||
fn sign(secret: &[u8], body: &[u8]) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
|
||||
mac.update(body);
|
||||
hex::encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
fn headers_with_sig(sig: &str) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(SIGNATURE_HEADER, sig.parse().unwrap());
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_signature_verifies() {
|
||||
let secret = b"shhh";
|
||||
let body = br#"{"from":"a@b.com"}"#;
|
||||
let sig = sign(secret, body);
|
||||
assert!(verify_signature(&headers_with_sig(&sig), body, secret).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_signature_rejected() {
|
||||
let body = br#"{"from":"a@b.com"}"#;
|
||||
let sig = sign(b"shhh", body);
|
||||
let err = verify_signature(&headers_with_sig(&sig), body, b"different").unwrap_err();
|
||||
assert!(matches!(err, EmailInboundError::Unauthorized));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_signature_header_rejected() {
|
||||
let err = verify_signature(&HeaderMap::new(), b"body", b"secret").unwrap_err();
|
||||
assert!(matches!(err, EmailInboundError::Unauthorized));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_body_fails_verification() {
|
||||
let secret = b"shhh";
|
||||
let sig = sign(secret, b"original");
|
||||
let err = verify_signature(&headers_with_sig(&sig), b"tampered", secret).unwrap_err();
|
||||
assert!(matches!(err, EmailInboundError::Unauthorized));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_round_trips_through_seal_open() {
|
||||
let key = MasterKey::from_bytes([3u8; 32]);
|
||||
let (ct, nonce) = seal(
|
||||
&key,
|
||||
&serde_json::Value::String("provider-secret".into()),
|
||||
DEFAULT_SECRET_MAX_VALUE_BYTES,
|
||||
)
|
||||
.unwrap();
|
||||
let recovered = decrypt_secret(&key, &ct, &nonce).unwrap();
|
||||
assert_eq!(recovered, "provider-secret");
|
||||
// And a signature made with the recovered secret verifies.
|
||||
let body = br#"{"from":"x@y.com"}"#;
|
||||
let sig = sign(recovered.as_bytes(), body);
|
||||
assert!(verify_signature(&headers_with_sig(&sig), body, recovered.as_bytes()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_requires_from_but_defaults_rest() {
|
||||
let ok: Result<InboundPayload, _> = serde_json::from_slice(br#"{"from":"a@b.com"}"#);
|
||||
let p = ok.expect("from-only payload parses");
|
||||
assert_eq!(p.from, "a@b.com");
|
||||
assert!(p.to.is_empty() && p.cc.is_empty() && p.text.is_none());
|
||||
|
||||
// Missing `from` → malformed.
|
||||
let bad: Result<InboundPayload, _> = serde_json::from_slice(br#"{"subject":"hi"}"#);
|
||||
assert!(bad.is_err());
|
||||
}
|
||||
}
|
||||
597
crates/manager-core/src/email_service.rs
Normal file
597
crates/manager-core/src/email_service.rs
Normal file
@@ -0,0 +1,597 @@
|
||||
//! `EmailServiceImpl` — outbound email over an SMTP relay (`lettre`),
|
||||
//! behind the `picloud_shared::EmailService` trait scripts reach via the
|
||||
//! Rhai `email::{send,send_html}` bridge.
|
||||
//!
|
||||
//! Layers added here:
|
||||
//!
|
||||
//! 1. **Script-as-gate authz**: `AppEmailSend` checked when
|
||||
//! `cx.principal.is_some()`; skipped for public-HTTP (`None`).
|
||||
//! 2. Required-field + RFC 5322-ish address validation at the boundary.
|
||||
//! 3. Per-message size cap (default 25 MB).
|
||||
//! 4. **Disabled mode**: if no SMTP relay is configured (HOST/USER/
|
||||
//! PASSWORD not all set) every `send` returns `NotConfigured` and
|
||||
//! startup logs a warning — there is no silent drop.
|
||||
//!
|
||||
//! Connection model: one connection per call (lettre's default). A
|
||||
//! pooled transport is a v1.2+ optimization. Per-app `from` validation /
|
||||
//! SPF / DKIM are the operator's responsibility at the relay (v1.1.7
|
||||
//! does not restrict the `from` address).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use lettre::message::{Mailbox, Message, MultiPart, SinglePart};
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
|
||||
use picloud_shared::{EmailError, EmailService, OutboundEmail, SdkCallCx};
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
|
||||
/// Default per-message size cap (25 MB) — matches most providers.
|
||||
/// Override with `PICLOUD_EMAIL_MAX_MESSAGE_BYTES`.
|
||||
pub const DEFAULT_EMAIL_MAX_MESSAGE_BYTES: usize = 25 * 1024 * 1024;
|
||||
|
||||
/// Generous upper bound on a single address string (RFC 5321 caps the
|
||||
/// path at 256; 320 covers local@domain comfortably).
|
||||
const ADDRESS_MAX_LEN: usize = 320;
|
||||
|
||||
/// Process config for the email service.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct EmailConfig {
|
||||
pub max_message_bytes: usize,
|
||||
}
|
||||
|
||||
impl EmailConfig {
|
||||
#[must_use]
|
||||
pub const fn conservative() -> Self {
|
||||
Self {
|
||||
max_message_bytes: DEFAULT_EMAIL_MAX_MESSAGE_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_env() -> Self {
|
||||
let mut c = Self::conservative();
|
||||
if let Ok(v) = std::env::var("PICLOUD_EMAIL_MAX_MESSAGE_BYTES") {
|
||||
match v.trim().parse::<usize>() {
|
||||
Ok(n) if n > 0 => c.max_message_bytes = n,
|
||||
_ => tracing::warn!(
|
||||
value = %v,
|
||||
"ignoring invalid PICLOUD_EMAIL_MAX_MESSAGE_BYTES (want a positive integer)"
|
||||
),
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EmailConfig {
|
||||
fn default() -> Self {
|
||||
Self::conservative()
|
||||
}
|
||||
}
|
||||
|
||||
/// TLS mode for the SMTP relay connection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SmtpTls {
|
||||
/// STARTTLS upgrade on a plaintext port (typically 587). Default.
|
||||
Starttls,
|
||||
/// Implicit TLS from connect (typically 465).
|
||||
Implicit,
|
||||
/// No TLS — plaintext. Dev/test only.
|
||||
None,
|
||||
}
|
||||
|
||||
/// SMTP relay connection settings, sourced from env.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SmtpConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
pub password: String,
|
||||
pub tls: SmtpTls,
|
||||
pub timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl SmtpConfig {
|
||||
/// Read SMTP settings from env. Returns `None` (→ disabled mode) when
|
||||
/// any of HOST / USER / PASSWORD is missing or empty.
|
||||
#[must_use]
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let host = non_empty_env("PICLOUD_SMTP_HOST")?;
|
||||
let user = non_empty_env("PICLOUD_SMTP_USER")?;
|
||||
let password = non_empty_env("PICLOUD_SMTP_PASSWORD")?;
|
||||
let tls = match std::env::var("PICLOUD_SMTP_TLS")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"implicit" => SmtpTls::Implicit,
|
||||
"none" => SmtpTls::None,
|
||||
// Default + explicit "starttls" + anything unrecognized.
|
||||
_ => SmtpTls::Starttls,
|
||||
};
|
||||
let default_port = match tls {
|
||||
SmtpTls::Implicit => 465,
|
||||
SmtpTls::Starttls | SmtpTls::None => 587,
|
||||
};
|
||||
let port = std::env::var("PICLOUD_SMTP_PORT")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u16>().ok())
|
||||
.unwrap_or(default_port);
|
||||
let timeout_secs = std::env::var("PICLOUD_SMTP_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u64>().ok())
|
||||
.filter(|n| *n > 0)
|
||||
.unwrap_or(30);
|
||||
Some(Self {
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
password,
|
||||
tls,
|
||||
timeout_secs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_env(key: &str) -> Option<String> {
|
||||
std::env::var(key).ok().filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Internal transport seam so the service can be tested without a live
|
||||
/// SMTP server. The production impl is [`LettreEmailTransport`]; tests
|
||||
/// use a recording fake.
|
||||
#[async_trait]
|
||||
pub trait EmailTransport: Send + Sync {
|
||||
async fn send(&self, message: &Message) -> Result<(), EmailError>;
|
||||
}
|
||||
|
||||
/// Production transport: a per-call lettre SMTP connection.
|
||||
pub struct LettreEmailTransport {
|
||||
inner: AsyncSmtpTransport<Tokio1Executor>,
|
||||
}
|
||||
|
||||
impl LettreEmailTransport {
|
||||
/// Build the transport from settings.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns the lettre SMTP error string if the relay descriptor is
|
||||
/// invalid (e.g. TLS setup fails).
|
||||
pub fn build(cfg: &SmtpConfig) -> Result<Self, String> {
|
||||
let builder = match cfg.tls {
|
||||
SmtpTls::Implicit => {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.host).map_err(|e| e.to_string())?
|
||||
}
|
||||
SmtpTls::Starttls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.host)
|
||||
.map_err(|e| e.to_string())?,
|
||||
SmtpTls::None => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.host),
|
||||
};
|
||||
let inner = builder
|
||||
.port(cfg.port)
|
||||
.credentials(Credentials::new(cfg.user.clone(), cfg.password.clone()))
|
||||
.timeout(Some(Duration::from_secs(cfg.timeout_secs)))
|
||||
.build();
|
||||
Ok(Self { inner })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EmailTransport for LettreEmailTransport {
|
||||
async fn send(&self, message: &Message) -> Result<(), EmailError> {
|
||||
// lettre's `AsyncTransport::send` consumes the `Message`; clone so
|
||||
// the caller keeps ownership (it needs it for the size check).
|
||||
self.inner
|
||||
.send(message.clone())
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| EmailError::Transport(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EmailServiceImpl {
|
||||
/// `None` → disabled mode (every send returns `NotConfigured`).
|
||||
transport: Option<Arc<dyn EmailTransport>>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
config: EmailConfig,
|
||||
}
|
||||
|
||||
impl EmailServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
transport: Option<Arc<dyn EmailTransport>>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
config: EmailConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
transport,
|
||||
authz,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct from env: builds a lettre SMTP transport if the relay is
|
||||
/// configured, otherwise runs in disabled mode (with a warning). A
|
||||
/// malformed relay descriptor is logged and also yields disabled mode
|
||||
/// — email is non-critical and must not block startup.
|
||||
#[must_use]
|
||||
pub fn from_env(authz: Arc<dyn AuthzRepo>) -> Self {
|
||||
let config = EmailConfig::from_env();
|
||||
let transport: Option<Arc<dyn EmailTransport>> = match SmtpConfig::from_env() {
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"email is DISABLED: set PICLOUD_SMTP_HOST/USER/PASSWORD to enable \
|
||||
email::send. Scripts calling email::send will get an error."
|
||||
);
|
||||
None
|
||||
}
|
||||
Some(cfg) => match LettreEmailTransport::build(&cfg) {
|
||||
Ok(t) => {
|
||||
tracing::info!(host = %cfg.host, port = cfg.port, "outbound email enabled");
|
||||
Some(Arc::new(t))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to build SMTP transport; email DISABLED");
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
Self::new(transport, authz, config)
|
||||
}
|
||||
|
||||
async fn check_send(&self, cx: &SdkCallCx) -> Result<(), EmailError> {
|
||||
if let Some(ref principal) = cx.principal {
|
||||
authz::require(&*self.authz, principal, Capability::AppEmailSend(cx.app_id))
|
||||
.await
|
||||
.map_err(|_| EmailError::Forbidden)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EmailService for EmailServiceImpl {
|
||||
async fn send(&self, cx: &SdkCallCx, email: OutboundEmail) -> Result<(), EmailError> {
|
||||
self.check_send(cx).await?;
|
||||
let Some(transport) = self.transport.as_ref() else {
|
||||
return Err(EmailError::NotConfigured);
|
||||
};
|
||||
let message = build_message(&email)?;
|
||||
let formatted = message.formatted();
|
||||
if formatted.len() > self.config.max_message_bytes {
|
||||
return Err(EmailError::TooLarge {
|
||||
limit: self.config.max_message_bytes,
|
||||
actual: formatted.len(),
|
||||
});
|
||||
}
|
||||
transport.send(&message).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the required fields + addresses and assemble a lettre
|
||||
/// `Message`. Pure (no I/O) so it's unit-testable on its own.
|
||||
fn build_message(email: &OutboundEmail) -> Result<Message, EmailError> {
|
||||
if email.from.trim().is_empty() {
|
||||
return Err(EmailError::MissingField("from".into()));
|
||||
}
|
||||
if email.to.iter().all(|a| a.trim().is_empty()) {
|
||||
return Err(EmailError::MissingField("to".into()));
|
||||
}
|
||||
if email.subject.trim().is_empty() {
|
||||
return Err(EmailError::MissingField("subject".into()));
|
||||
}
|
||||
let has_text = email.text.as_ref().is_some_and(|t| !t.is_empty());
|
||||
let has_html = email.html.as_ref().is_some_and(|h| !h.is_empty());
|
||||
if !has_text && !has_html {
|
||||
return Err(EmailError::MissingField("text or html".into()));
|
||||
}
|
||||
|
||||
let mut builder = Message::builder()
|
||||
.from(parse_address(&email.from)?)
|
||||
.subject(email.subject.clone());
|
||||
|
||||
for addr in non_empty(&email.to) {
|
||||
builder = builder.to(parse_address(addr)?);
|
||||
}
|
||||
for addr in non_empty(&email.cc) {
|
||||
builder = builder.cc(parse_address(addr)?);
|
||||
}
|
||||
for addr in non_empty(&email.bcc) {
|
||||
builder = builder.bcc(parse_address(addr)?);
|
||||
}
|
||||
// reply_to defaults to `from` when not supplied.
|
||||
let reply_to = email.reply_to.as_deref().unwrap_or(&email.from);
|
||||
builder = builder.reply_to(parse_address(reply_to)?);
|
||||
|
||||
// `has_text` / `has_html` were validated above (at least one is set).
|
||||
let text = email.text.clone().unwrap_or_default();
|
||||
let html = email.html.clone().unwrap_or_default();
|
||||
let message = if has_text && has_html {
|
||||
builder.multipart(MultiPart::alternative_plain_html(text, html))
|
||||
} else if has_html {
|
||||
builder.singlepart(SinglePart::html(html))
|
||||
} else {
|
||||
builder.singlepart(SinglePart::plain(text))
|
||||
}
|
||||
.map_err(|e| EmailError::Transport(e.to_string()))?;
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
fn non_empty(addrs: &[String]) -> impl Iterator<Item = &String> {
|
||||
addrs.iter().filter(|a| !a.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Hand-rolled RFC 5322-ish address check, then a `lettre::Mailbox`
|
||||
/// parse (the authoritative validator). We do NOT check deliverability —
|
||||
/// that's the SMTP layer's job.
|
||||
fn parse_address(addr: &str) -> Result<Mailbox, EmailError> {
|
||||
let trimmed = addr.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(EmailError::InvalidAddress("empty address".into()));
|
||||
}
|
||||
if trimmed.len() > ADDRESS_MAX_LEN {
|
||||
return Err(EmailError::InvalidAddress(format!(
|
||||
"address exceeds {ADDRESS_MAX_LEN} bytes"
|
||||
)));
|
||||
}
|
||||
// Must have a single-ish @ with a non-empty local part and a domain
|
||||
// that contains a dot (rejects "a@b" and bare tokens).
|
||||
match trimmed.rsplit_once('@') {
|
||||
Some((local, domain)) if !local.is_empty() && domain.contains('.') => {}
|
||||
_ => {
|
||||
return Err(EmailError::InvalidAddress(format!(
|
||||
"{trimmed:?} is not a valid email address"
|
||||
)))
|
||||
}
|
||||
}
|
||||
trimmed.parse::<Mailbox>().map_err(|_| {
|
||||
EmailError::InvalidAddress(format!("{trimmed:?} is not a valid email address"))
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests — recording transport so unit tests need no live SMTP server.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
|
||||
UserId,
|
||||
};
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingTransport {
|
||||
sent: StdMutex<Vec<Vec<u8>>>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl EmailTransport for RecordingTransport {
|
||||
async fn send(&self, message: &Message) -> Result<(), EmailError> {
|
||||
self.sent.lock().unwrap().push(message.formatted());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DenyAuthz;
|
||||
#[async_trait]
|
||||
impl AuthzRepo for DenyAuthz {
|
||||
async fn membership(&self, _: UserId, _: AppId) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
struct GrantAuthz {
|
||||
app: AppId,
|
||||
role: AppRole,
|
||||
}
|
||||
#[async_trait]
|
||||
impl AuthzRepo for GrantAuthz {
|
||||
async fn membership(
|
||||
&self,
|
||||
_: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok((app_id == self.app).then_some(self.role))
|
||||
}
|
||||
}
|
||||
|
||||
fn svc_with(
|
||||
transport: Option<Arc<dyn EmailTransport>>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
) -> EmailServiceImpl {
|
||||
EmailServiceImpl::new(transport, authz, EmailConfig::conservative())
|
||||
}
|
||||
|
||||
fn recording() -> (EmailServiceImpl, Arc<RecordingTransport>) {
|
||||
let rec = Arc::new(RecordingTransport::default());
|
||||
let svc = svc_with(Some(rec.clone()), Arc::new(DenyAuthz));
|
||||
(svc, rec)
|
||||
}
|
||||
|
||||
fn cx_with(app_id: AppId, principal: Option<Principal>) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
script_id: ScriptId::new(),
|
||||
principal,
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: ExecutionId::new(),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn anon(app: AppId) -> SdkCallCx {
|
||||
cx_with(app, None)
|
||||
}
|
||||
|
||||
fn principal(role: InstanceRole) -> Principal {
|
||||
Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: role,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn base_email() -> OutboundEmail {
|
||||
OutboundEmail {
|
||||
to: vec!["alice@example.com".into()],
|
||||
from: "alerts@myapp.com".into(),
|
||||
subject: "Build complete".into(),
|
||||
text: Some("Your deploy finished.".into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn last_message(rec: &RecordingTransport) -> String {
|
||||
let g = rec.sent.lock().unwrap();
|
||||
String::from_utf8_lossy(g.last().expect("a message was sent")).into_owned()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_text_includes_headers_and_body() {
|
||||
let (svc, rec) = recording();
|
||||
svc.send(&anon(AppId::new()), base_email()).await.unwrap();
|
||||
let msg = last_message(&rec);
|
||||
assert!(msg.contains("To: alice@example.com"), "{msg}");
|
||||
assert!(msg.contains("From: alerts@myapp.com"), "{msg}");
|
||||
assert!(msg.contains("Subject: Build complete"), "{msg}");
|
||||
assert!(msg.contains("Your deploy finished."), "{msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_html_is_multipart_with_both_parts() {
|
||||
let (svc, rec) = recording();
|
||||
let mut e = base_email();
|
||||
e.text = Some("plain fallback".into());
|
||||
e.html = Some("<p>rich <b>body</b></p>".into());
|
||||
svc.send(&anon(AppId::new()), e).await.unwrap();
|
||||
let msg = last_message(&rec);
|
||||
assert!(msg.contains("multipart/alternative"), "{msg}");
|
||||
assert!(msg.contains("plain fallback"), "{msg}");
|
||||
// HTML part is quoted-printable encoded, but the tag survives.
|
||||
assert!(msg.contains("text/html"), "{msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_recipients_and_cc_bcc() {
|
||||
let (svc, rec) = recording();
|
||||
let mut e = base_email();
|
||||
e.to = vec!["alice@x.com".into(), "bob@y.com".into()];
|
||||
e.cc = vec!["dave@z.com".into()];
|
||||
e.bcc = vec!["audit@myapp.com".into()];
|
||||
svc.send(&anon(AppId::new()), e).await.unwrap();
|
||||
let msg = last_message(&rec);
|
||||
assert!(
|
||||
msg.contains("alice@x.com") && msg.contains("bob@y.com"),
|
||||
"{msg}"
|
||||
);
|
||||
assert!(msg.contains("Cc: dave@z.com"), "{msg}");
|
||||
// Bcc is intentionally NOT serialized into the visible headers.
|
||||
assert!(
|
||||
!msg.contains("Bcc:"),
|
||||
"bcc must not appear in headers: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reply_to_populated() {
|
||||
let (svc, rec) = recording();
|
||||
let mut e = base_email();
|
||||
e.reply_to = Some("support@myapp.com".into());
|
||||
svc.send(&anon(AppId::new()), e).await.unwrap();
|
||||
assert!(last_message(&rec).contains("Reply-To: support@myapp.com"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_required_field_throws() {
|
||||
let (svc, _) = recording();
|
||||
let mut e = base_email();
|
||||
e.subject = String::new();
|
||||
let err = svc.send(&anon(AppId::new()), e).await.unwrap_err();
|
||||
assert!(matches!(err, EmailError::MissingField(f) if f == "subject"));
|
||||
|
||||
let (svc, _) = recording();
|
||||
let mut e = base_email();
|
||||
e.text = None;
|
||||
e.html = None;
|
||||
let err = svc.send(&anon(AppId::new()), e).await.unwrap_err();
|
||||
assert!(matches!(err, EmailError::MissingField(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_address_throws() {
|
||||
let (svc, _) = recording();
|
||||
let mut e = base_email();
|
||||
e.to = vec!["not-an-email".into()];
|
||||
let err = svc.send(&anon(AppId::new()), e).await.unwrap_err();
|
||||
assert!(matches!(err, EmailError::InvalidAddress(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn message_size_cap_enforced() {
|
||||
let rec = Arc::new(RecordingTransport::default());
|
||||
let svc = EmailServiceImpl::new(
|
||||
Some(rec),
|
||||
Arc::new(DenyAuthz),
|
||||
EmailConfig {
|
||||
max_message_bytes: 64,
|
||||
},
|
||||
);
|
||||
let err = svc
|
||||
.send(&anon(AppId::new()), base_email())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, EmailError::TooLarge { limit: 64, .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn not_configured_throws() {
|
||||
let svc = svc_with(None, Arc::new(DenyAuthz));
|
||||
let err = svc
|
||||
.send(&anon(AppId::new()), base_email())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, EmailError::NotConfigured));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anonymous_skips_authz() {
|
||||
// DenyAuthz would deny an authed principal; anon skips the check.
|
||||
let (svc, _) = recording();
|
||||
svc.send(&anon(AppId::new()), base_email()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn member_with_editor_role_allowed() {
|
||||
let app = AppId::new();
|
||||
let rec = Arc::new(RecordingTransport::default());
|
||||
let svc = svc_with(
|
||||
Some(rec),
|
||||
Arc::new(GrantAuthz {
|
||||
app,
|
||||
role: AppRole::Editor,
|
||||
}),
|
||||
);
|
||||
let cx = cx_with(app, Some(principal(InstanceRole::Member)));
|
||||
svc.send(&cx, base_email()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn member_without_role_forbidden() {
|
||||
let (svc, _) = recording();
|
||||
let cx = cx_with(AppId::new(), Some(principal(InstanceRole::Member)));
|
||||
let err = svc.send(&cx, base_email()).await.unwrap_err();
|
||||
assert!(matches!(err, EmailError::Forbidden));
|
||||
}
|
||||
}
|
||||
215
crates/manager-core/src/files_api.rs
Normal file
215
crates/manager-core/src/files_api.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
//! `/api/v1/admin/apps/{id}/files*` — minimal files admin endpoints
|
||||
//! backing the dashboard's files view (v1.1.5).
|
||||
//!
|
||||
//! Two operations only, both operator-facing:
|
||||
//! * `GET /apps/{id}/files?collection=<c>&cursor=&limit=` — list file
|
||||
//! metadata for a collection (cursor-paginated).
|
||||
//! * `DELETE /apps/{id}/files/{collection}/{file_id}` — remove a file.
|
||||
//!
|
||||
//! These talk to the `FilesRepo` directly (like `triggers_api` talks to
|
||||
//! `TriggerRepo`), guarded by the same capability model as the SDK
|
||||
//! (`AppFilesRead` / `AppFilesWrite`). **Admin deletes do NOT emit a
|
||||
//! `files:delete` trigger event** — they're operator cleanup actions,
|
||||
//! not script mutations (see HANDBACK §7). The capability binds to the
|
||||
//! resource's `app_id` after the app is loaded.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{delete, get};
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{AppId, Principal};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::files_repo::{FilesRepo, FilesRepoError};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FilesAdminState {
|
||||
pub files: Arc<dyn FilesRepo>,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn files_admin_router(state: FilesAdminState) -> Router {
|
||||
Router::new()
|
||||
.route("/apps/{app_id}/files", get(list_files))
|
||||
.route(
|
||||
"/apps/{app_id}/files/{collection}/{file_id}",
|
||||
delete(delete_file),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListFilesQuery {
|
||||
pub collection: String,
|
||||
#[serde(default)]
|
||||
pub cursor: Option<String>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct FileMetaDto {
|
||||
id: String,
|
||||
collection: String,
|
||||
name: String,
|
||||
content_type: String,
|
||||
size: u64,
|
||||
checksum: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ListFilesResponse {
|
||||
files: Vec<FileMetaDto>,
|
||||
next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_files(
|
||||
State(s): State<FilesAdminState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(app_id): Path<AppId>,
|
||||
Query(q): Query<ListFilesQuery>,
|
||||
) -> Result<Json<ListFilesResponse>, FilesApiError> {
|
||||
ensure_app_exists(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppFilesRead(app_id),
|
||||
)
|
||||
.await?;
|
||||
if q.collection.trim().is_empty() {
|
||||
return Err(FilesApiError::Invalid(
|
||||
"collection must not be empty".into(),
|
||||
));
|
||||
}
|
||||
let page = s
|
||||
.files
|
||||
.list(
|
||||
app_id,
|
||||
&q.collection,
|
||||
q.cursor.as_deref(),
|
||||
q.limit.unwrap_or(0),
|
||||
)
|
||||
.await?;
|
||||
let files = page
|
||||
.files
|
||||
.into_iter()
|
||||
.map(|m| FileMetaDto {
|
||||
id: m.id.to_string(),
|
||||
collection: m.collection,
|
||||
name: m.name,
|
||||
content_type: m.content_type,
|
||||
size: m.size,
|
||||
checksum: m.checksum,
|
||||
created_at: m.created_at.to_rfc3339(),
|
||||
updated_at: m.updated_at.to_rfc3339(),
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ListFilesResponse {
|
||||
files,
|
||||
next_cursor: page.next_cursor,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn delete_file(
|
||||
State(s): State<FilesAdminState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((app_id, collection, file_id)): Path<(AppId, String, String)>,
|
||||
) -> Result<StatusCode, FilesApiError> {
|
||||
ensure_app_exists(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppFilesWrite(app_id),
|
||||
)
|
||||
.await?;
|
||||
let id = Uuid::parse_str(&file_id).map_err(|_| FilesApiError::NotFound)?;
|
||||
if s.files.delete(app_id, &collection, id).await?.is_none() {
|
||||
return Err(FilesApiError::NotFound);
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn ensure_app_exists(apps: &dyn AppRepository, app_id: AppId) -> Result<(), FilesApiError> {
|
||||
apps.get_by_id(app_id)
|
||||
.await
|
||||
.map_err(|e| FilesApiError::Backend(e.to_string()))?
|
||||
.ok_or(FilesApiError::AppNotFound)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FilesApiError {
|
||||
#[error("app not found")]
|
||||
AppNotFound,
|
||||
#[error("file not found")]
|
||||
NotFound,
|
||||
#[error("invalid request: {0}")]
|
||||
Invalid(String),
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
#[error("files backend: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for FilesApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthzError> for FilesApiError {
|
||||
fn from(e: AuthzError) -> Self {
|
||||
Self::AuthzRepo(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FilesRepoError> for FilesApiError {
|
||||
fn from(e: FilesRepoError) -> Self {
|
||||
Self::Backend(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for FilesApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound | Self::NotFound => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Invalid(_) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "files admin authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "files admin backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
759
crates/manager-core/src/files_repo.rs
Normal file
759
crates/manager-core/src/files_repo.rs
Normal file
@@ -0,0 +1,759 @@
|
||||
//! `FilesRepo` — the metadata row (Postgres) + blob bytes (filesystem)
|
||||
//! storage layer for the v1.1.5 `files::*` SDK.
|
||||
//!
|
||||
//! Unlike KV/docs, this repo owns BOTH halves of a file: the `files`
|
||||
//! row (metadata + SHA-256 checksum) and the bytes on disk at
|
||||
//! `<root>/files/<app_id>/<collection>/<id[0:2]>/<id>`.
|
||||
//! It owns both because the write must be atomic across them — a crash
|
||||
//! mid-write must never leave a readable half-written file.
|
||||
//!
|
||||
//! ## Atomic write protocol (`create` / `update`)
|
||||
//! 1. Validate (collection path-safety; caps live one layer up).
|
||||
//! 2. `create_dir_all` the shard dir with `0o700`.
|
||||
//! 3. SHA-256 the in-memory bytes (single pass) while writing to
|
||||
//! `<final>.tmp.<unique>`.
|
||||
//! 4. `fsync` the temp file.
|
||||
//! 5. `rename` temp → final (atomic on POSIX).
|
||||
//! 6. `fsync` the parent dir (so the rename is durable).
|
||||
//! 7. INSERT / UPDATE the DB row.
|
||||
//!
|
||||
//! A crash between 1–5 leaves an orphan `*.tmp.*` (never read). A crash
|
||||
//! between 5–7 leaves a file with no row — never reachable via the SDK
|
||||
//! (reads start from the row). Both are reclaimed by a future orphan
|
||||
//! sweep (deferred to v1.1.6+; see HANDBACK §7).
|
||||
//!
|
||||
//! ## Atomic delete protocol
|
||||
//! 1. SELECT + DELETE the row inside one transaction; commit.
|
||||
//! 2. `unlink` the file (outside the tx). A failure here leaves an
|
||||
//! orphan; a failure before the commit changes nothing.
|
||||
//!
|
||||
//! ## Checksum-on-read
|
||||
//! `get` reads the file, hashes it, and compares against the stored
|
||||
//! checksum — returning `FilesError::Corrupted` (and logging the path
|
||||
//! at error level) on a mismatch. It never auto-deletes; the operator
|
||||
//! decides what to do with a metadata-vs-bytes divergence.
|
||||
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AppId, FileMeta, FileUpdate, FilesListPage, NewFile};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 100 MB default per-file cap.
|
||||
pub const DEFAULT_MAX_FILE_SIZE_BYTES: usize = 100 * 1024 * 1024;
|
||||
/// Default filesystem root (relative to the process CWD).
|
||||
pub const DEFAULT_FILES_ROOT: &str = "./data";
|
||||
|
||||
const FILES_LIST_MAX_LIMIT: u32 = 1_000;
|
||||
const FILES_LIST_DEFAULT_LIMIT: u32 = 100;
|
||||
|
||||
/// Monotonic counter feeding unique temp-file suffixes (combined with
|
||||
/// the pid). Avoids `rand` in the storage layer per the brief.
|
||||
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FilesRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
#[error("filesystem error: {0}")]
|
||||
Io(String),
|
||||
|
||||
#[error("invalid collection name: {0}")]
|
||||
InvalidCollection(String),
|
||||
|
||||
/// The bytes on disk no longer match the stored checksum (or are
|
||||
/// missing entirely while the row persists).
|
||||
#[error("file content corrupted (checksum mismatch)")]
|
||||
Corrupted,
|
||||
|
||||
#[error("invalid pagination cursor")]
|
||||
InvalidCursor,
|
||||
}
|
||||
|
||||
/// Outbound-files tunables. Env-overridable following the same pattern
|
||||
/// as `HttpConfig::from_env`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FilesConfig {
|
||||
pub root: PathBuf,
|
||||
pub max_file_size_bytes: usize,
|
||||
}
|
||||
|
||||
impl FilesConfig {
|
||||
#[must_use]
|
||||
pub fn conservative() -> Self {
|
||||
Self {
|
||||
root: PathBuf::from(DEFAULT_FILES_ROOT),
|
||||
max_file_size_bytes: DEFAULT_MAX_FILE_SIZE_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_env() -> Self {
|
||||
let mut c = Self::conservative();
|
||||
if let Ok(v) = env::var("PICLOUD_FILES_ROOT") {
|
||||
if !v.trim().is_empty() {
|
||||
c.root = PathBuf::from(v);
|
||||
}
|
||||
}
|
||||
if let Ok(v) = env::var("PICLOUD_FILES_MAX_FILE_SIZE_BYTES") {
|
||||
match v.parse::<usize>() {
|
||||
Ok(n) => c.max_file_size_bytes = n,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "ignoring invalid PICLOUD_FILES_MAX_FILE_SIZE_BYTES");
|
||||
}
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FilesConfig {
|
||||
fn default() -> Self {
|
||||
Self::conservative()
|
||||
}
|
||||
}
|
||||
|
||||
/// The new+prior metadata returned from a successful `update`, so the
|
||||
/// service can emit a `ServiceEvent` with the change-data-capture
|
||||
/// surface (`old_payload`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileUpdated {
|
||||
pub new: FileMeta,
|
||||
pub prev: FileMeta,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait FilesRepo: Send + Sync {
|
||||
async fn create(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
new: NewFile,
|
||||
) -> Result<FileMeta, FilesRepoError>;
|
||||
|
||||
async fn head(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, FilesRepoError>;
|
||||
|
||||
/// Reads + checksum-verifies the bytes. `Ok(None)` when no row
|
||||
/// exists; `Err(Corrupted)` when the row exists but the bytes are
|
||||
/// missing or mismatched.
|
||||
async fn get(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<Vec<u8>>, FilesRepoError>;
|
||||
|
||||
/// `Ok(None)` when no row exists (the SDK turns this into
|
||||
/// `FilesError::NotFound`).
|
||||
async fn update(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
upd: FileUpdate,
|
||||
) -> Result<Option<FileUpdated>, FilesRepoError>;
|
||||
|
||||
/// Returns the deleted row's metadata if present, `None` otherwise.
|
||||
async fn delete(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, FilesRepoError>;
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<FilesListPage, FilesRepoError>;
|
||||
}
|
||||
|
||||
/// Filesystem-bytes + Postgres-metadata repo.
|
||||
pub struct FsFilesRepo {
|
||||
pool: PgPool,
|
||||
config: FilesConfig,
|
||||
}
|
||||
|
||||
impl FsFilesRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool, config: FilesConfig) -> Self {
|
||||
Self { pool, config }
|
||||
}
|
||||
|
||||
/// Defensive path-component guard. The service already validates the
|
||||
/// collection at the SDK boundary; this is belt-and-suspenders so a
|
||||
/// future caller can't smuggle a traversal sequence onto disk.
|
||||
fn guard_collection(collection: &str) -> Result<(), FilesRepoError> {
|
||||
if collection.is_empty()
|
||||
|| collection.contains('/')
|
||||
|| collection.contains('\\')
|
||||
|| collection.contains("..")
|
||||
|| collection.contains('\0')
|
||||
{
|
||||
return Err(FilesRepoError::InvalidCollection(collection.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn final_path(&self, app_id: AppId, collection: &str, id: Uuid) -> PathBuf {
|
||||
final_path_at(&self.config.root, app_id, collection, id)
|
||||
}
|
||||
|
||||
fn write_atomic(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
bytes: &[u8],
|
||||
) -> Result<String, FilesRepoError> {
|
||||
write_atomic_at(&self.config.root, app_id, collection, id, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn shard_dir_at(root: &Path, app_id: AppId, collection: &str, id_str: &str) -> PathBuf {
|
||||
root.join("files")
|
||||
.join(app_id.into_inner().to_string())
|
||||
.join(collection)
|
||||
.join(&id_str[..2])
|
||||
}
|
||||
|
||||
fn final_path_at(root: &Path, app_id: AppId, collection: &str, id: Uuid) -> PathBuf {
|
||||
let id_str = id.to_string();
|
||||
shard_dir_at(root, app_id, collection, &id_str).join(&id_str)
|
||||
}
|
||||
|
||||
/// Steps 2–6 of the atomic-write protocol. Returns the lowercase hex
|
||||
/// SHA-256 of the bytes (computed in a single pass over the in-memory
|
||||
/// buffer — the file is never re-read). Free function so the fs
|
||||
/// mechanics are unit-testable without a Postgres pool.
|
||||
fn write_atomic_at(
|
||||
root: &Path,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
bytes: &[u8],
|
||||
) -> Result<String, FilesRepoError> {
|
||||
use std::io::Write as _;
|
||||
|
||||
let id_str = id.to_string();
|
||||
let dir = shard_dir_at(root, app_id, collection, &id_str);
|
||||
create_dir_all_secure(&dir)?;
|
||||
|
||||
// Single-pass checksum over the in-memory buffer.
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
let checksum = hex_lower(&hasher.finalize());
|
||||
|
||||
let seq = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let tmp = dir.join(format!("{id_str}.tmp.{}-{seq}", std::process::id()));
|
||||
let final_path = dir.join(&id_str);
|
||||
|
||||
{
|
||||
let mut f = std::fs::File::create(&tmp).map_err(io_err)?;
|
||||
f.write_all(bytes).map_err(io_err)?;
|
||||
f.sync_all().map_err(io_err)?; // fsync temp
|
||||
}
|
||||
std::fs::rename(&tmp, &final_path).map_err(io_err)?; // atomic
|
||||
// fsync the parent dir so the rename is durable.
|
||||
if let Ok(dirf) = std::fs::File::open(&dir) {
|
||||
let _ = dirf.sync_all();
|
||||
}
|
||||
Ok(checksum)
|
||||
}
|
||||
|
||||
/// Read + checksum-verify the bytes at the given path-set. Free
|
||||
/// function mirror of the `get` read path. Returns `Corrupted` when the
|
||||
/// bytes are missing or don't match `expected_checksum`.
|
||||
fn read_verify_at(
|
||||
root: &Path,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
expected_checksum: &str,
|
||||
) -> Result<Vec<u8>, FilesRepoError> {
|
||||
let path = final_path_at(root, app_id, collection, id);
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
path = %path.display(), error = %e,
|
||||
"files: row exists but bytes are unreadable — treating as corrupted"
|
||||
);
|
||||
return Err(FilesRepoError::Corrupted);
|
||||
}
|
||||
};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&bytes);
|
||||
let actual = hex_lower(&hasher.finalize());
|
||||
if actual != expected_checksum {
|
||||
tracing::error!(
|
||||
path = %path.display(), expected = %expected_checksum, actual = %actual,
|
||||
"files: checksum mismatch on read — content corrupted"
|
||||
);
|
||||
return Err(FilesRepoError::Corrupted);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FilesRepo for FsFilesRepo {
|
||||
async fn create(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
new: NewFile,
|
||||
) -> Result<FileMeta, FilesRepoError> {
|
||||
Self::guard_collection(collection)?;
|
||||
let id = Uuid::new_v4();
|
||||
let size = i64::try_from(new.data.len()).unwrap_or(i64::MAX);
|
||||
|
||||
let checksum = self.write_atomic(app_id, collection, id, &new.data)?;
|
||||
|
||||
let row: FileRow = sqlx::query_as(
|
||||
"INSERT INTO files \
|
||||
(app_id, collection, id, name, content_type, size_bytes, checksum_sha256) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, collection, name, content_type, size_bytes, \
|
||||
checksum_sha256, created_at, updated_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.bind(&new.name)
|
||||
.bind(&new.content_type)
|
||||
.bind(size)
|
||||
.bind(&checksum)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.into_meta())
|
||||
}
|
||||
|
||||
async fn head(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, FilesRepoError> {
|
||||
let row: Option<FileRow> = sqlx::query_as(
|
||||
"SELECT id, collection, name, content_type, size_bytes, \
|
||||
checksum_sha256, created_at, updated_at \
|
||||
FROM files WHERE app_id = $1 AND collection = $2 AND id = $3",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(FileRow::into_meta))
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<Vec<u8>>, FilesRepoError> {
|
||||
let row: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT checksum_sha256 FROM files \
|
||||
WHERE app_id = $1 AND collection = $2 AND id = $3",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
let Some((stored_checksum,)) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes = read_verify_at(&self.config.root, app_id, collection, id, &stored_checksum)?;
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
upd: FileUpdate,
|
||||
) -> Result<Option<FileUpdated>, FilesRepoError> {
|
||||
Self::guard_collection(collection)?;
|
||||
// Read the prior row first (existence check + CDC surface).
|
||||
let Some(prev) = self.head(app_id, collection, id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let size = i64::try_from(upd.data.len()).unwrap_or(i64::MAX);
|
||||
let checksum = self.write_atomic(app_id, collection, id, &upd.data)?;
|
||||
|
||||
let row: FileRow = sqlx::query_as(
|
||||
"UPDATE files SET \
|
||||
name = COALESCE($4, name), \
|
||||
content_type = COALESCE($5, content_type), \
|
||||
size_bytes = $6, \
|
||||
checksum_sha256 = $7, \
|
||||
updated_at = NOW() \
|
||||
WHERE app_id = $1 AND collection = $2 AND id = $3 \
|
||||
RETURNING id, collection, name, content_type, size_bytes, \
|
||||
checksum_sha256, created_at, updated_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.bind(upd.name.as_deref())
|
||||
.bind(upd.content_type.as_deref())
|
||||
.bind(size)
|
||||
.bind(&checksum)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Some(FileUpdated {
|
||||
new: row.into_meta(),
|
||||
prev,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, FilesRepoError> {
|
||||
// SELECT + DELETE in one tx; unlink afterwards (outside the tx).
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let row: Option<FileRow> = sqlx::query_as(
|
||||
"SELECT id, collection, name, content_type, size_bytes, \
|
||||
checksum_sha256, created_at, updated_at \
|
||||
FROM files WHERE app_id = $1 AND collection = $2 AND id = $3 \
|
||||
FOR UPDATE",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let Some(row) = row else {
|
||||
tx.rollback().await?;
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
sqlx::query("DELETE FROM files WHERE app_id = $1 AND collection = $2 AND id = $3")
|
||||
.bind(app_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// Row is gone; unlink the bytes. A failure here leaves an orphan
|
||||
// file (reclaimed by a future sweep) — not fatal.
|
||||
let path = self.final_path(app_id, collection, id);
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
tracing::warn!(path = %path.display(), error = %e, "files: unlink after delete failed (orphan)");
|
||||
}
|
||||
}
|
||||
Ok(Some(row.into_meta()))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<FilesListPage, FilesRepoError> {
|
||||
let limit = if limit == 0 {
|
||||
FILES_LIST_DEFAULT_LIMIT
|
||||
} else {
|
||||
limit.min(FILES_LIST_MAX_LIMIT)
|
||||
};
|
||||
let last_id = match cursor {
|
||||
Some(c) => Some(decode_cursor(c)?),
|
||||
None => None,
|
||||
};
|
||||
let take = i64::from(limit) + 1;
|
||||
let rows: Vec<FileRow> = sqlx::query_as(
|
||||
"SELECT id, collection, name, content_type, size_bytes, \
|
||||
checksum_sha256, created_at, updated_at \
|
||||
FROM files \
|
||||
WHERE app_id = $1 AND collection = $2 \
|
||||
AND ($3::uuid IS NULL OR id > $3) \
|
||||
ORDER BY id ASC \
|
||||
LIMIT $4",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(last_id)
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut files: Vec<FileMeta> = rows.into_iter().map(FileRow::into_meta).collect();
|
||||
let next_cursor = if files.len() > limit as usize {
|
||||
files.truncate(limit as usize);
|
||||
files.last().map(|m| encode_cursor(m.id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(FilesListPage { files, next_cursor })
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn io_err(e: std::io::Error) -> FilesRepoError {
|
||||
FilesRepoError::Io(e.to_string())
|
||||
}
|
||||
|
||||
/// `create_dir_all` with `0o700` on the created tree (Unix). On other
|
||||
/// platforms it falls back to the default permissions.
|
||||
fn create_dir_all_secure(dir: &Path) -> Result<(), FilesRepoError> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt as _;
|
||||
std::fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(dir)
|
||||
.map_err(io_err)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::create_dir_all(dir).map_err(io_err)
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_lower(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(s, "{b:02x}");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn encode_cursor(last_id: Uuid) -> String {
|
||||
URL_SAFE_NO_PAD.encode(last_id.to_string().as_bytes())
|
||||
}
|
||||
|
||||
fn decode_cursor(cursor: &str) -> Result<Uuid, FilesRepoError> {
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(cursor)
|
||||
.map_err(|_| FilesRepoError::InvalidCursor)?;
|
||||
let s = String::from_utf8(bytes).map_err(|_| FilesRepoError::InvalidCursor)?;
|
||||
Uuid::parse_str(&s).map_err(|_| FilesRepoError::InvalidCursor)
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct FileRow {
|
||||
id: Uuid,
|
||||
collection: String,
|
||||
name: String,
|
||||
content_type: String,
|
||||
size_bytes: i64,
|
||||
checksum_sha256: String,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl FileRow {
|
||||
fn into_meta(self) -> FileMeta {
|
||||
FileMeta {
|
||||
id: self.id,
|
||||
collection: self.collection,
|
||||
name: self.name,
|
||||
content_type: self.content_type,
|
||||
size: u64::try_from(self.size_bytes).unwrap_or(0),
|
||||
checksum: self.checksum_sha256,
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hex_lower_matches_known_sha256_vector() {
|
||||
// SHA-256("abc") — NIST known-answer vector.
|
||||
let mut h = Sha256::new();
|
||||
h.update(b"abc");
|
||||
assert_eq!(
|
||||
hex_lower(&h.finalize()),
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_lower_of_empty_is_known_vector() {
|
||||
let mut h = Sha256::new();
|
||||
h.update(b"");
|
||||
assert_eq!(
|
||||
hex_lower(&h.finalize()),
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_round_trips() {
|
||||
let id = Uuid::new_v4();
|
||||
let enc = encode_cursor(id);
|
||||
assert_eq!(decode_cursor(&enc).unwrap(), id);
|
||||
assert!(matches!(
|
||||
decode_cursor("!!not-base64!!"),
|
||||
Err(FilesRepoError::InvalidCursor)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_collection_rejects_traversal() {
|
||||
assert!(FsFilesRepo::guard_collection("avatars").is_ok());
|
||||
assert!(FsFilesRepo::guard_collection("a/b").is_err());
|
||||
assert!(FsFilesRepo::guard_collection("..").is_err());
|
||||
assert!(FsFilesRepo::guard_collection("a..b").is_err());
|
||||
assert!(FsFilesRepo::guard_collection("").is_err());
|
||||
assert!(FsFilesRepo::guard_collection("a\0b").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_from_env_defaults_are_conservative() {
|
||||
let c = FilesConfig::conservative();
|
||||
assert_eq!(c.max_file_size_bytes, DEFAULT_MAX_FILE_SIZE_BYTES);
|
||||
assert_eq!(c.root, PathBuf::from(DEFAULT_FILES_ROOT));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Tempdir-backed filesystem mechanics — exercise the atomic write,
|
||||
// single-pass checksum, and checksum-on-read tamper detection
|
||||
// without needing a Postgres pool.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
use picloud_shared::AppId;
|
||||
|
||||
/// Process-unique scratch dir under the system temp dir. Cleaned up
|
||||
/// by each test via `remove_dir_all`.
|
||||
fn unique_tmp_root() -> PathBuf {
|
||||
let seq = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("picloud-files-test-{}-{seq}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_atomic_then_read_verify_round_trips() {
|
||||
let root = unique_tmp_root();
|
||||
let app = AppId::new();
|
||||
let id = Uuid::new_v4();
|
||||
let bytes = b"hello picloud files".to_vec();
|
||||
|
||||
let checksum = write_atomic_at(&root, app, "avatars", id, &bytes).unwrap();
|
||||
// Single-pass checksum matches an independent hash of the bytes.
|
||||
let mut h = Sha256::new();
|
||||
h.update(&bytes);
|
||||
assert_eq!(checksum, hex_lower(&h.finalize()));
|
||||
|
||||
let read = read_verify_at(&root, app, "avatars", id, &checksum).unwrap();
|
||||
assert_eq!(read, bytes);
|
||||
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_verify_detects_tampering_as_corrupted() {
|
||||
let root = unique_tmp_root();
|
||||
let app = AppId::new();
|
||||
let id = Uuid::new_v4();
|
||||
let checksum = write_atomic_at(&root, app, "c", id, b"original").unwrap();
|
||||
|
||||
// Mutate the bytes behind the repo's back.
|
||||
let path = final_path_at(&root, app, "c", id);
|
||||
std::fs::write(&path, b"tampered").unwrap();
|
||||
|
||||
let err = read_verify_at(&root, app, "c", id, &checksum).unwrap_err();
|
||||
assert!(matches!(err, FilesRepoError::Corrupted));
|
||||
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_verify_missing_bytes_is_corrupted() {
|
||||
let root = unique_tmp_root();
|
||||
let app = AppId::new();
|
||||
let id = Uuid::new_v4();
|
||||
// No write — the file never existed.
|
||||
let err = read_verify_at(&root, app, "c", id, "deadbeef").unwrap_err();
|
||||
assert!(matches!(err, FilesRepoError::Corrupted));
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_write_leaves_no_tmp_file_after_success() {
|
||||
let root = unique_tmp_root();
|
||||
let app = AppId::new();
|
||||
let id = Uuid::new_v4();
|
||||
write_atomic_at(&root, app, "c", id, b"data").unwrap();
|
||||
|
||||
let id_str = id.to_string();
|
||||
let dir = shard_dir_at(&root, app, "c", &id_str);
|
||||
let entries: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
// Exactly the final file is visible — no `*.tmp.*` orphan.
|
||||
assert_eq!(entries, vec![id_str]);
|
||||
assert!(!entries.iter().any(|n| n.contains(".tmp.")));
|
||||
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_shard_uses_first_two_chars() {
|
||||
let root = PathBuf::from("/tmp/x");
|
||||
let app = AppId::new();
|
||||
let id = Uuid::new_v4();
|
||||
let id_str = id.to_string();
|
||||
let path = final_path_at(&root, app, "col", id);
|
||||
let shard = &id_str[..2];
|
||||
assert!(path
|
||||
.to_string_lossy()
|
||||
.contains(&format!("/col/{shard}/{id_str}")));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn shard_tree_created_with_0700() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let root = unique_tmp_root();
|
||||
let app = AppId::new();
|
||||
let id = Uuid::new_v4();
|
||||
write_atomic_at(&root, app, "c", id, b"data").unwrap();
|
||||
let id_str = id.to_string();
|
||||
let dir = shard_dir_at(&root, app, "c", &id_str);
|
||||
let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o700, "shard dir should be 0o700");
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
}
|
||||
833
crates/manager-core/src/files_service.rs
Normal file
833
crates/manager-core/src/files_service.rs
Normal file
@@ -0,0 +1,833 @@
|
||||
//! `FilesServiceImpl` — wires the `FilesRepo` underneath the
|
||||
//! `picloud_shared::FilesService` trait scripts see via the Rhai
|
||||
//! bridge.
|
||||
//!
|
||||
//! Layers added here (vs the raw repo), mirroring `KvServiceImpl`:
|
||||
//! 1. Collection validation (empty + path-traversal) and field /
|
||||
//! size-cap validation at the SDK boundary.
|
||||
//! 2. **Script-as-gate authz**: when `cx.principal.is_some()` we run
|
||||
//! `authz::require(...)`; when it's `None` (public HTTP) we skip.
|
||||
//! Cross-app isolation is unaffected — every repo call is keyed by
|
||||
//! `cx.app_id`, never an argument.
|
||||
//! 3. `ServiceEvent` emission after each mutation (`create` /
|
||||
//! `update` / `delete`). The payload is the file **metadata**, not
|
||||
//! the blob bytes (files are too big for trigger payloads).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
validate_files_collection, FileMeta, FileUpdate, FilesError, FilesListPage, FilesService,
|
||||
NewFile, SdkCallCx, ServiceEvent, ServiceEventEmitter,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::files_repo::{FileUpdated, FilesRepo, FilesRepoError};
|
||||
|
||||
pub struct FilesServiceImpl {
|
||||
repo: Arc<dyn FilesRepo>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
events: Arc<dyn ServiceEventEmitter>,
|
||||
max_file_size_bytes: usize,
|
||||
}
|
||||
|
||||
impl FilesServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repo: Arc<dyn FilesRepo>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
events: Arc<dyn ServiceEventEmitter>,
|
||||
max_file_size_bytes: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
authz,
|
||||
events,
|
||||
max_file_size_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), FilesError> {
|
||||
if let Some(ref principal) = cx.principal {
|
||||
authz::require(&*self.authz, principal, Capability::AppFilesRead(cx.app_id))
|
||||
.await
|
||||
.map_err(|_| FilesError::Forbidden)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_write(&self, cx: &SdkCallCx) -> Result<(), FilesError> {
|
||||
if let Some(ref principal) = cx.principal {
|
||||
authz::require(
|
||||
&*self.authz,
|
||||
principal,
|
||||
Capability::AppFilesWrite(cx.app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| FilesError::Forbidden)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort `ServiceEvent` emission. A failed emit is logged but
|
||||
/// never rolls back the (already-durable) file write.
|
||||
async fn emit(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
op: &'static str,
|
||||
collection: &str,
|
||||
meta: &FileMeta,
|
||||
old: Option<&FileMeta>,
|
||||
) {
|
||||
let payload = serde_json::to_value(meta).ok();
|
||||
let old_payload = old.and_then(|m| serde_json::to_value(m).ok());
|
||||
if let Err(e) = self
|
||||
.events
|
||||
.emit(
|
||||
cx,
|
||||
ServiceEvent {
|
||||
source: "files",
|
||||
op,
|
||||
collection: Some(collection.to_string()),
|
||||
key: Some(meta.id.to_string()),
|
||||
payload,
|
||||
old_payload,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, source = "files", op, "event emit failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a script-supplied id. Invalid UUIDs aren't an error shape the
|
||||
/// SDK exposes — for reads/deletes they simply mean "no such file".
|
||||
fn parse_id(id: &str) -> Option<Uuid> {
|
||||
Uuid::parse_str(id).ok()
|
||||
}
|
||||
|
||||
impl From<FilesRepoError> for FilesError {
|
||||
fn from(e: FilesRepoError) -> Self {
|
||||
match e {
|
||||
FilesRepoError::Corrupted => Self::Corrupted,
|
||||
FilesRepoError::InvalidCollection(c) => Self::InvalidCollection(c),
|
||||
other => Self::Backend(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FilesService for FilesServiceImpl {
|
||||
async fn create(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
new: NewFile,
|
||||
) -> Result<Uuid, FilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
self.check_write(cx).await?;
|
||||
new.validate(self.max_file_size_bytes)?;
|
||||
let meta = self.repo.create(cx.app_id, collection, new).await?;
|
||||
self.emit(cx, "create", collection, &meta, None).await;
|
||||
Ok(meta.id)
|
||||
}
|
||||
|
||||
async fn head(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<Option<FileMeta>, FilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
self.check_read(cx).await?;
|
||||
let Some(uuid) = parse_id(id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(self.repo.head(cx.app_id, collection, uuid).await?)
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<Option<Vec<u8>>, FilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
self.check_read(cx).await?;
|
||||
let Some(uuid) = parse_id(id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(self.repo.get(cx.app_id, collection, uuid).await?)
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
upd: FileUpdate,
|
||||
) -> Result<(), FilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
self.check_write(cx).await?;
|
||||
upd.validate(self.max_file_size_bytes)?;
|
||||
let Some(uuid) = parse_id(id) else {
|
||||
return Err(FilesError::NotFound);
|
||||
};
|
||||
match self.repo.update(cx.app_id, collection, uuid, upd).await? {
|
||||
Some(FileUpdated { new, prev }) => {
|
||||
self.emit(cx, "update", collection, &new, Some(&prev)).await;
|
||||
Ok(())
|
||||
}
|
||||
None => Err(FilesError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, cx: &SdkCallCx, collection: &str, id: &str) -> Result<bool, FilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
self.check_write(cx).await?;
|
||||
let Some(uuid) = parse_id(id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
match self.repo.delete(cx.app_id, collection, uuid).await? {
|
||||
Some(meta) => {
|
||||
// On delete, the top-level metadata AND `prev` both carry
|
||||
// the deleted row (per docs/v1.1.x design + the brief).
|
||||
self.emit(cx, "delete", collection, &meta, Some(&meta))
|
||||
.await;
|
||||
Ok(true)
|
||||
}
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<FilesListPage, FilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
self.check_read(cx).await?;
|
||||
Ok(self.repo.list(cx.app_id, collection, cursor, limit).await?)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests — in-memory FilesRepo so unit tests need neither Postgres nor a
|
||||
// filesystem. The on-disk atomic-write / checksum mechanics are covered
|
||||
// by the tempdir tests in `files_repo.rs`.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, AppRole, EmitError, ExecutionId, InstanceRole, Principal, RequestId,
|
||||
ScriptId, ServiceEvent, UserId,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// In-memory FilesRepo keyed by (app, collection, id). Stores the
|
||||
/// metadata + bytes together so cross-app isolation and round-trips
|
||||
/// can be checked without disk.
|
||||
#[derive(Default)]
|
||||
struct InMemoryFilesRepo {
|
||||
#[allow(clippy::type_complexity)]
|
||||
data: Mutex<BTreeMap<(AppId, String, Uuid), (FileMeta, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut h = Sha256::new();
|
||||
h.update(bytes);
|
||||
let out = h.finalize();
|
||||
let mut s = String::new();
|
||||
for b in out {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(s, "{b:02x}");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FilesRepo for InMemoryFilesRepo {
|
||||
async fn create(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
new: NewFile,
|
||||
) -> Result<FileMeta, FilesRepoError> {
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
let meta = FileMeta {
|
||||
id,
|
||||
collection: collection.to_string(),
|
||||
name: new.name.clone(),
|
||||
content_type: new.content_type.clone(),
|
||||
size: new.data.len() as u64,
|
||||
checksum: sha256_hex(&new.data),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
self.data.lock().await.insert(
|
||||
(app_id, collection.to_string(), id),
|
||||
(meta.clone(), new.data),
|
||||
);
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
async fn head(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, FilesRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(app_id, collection.to_string(), id))
|
||||
.map(|(m, _)| m.clone()))
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<Vec<u8>>, FilesRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(app_id, collection.to_string(), id))
|
||||
.map(|(_, b)| b.clone()))
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
upd: FileUpdate,
|
||||
) -> Result<Option<FileUpdated>, FilesRepoError> {
|
||||
let mut data = self.data.lock().await;
|
||||
let key = (app_id, collection.to_string(), id);
|
||||
let Some((prev_meta, _)) = data.get(&key).cloned() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let now = Utc::now();
|
||||
let new_meta = FileMeta {
|
||||
id,
|
||||
collection: collection.to_string(),
|
||||
name: upd.name.clone().unwrap_or_else(|| prev_meta.name.clone()),
|
||||
content_type: upd
|
||||
.content_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| prev_meta.content_type.clone()),
|
||||
size: upd.data.len() as u64,
|
||||
checksum: sha256_hex(&upd.data),
|
||||
created_at: prev_meta.created_at,
|
||||
updated_at: now,
|
||||
};
|
||||
data.insert(key, (new_meta.clone(), upd.data));
|
||||
Ok(Some(FileUpdated {
|
||||
new: new_meta,
|
||||
prev: prev_meta,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, FilesRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.remove(&(app_id, collection.to_string(), id))
|
||||
.map(|(m, _)| m))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<FilesListPage, FilesRepoError> {
|
||||
let data = self.data.lock().await;
|
||||
let after = cursor.and_then(|c| Uuid::parse_str(c).ok());
|
||||
let mut metas: Vec<FileMeta> = data
|
||||
.iter()
|
||||
.filter(|((a, c, _), _)| *a == app_id && c == collection)
|
||||
.map(|(_, (m, _))| m.clone())
|
||||
.filter(|m| after.is_none_or(|a| m.id > a))
|
||||
.collect();
|
||||
metas.sort_by_key(|m| m.id);
|
||||
let take = (limit.max(1)) as usize;
|
||||
let next_cursor = if metas.len() > take {
|
||||
metas.truncate(take);
|
||||
metas.last().map(|m| m.id.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(FilesListPage {
|
||||
files: metas,
|
||||
next_cursor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Captures emitted events so tests can assert on fan-out shape.
|
||||
#[derive(Default)]
|
||||
struct CapturingEmitter {
|
||||
events: StdMutex<Vec<ServiceEvent>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ServiceEventEmitter for CapturingEmitter {
|
||||
async fn emit(&self, _cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> {
|
||||
self.events.lock().unwrap().push(event);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DenyingAuthzRepo;
|
||||
#[async_trait]
|
||||
impl AuthzRepo for DenyingAuthzRepo {
|
||||
async fn membership(
|
||||
&self,
|
||||
_user_id: UserId,
|
||||
_app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct EditorAuthzRepo;
|
||||
#[async_trait]
|
||||
impl AuthzRepo for EditorAuthzRepo {
|
||||
async fn membership(
|
||||
&self,
|
||||
_user_id: UserId,
|
||||
_app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(Some(AppRole::Editor))
|
||||
}
|
||||
}
|
||||
|
||||
fn anon_cx(app_id: AppId) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
script_id: ScriptId::new(),
|
||||
principal: None,
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: ExecutionId::new(),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn member_cx(app_id: AppId) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
principal: Some(Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Member,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}),
|
||||
..anon_cx(app_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn svc_with(authz: Arc<dyn AuthzRepo>, emitter: Arc<CapturingEmitter>) -> FilesServiceImpl {
|
||||
FilesServiceImpl::new(
|
||||
Arc::new(InMemoryFilesRepo::default()),
|
||||
authz,
|
||||
emitter,
|
||||
10 * 1024 * 1024,
|
||||
)
|
||||
}
|
||||
|
||||
fn svc() -> FilesServiceImpl {
|
||||
svc_with(
|
||||
Arc::new(DenyingAuthzRepo),
|
||||
Arc::new(CapturingEmitter::default()),
|
||||
)
|
||||
}
|
||||
|
||||
fn new_file(name: &str, data: &[u8]) -> NewFile {
|
||||
NewFile {
|
||||
name: name.to_string(),
|
||||
content_type: "application/octet-stream".to_string(),
|
||||
data: data.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_then_get_head_round_trips() {
|
||||
let files = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let id = files
|
||||
.create(&cx, "avatars", new_file("a.bin", b"hello"))
|
||||
.await
|
||||
.unwrap();
|
||||
let bytes = files.get(&cx, "avatars", &id.to_string()).await.unwrap();
|
||||
assert_eq!(bytes, Some(b"hello".to_vec()));
|
||||
let meta = files
|
||||
.head(&cx, "avatars", &id.to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(meta.name, "a.bin");
|
||||
assert_eq!(meta.size, 5);
|
||||
assert_eq!(meta.checksum, sha256_hex(b"hello"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_and_head_missing_return_none() {
|
||||
let files = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let missing = Uuid::new_v4().to_string();
|
||||
assert_eq!(files.get(&cx, "c", &missing).await.unwrap(), None);
|
||||
assert!(files.head(&cx, "c", &missing).await.unwrap().is_none());
|
||||
// Non-UUID id is also "missing", not an error.
|
||||
assert_eq!(files.get(&cx, "c", "not-a-uuid").await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_replaces_content_and_keeps_metadata_when_omitted() {
|
||||
let files = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let id = files
|
||||
.create(&cx, "c", new_file("v1.txt", b"one"))
|
||||
.await
|
||||
.unwrap();
|
||||
files
|
||||
.update(
|
||||
&cx,
|
||||
"c",
|
||||
&id.to_string(),
|
||||
FileUpdate {
|
||||
data: b"two!!".to_vec(),
|
||||
name: None,
|
||||
content_type: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let meta = files
|
||||
.head(&cx, "c", &id.to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(meta.name, "v1.txt"); // kept
|
||||
assert_eq!(meta.size, 5);
|
||||
assert_eq!(
|
||||
files.get(&cx, "c", &id.to_string()).await.unwrap(),
|
||||
Some(b"two!!".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_missing_throws_not_found() {
|
||||
let files = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let err = files
|
||||
.update(
|
||||
&cx,
|
||||
"c",
|
||||
&Uuid::new_v4().to_string(),
|
||||
FileUpdate {
|
||||
data: b"x".to_vec(),
|
||||
name: None,
|
||||
content_type: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, FilesError::NotFound));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_returns_was_present() {
|
||||
let files = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let id = files.create(&cx, "c", new_file("f", b"x")).await.unwrap();
|
||||
assert!(files.delete(&cx, "c", &id.to_string()).await.unwrap());
|
||||
assert!(!files.delete(&cx, "c", &id.to_string()).await.unwrap());
|
||||
assert!(!files.delete(&cx, "c", "not-a-uuid").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_collection_rejected() {
|
||||
let files = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let err = files
|
||||
.create(&cx, "", new_file("f", b"x"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, FilesError::InvalidCollection(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn traversal_collection_rejected() {
|
||||
let files = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
for bad in ["../etc", "a/b", "a..b", "x\0y"] {
|
||||
let err = files
|
||||
.create(&cx, bad, new_file("f", b"x"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, FilesError::InvalidCollection(_)),
|
||||
"expected reject for {bad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_required_fields_have_field_specific_messages() {
|
||||
let files = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
// name
|
||||
let err = files
|
||||
.create(
|
||||
&cx,
|
||||
"c",
|
||||
NewFile {
|
||||
name: " ".into(),
|
||||
content_type: "text/plain".into(),
|
||||
data: b"x".to_vec(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, FilesError::MissingField("name")));
|
||||
// content_type
|
||||
let err = files
|
||||
.create(
|
||||
&cx,
|
||||
"c",
|
||||
NewFile {
|
||||
name: "f".into(),
|
||||
content_type: String::new(),
|
||||
data: b"x".to_vec(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, FilesError::MissingField("content_type")));
|
||||
// Empty `data` is NO LONGER rejected (v1.1.6 relaxation) — see
|
||||
// `empty_file_round_trips`.
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_file_round_trips() {
|
||||
// v1.1.6: a zero-byte blob is a valid stored state (sentinels,
|
||||
// placeholders). Create with empty data, then read it back.
|
||||
let files = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let id = files
|
||||
.create(
|
||||
&cx,
|
||||
"c",
|
||||
NewFile {
|
||||
name: "empty.bin".into(),
|
||||
content_type: "application/octet-stream".into(),
|
||||
data: vec![],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("empty file create should succeed");
|
||||
let bytes = files.get(&cx, "c", &id.to_string()).await.unwrap();
|
||||
assert_eq!(bytes, Some(Vec::new()));
|
||||
let meta = files
|
||||
.head(&cx, "c", &id.to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("metadata present");
|
||||
assert_eq!(meta.size, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn name_and_content_type_length_caps_enforced() {
|
||||
let files = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let long_name = "x".repeat(256);
|
||||
let err = files
|
||||
.create(&cx, "c", new_file(&long_name, b"x"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, FilesError::NameTooLong(256)));
|
||||
|
||||
let err = files
|
||||
.create(
|
||||
&cx,
|
||||
"c",
|
||||
NewFile {
|
||||
name: "f".into(),
|
||||
content_type: "x".repeat(128),
|
||||
data: b"x".to_vec(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, FilesError::ContentTypeTooLong(128)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn per_file_size_cap_enforced() {
|
||||
let files = FilesServiceImpl::new(
|
||||
Arc::new(InMemoryFilesRepo::default()),
|
||||
Arc::new(DenyingAuthzRepo),
|
||||
Arc::new(CapturingEmitter::default()),
|
||||
8, // tiny cap
|
||||
);
|
||||
let cx = anon_cx(AppId::new());
|
||||
let err = files
|
||||
.create(&cx, "c", new_file("big", b"123456789"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, FilesError::TooLarge { limit: 8, .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_app_isolation() {
|
||||
let files = svc();
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
let cx_a = anon_cx(app_a);
|
||||
let cx_b = anon_cx(app_b);
|
||||
let id = files
|
||||
.create(&cx_a, "shared", new_file("f", b"from-a"))
|
||||
.await
|
||||
.unwrap();
|
||||
// app B cannot see app A's file by id.
|
||||
assert_eq!(
|
||||
files.get(&cx_b, "shared", &id.to_string()).await.unwrap(),
|
||||
None
|
||||
);
|
||||
assert!(files
|
||||
.head(&cx_b, "shared", &id.to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let page_b = files.list(&cx_b, "shared", None, 100).await.unwrap();
|
||||
assert!(page_b.files.is_empty());
|
||||
// app A still sees it.
|
||||
assert!(files
|
||||
.get(&cx_a, "shared", &id.to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anonymous_cx_skips_authz() {
|
||||
let files = svc(); // DenyingAuthzRepo
|
||||
let cx = anon_cx(AppId::new());
|
||||
// No principal → no authz check, even with a denying repo.
|
||||
files.create(&cx, "c", new_file("f", b"x")).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn member_without_role_is_forbidden() {
|
||||
let files = svc(); // DenyingAuthzRepo
|
||||
let cx = member_cx(AppId::new());
|
||||
let err = files
|
||||
.create(&cx, "c", new_file("f", b"x"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, FilesError::Forbidden));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn member_with_editor_role_allowed() {
|
||||
let files = svc_with(
|
||||
Arc::new(EditorAuthzRepo),
|
||||
Arc::new(CapturingEmitter::default()),
|
||||
);
|
||||
let cx = member_cx(AppId::new());
|
||||
files.create(&cx, "c", new_file("f", b"x")).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mutations_emit_events_with_correct_prev() {
|
||||
let emitter = Arc::new(CapturingEmitter::default());
|
||||
let files = svc_with(Arc::new(DenyingAuthzRepo), emitter.clone());
|
||||
let cx = anon_cx(AppId::new());
|
||||
|
||||
let id = files.create(&cx, "c", new_file("f", b"one")).await.unwrap();
|
||||
files
|
||||
.update(
|
||||
&cx,
|
||||
"c",
|
||||
&id.to_string(),
|
||||
FileUpdate {
|
||||
data: b"two".to_vec(),
|
||||
name: None,
|
||||
content_type: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
files.delete(&cx, "c", &id.to_string()).await.unwrap();
|
||||
|
||||
let events = emitter.events.lock().unwrap();
|
||||
assert_eq!(events.len(), 3);
|
||||
// create: prev is None
|
||||
assert_eq!(events[0].op, "create");
|
||||
assert_eq!(events[0].source, "files");
|
||||
assert!(events[0].old_payload.is_none());
|
||||
assert!(events[0].payload.is_some());
|
||||
// update: prev is the prior metadata
|
||||
assert_eq!(events[1].op, "update");
|
||||
assert!(events[1].old_payload.is_some());
|
||||
// delete: prev is the deleted metadata (payload == old_payload)
|
||||
assert_eq!(events[2].op, "delete");
|
||||
assert_eq!(events[2].payload, events[2].old_payload);
|
||||
assert!(events[2].payload.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_cursor_paginates() {
|
||||
let files = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
for i in 0..5 {
|
||||
files
|
||||
.create(&cx, "c", new_file(&format!("f{i}"), b"x"))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let p1 = files.list(&cx, "c", None, 2).await.unwrap();
|
||||
assert_eq!(p1.files.len(), 2);
|
||||
assert!(p1.next_cursor.is_some());
|
||||
let p2 = files
|
||||
.list(&cx, "c", p1.next_cursor.as_deref(), 2)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(p2.files.len(), 2);
|
||||
let p3 = files
|
||||
.list(&cx, "c", p2.next_cursor.as_deref(), 2)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(p3.files.len(), 1);
|
||||
assert!(p3.next_cursor.is_none());
|
||||
}
|
||||
}
|
||||
185
crates/manager-core/src/files_sweep.rs
Normal file
185
crates/manager-core/src/files_sweep.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
//! Orphan `*.tmp.*` blob sweeper (v1.1.6, v1.1.5 follow-up).
|
||||
//!
|
||||
//! The files repo writes blobs atomically: it streams into a
|
||||
//! `<id>.tmp.<pid>-<seq>` temp file, fsyncs, then renames to the final
|
||||
//! `<id>` path. A crash between create and rename leaves an orphan temp
|
||||
//! file that is never read and never reclaimed. This sweeper deletes
|
||||
//! those: every `PICLOUD_FILES_ORPHAN_SWEEP_INTERVAL_SEC` (default 6h) it
|
||||
//! walks `<root>/files/` and unlinks any `*.tmp.*` file older than
|
||||
//! `PICLOUD_FILES_ORPHAN_TMP_TTL_SEC` (default 1h).
|
||||
//!
|
||||
//! Deliberately bounded: it does NOT cross-check on-disk files against DB
|
||||
//! rows (the full reconciling sweeper is v1.3+). It only targets the temp
|
||||
//! files, which are unambiguously orphans once past the TTL — no live
|
||||
//! writer keeps one around for an hour.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
const ENV_INTERVAL: &str = "PICLOUD_FILES_ORPHAN_SWEEP_INTERVAL_SEC";
|
||||
const ENV_TMP_TTL: &str = "PICLOUD_FILES_ORPHAN_TMP_TTL_SEC";
|
||||
const DEFAULT_INTERVAL_SECS: u64 = 21_600; // 6h
|
||||
const DEFAULT_TMP_TTL_SECS: u64 = 3_600; // 1h
|
||||
|
||||
/// Marker that identifies a temp blob (`<id>.tmp.<pid>-<seq>`). A final
|
||||
/// blob is named just `<id>` (a UUID), so it never contains this.
|
||||
const TMP_MARKER: &str = ".tmp.";
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct SweepStats {
|
||||
pub dirs_walked: u64,
|
||||
pub files_deleted: u64,
|
||||
pub bytes_reclaimed: u64,
|
||||
}
|
||||
|
||||
/// Spawn the periodic orphan sweep. Spawned at startup alongside the
|
||||
/// cron scheduler and the realtime/cache GC tasks.
|
||||
pub fn spawn_files_orphan_sweep(files_root: PathBuf) {
|
||||
let interval = Duration::from_secs(read_secs(ENV_INTERVAL, DEFAULT_INTERVAL_SECS));
|
||||
let ttl = Duration::from_secs(read_secs(ENV_TMP_TTL, DEFAULT_TMP_TTL_SECS));
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(interval);
|
||||
ticker.tick().await; // skip the immediate first fire
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let root = files_root.clone();
|
||||
// Blocking filesystem walk off the async worker.
|
||||
let stats = tokio::task::spawn_blocking(move || sweep_orphan_tmp_files(&root, ttl))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
tracing::info!(
|
||||
dirs_walked = stats.dirs_walked,
|
||||
files_deleted = stats.files_deleted,
|
||||
bytes_reclaimed = stats.bytes_reclaimed,
|
||||
"files orphan sweep complete"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Walk `<files_root>/files/` and delete `*.tmp.*` files older than
|
||||
/// `ttl`. Missing root is not an error (returns zeroed stats). Pure +
|
||||
/// synchronous so it's unit-testable without a runtime.
|
||||
#[must_use]
|
||||
pub fn sweep_orphan_tmp_files(files_root: &Path, ttl: Duration) -> SweepStats {
|
||||
let mut stats = SweepStats::default();
|
||||
let blobs_dir = files_root.join("files");
|
||||
if !blobs_dir.is_dir() {
|
||||
return stats;
|
||||
}
|
||||
let now = SystemTime::now();
|
||||
walk(&blobs_dir, ttl, now, &mut stats);
|
||||
stats
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, ttl: Duration, now: SystemTime, stats: &mut SweepStats) {
|
||||
stats.dirs_walked += 1;
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let Ok(ft) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
let path = entry.path();
|
||||
if ft.is_dir() {
|
||||
walk(&path, ttl, now, stats);
|
||||
continue;
|
||||
}
|
||||
if !ft.is_file() {
|
||||
continue;
|
||||
}
|
||||
if !entry.file_name().to_string_lossy().contains(TMP_MARKER) {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
let age = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|m| now.duration_since(m).ok())
|
||||
.unwrap_or(Duration::ZERO);
|
||||
if age >= ttl {
|
||||
let size = meta.len();
|
||||
if std::fs::remove_file(&path).is_ok() {
|
||||
stats.files_deleted += 1;
|
||||
stats.bytes_reclaimed += size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_secs(key: &str, default: u64) -> u64 {
|
||||
match std::env::var(key) {
|
||||
Err(_) => default,
|
||||
Ok(v) => match v.parse::<u64>() {
|
||||
Ok(n) if n > 0 => n,
|
||||
_ => {
|
||||
tracing::warn!(env = key, value = %v, "invalid; using default");
|
||||
default
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn tmp_root() -> PathBuf {
|
||||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("picloud-sweep-test-{}-{n}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("files").join("ab")).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn touch(path: &Path) {
|
||||
std::fs::write(path, b"x").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletes_old_tmp_files() {
|
||||
let root = tmp_root();
|
||||
let tmp = root.join("files/ab/uuid.tmp.123-0");
|
||||
touch(&tmp);
|
||||
// ttl 0 → any tmp file counts as old.
|
||||
let stats = sweep_orphan_tmp_files(&root, Duration::ZERO);
|
||||
assert_eq!(stats.files_deleted, 1);
|
||||
assert!(!tmp.exists());
|
||||
assert!(stats.bytes_reclaimed >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_young_tmp_files() {
|
||||
let root = tmp_root();
|
||||
let tmp = root.join("files/ab/uuid.tmp.123-0");
|
||||
touch(&tmp);
|
||||
// Large TTL → the just-created file is too young to reap.
|
||||
let stats = sweep_orphan_tmp_files(&root, Duration::from_secs(3600));
|
||||
assert_eq!(stats.files_deleted, 0);
|
||||
assert!(tmp.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_non_tmp_files() {
|
||||
let root = tmp_root();
|
||||
let blob = root.join("files/ab/0123456789abcdef");
|
||||
touch(&blob);
|
||||
let stats = sweep_orphan_tmp_files(&root, Duration::ZERO);
|
||||
assert_eq!(stats.files_deleted, 0);
|
||||
assert!(blob.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_root_does_not_panic() {
|
||||
let root = std::env::temp_dir().join("picloud-sweep-nonexistent-xyz");
|
||||
let stats = sweep_orphan_tmp_files(&root, Duration::ZERO);
|
||||
assert_eq!(stats.files_deleted, 0);
|
||||
assert_eq!(stats.dirs_walked, 0);
|
||||
}
|
||||
}
|
||||
793
crates/manager-core/src/http_service.rs
Normal file
793
crates/manager-core/src/http_service.rs
Normal file
@@ -0,0 +1,793 @@
|
||||
//! `HttpServiceImpl` — reqwest-backed outbound HTTP for the v1.1.4
|
||||
//! `http::*` SDK.
|
||||
//!
|
||||
//! Mirrors the v1.1.1+ stateful-service shape (`KvServiceImpl`):
|
||||
//! script-as-gate authz (`AppHttpRequest`, skipped when
|
||||
//! `cx.principal` is `None`), with the backend talking to the network
|
||||
//! instead of Postgres. The reqwest client is built once at startup
|
||||
//! with the [`crate::ssrf::SsrfResolver`] wired in via
|
||||
//! `dns_resolver`, so the SSRF deny-list applies at every connection —
|
||||
//! including each redirect hop, since redirects are followed manually
|
||||
//! through the same client.
|
||||
//!
|
||||
//! Layering vs the raw client:
|
||||
//! 1. URL validation: scheme must be http/https; ports 22/25/465/587
|
||||
//! are blocked. (IP-level filtering is the resolver's job.)
|
||||
//! 2. Body-size caps on both request and response (stream-with-cap on
|
||||
//! the response, checking `Content-Length` first).
|
||||
//! 3. Total-request timeout (default 30s, max 60s) on top of the
|
||||
//! client's 10s connect timeout.
|
||||
//! 4. Default `User-Agent` unless the caller set one.
|
||||
//!
|
||||
//! Bodies/headers are never logged (PII): only url + status + duration
|
||||
//! at debug level.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{HttpError, HttpRequest, HttpResponse, HttpService, SdkCallCx};
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, CONTENT_TYPE, LOCATION, USER_AGENT};
|
||||
use reqwest::{Client, Method, StatusCode};
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::ssrf::{self, SsrfPolicy, SSRF_BLOCK_PREFIX};
|
||||
|
||||
/// Default per-request timeout (ms) when the script omits `timeout_ms`.
|
||||
pub const DEFAULT_TIMEOUT_MS: u32 = 30_000;
|
||||
/// Hard ceiling on the per-request timeout. Values above this are
|
||||
/// rejected by the bridge (not silently clamped).
|
||||
pub const MAX_TIMEOUT_MS: u32 = 60_000;
|
||||
/// Default redirect cap.
|
||||
pub const DEFAULT_MAX_REDIRECTS: u32 = 5;
|
||||
/// Hard ceiling on redirects.
|
||||
pub const MAX_REDIRECTS_CEILING: u32 = 10;
|
||||
/// 10 MB default body cap on both directions.
|
||||
const DEFAULT_BODY_LIMIT_BYTES: usize = 10 * 1024 * 1024;
|
||||
/// DNS + connect + TLS hard cap.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Outbound-HTTP tunables. Env-overridable following the same pattern
|
||||
/// as `TriggerConfig::from_env`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct HttpConfig {
|
||||
/// Disables the SSRF deny-list entirely. Dev/test only — the binary
|
||||
/// logs a startup warning when this is set.
|
||||
pub allow_private: bool,
|
||||
pub max_request_body_bytes: usize,
|
||||
pub max_response_body_bytes: usize,
|
||||
}
|
||||
|
||||
impl HttpConfig {
|
||||
#[must_use]
|
||||
pub const fn conservative() -> Self {
|
||||
Self {
|
||||
allow_private: false,
|
||||
max_request_body_bytes: DEFAULT_BODY_LIMIT_BYTES,
|
||||
max_response_body_bytes: DEFAULT_BODY_LIMIT_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_env() -> Self {
|
||||
let mut c = Self::conservative();
|
||||
if let Ok(v) = env::var("PICLOUD_HTTP_ALLOW_PRIVATE") {
|
||||
c.allow_private =
|
||||
matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes");
|
||||
}
|
||||
load_usize(
|
||||
&mut c.max_request_body_bytes,
|
||||
"PICLOUD_HTTP_MAX_REQUEST_BODY_BYTES",
|
||||
);
|
||||
load_usize(
|
||||
&mut c.max_response_body_bytes,
|
||||
"PICLOUD_HTTP_MAX_RESPONSE_BODY_BYTES",
|
||||
);
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HttpConfig {
|
||||
fn default() -> Self {
|
||||
Self::conservative()
|
||||
}
|
||||
}
|
||||
|
||||
fn load_usize(dst: &mut usize, key: &str) {
|
||||
if let Ok(v) = env::var(key) {
|
||||
match v.parse::<usize>() {
|
||||
Ok(n) => *dst = n,
|
||||
Err(e) => {
|
||||
tracing::warn!(env = key, error = %e, "ignoring invalid http-config value");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HttpServiceImpl {
|
||||
client: Client,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
config: HttpConfig,
|
||||
/// Same policy wired into the DNS resolver. Held here too because
|
||||
/// reqwest only routes *hostnames* through the custom resolver — a
|
||||
/// URL with a **literal IP** host bypasses it, so literal IPs are
|
||||
/// checked directly at URL-validation time.
|
||||
policy: SsrfPolicy,
|
||||
}
|
||||
|
||||
impl HttpServiceImpl {
|
||||
/// Build the service, constructing the reqwest client with the SSRF
|
||||
/// resolver. Redirects are followed manually (so per-request limits
|
||||
/// are honored and every hop re-resolves through the SSRF
|
||||
/// resolver), hence `redirect(Policy::none())`.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the reqwest client fails to build — this is a
|
||||
/// startup-time invariant, not a runtime path.
|
||||
#[must_use]
|
||||
pub fn new(config: HttpConfig, authz: Arc<dyn AuthzRepo>) -> Self {
|
||||
let policy = SsrfPolicy::new(config.allow_private);
|
||||
let client = Client::builder()
|
||||
.dns_resolver(ssrf::resolver(policy))
|
||||
.connect_timeout(CONNECT_TIMEOUT)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("build outbound http client");
|
||||
Self {
|
||||
client,
|
||||
authz,
|
||||
config,
|
||||
policy,
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_request(&self, cx: &SdkCallCx) -> Result<(), HttpError> {
|
||||
if let Some(ref principal) = cx.principal {
|
||||
authz::require(
|
||||
&*self.authz,
|
||||
principal,
|
||||
Capability::AppHttpRequest(cx.app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| HttpError::Forbidden)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HttpService for HttpServiceImpl {
|
||||
async fn request(&self, cx: &SdkCallCx, req: HttpRequest) -> Result<HttpResponse, HttpError> {
|
||||
self.check_request(cx).await?;
|
||||
|
||||
// Request body cap.
|
||||
if let Some(ref body) = req.body {
|
||||
if body.len() > self.config.max_request_body_bytes {
|
||||
return Err(HttpError::BodyTooLarge("request"));
|
||||
}
|
||||
}
|
||||
|
||||
let timeout = Duration::from_millis(u64::from(req.timeout_ms.min(MAX_TIMEOUT_MS)));
|
||||
let started = std::time::Instant::now();
|
||||
let url_for_log = req.url.clone();
|
||||
|
||||
// Whole-request budget (DNS + connect + TLS + all redirect hops
|
||||
// + body read). Connect alone is further bounded by the
|
||||
// client's CONNECT_TIMEOUT.
|
||||
let outcome = match tokio::time::timeout(timeout, self.run(req)).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err(HttpError::Timeout),
|
||||
};
|
||||
|
||||
let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
match &outcome {
|
||||
Ok(resp) => tracing::debug!(
|
||||
url = %url_for_log,
|
||||
status = resp.status,
|
||||
duration_ms,
|
||||
"outbound http"
|
||||
),
|
||||
Err(err) => tracing::debug!(
|
||||
url = %url_for_log,
|
||||
error = %err,
|
||||
duration_ms,
|
||||
"outbound http failed"
|
||||
),
|
||||
}
|
||||
outcome
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpServiceImpl {
|
||||
/// Core request path: validate, build headers, follow redirects
|
||||
/// manually, read the response body with a cap.
|
||||
async fn run(&self, req: HttpRequest) -> Result<HttpResponse, HttpError> {
|
||||
let method = Method::from_bytes(req.method.as_bytes())
|
||||
.map_err(|_| HttpError::Backend(format!("invalid method: {}", req.method)))?;
|
||||
|
||||
let mut current = url::Url::parse(&req.url)
|
||||
.map_err(|e| HttpError::InvalidUrl(format!("{}: {e}", req.url)))?;
|
||||
validate_url(¤t, self.policy)?;
|
||||
|
||||
let mut header_map = build_headers(&req, ¤t)?;
|
||||
let mut method = method;
|
||||
let mut body = req.body.clone();
|
||||
let mut redirects: u32 = 0;
|
||||
let max_redirects = req.max_redirects.min(MAX_REDIRECTS_CEILING);
|
||||
|
||||
loop {
|
||||
// Re-validate scheme/port (and literal-IP SSRF) on each hop.
|
||||
// Hostname IP filtering is the resolver's job and runs
|
||||
// automatically at connect time.
|
||||
validate_url(¤t, self.policy)?;
|
||||
|
||||
let mut rb = self.client.request(method.clone(), current.clone());
|
||||
rb = rb.headers(header_map.clone());
|
||||
if let Some(ref b) = body {
|
||||
rb = rb.body(b.clone());
|
||||
}
|
||||
let resp = rb.send().await.map_err(map_reqwest_err)?;
|
||||
let status = resp.status();
|
||||
|
||||
if req.follow_redirects && is_redirect(status) {
|
||||
if let Some(loc) = resp.headers().get(LOCATION) {
|
||||
if redirects >= max_redirects {
|
||||
return Err(HttpError::Backend(format!(
|
||||
"too many redirects (max {max_redirects})"
|
||||
)));
|
||||
}
|
||||
redirects += 1;
|
||||
let loc_str = loc.to_str().map_err(|_| {
|
||||
HttpError::Backend("redirect Location not valid UTF-8".into())
|
||||
})?;
|
||||
current = current
|
||||
.join(loc_str)
|
||||
.map_err(|e| HttpError::InvalidUrl(format!("redirect target: {e}")))?;
|
||||
|
||||
// 303 always → GET; 301/302 historically downgrade
|
||||
// POST→GET (matches browsers). 307/308 preserve.
|
||||
if matches!(status.as_u16(), 301..=303) {
|
||||
method = Method::GET;
|
||||
body = None;
|
||||
header_map.remove(CONTENT_TYPE);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return self.read_capped(resp).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_capped(&self, resp: reqwest::Response) -> Result<HttpResponse, HttpError> {
|
||||
let status = resp.status().as_u16();
|
||||
let mut headers = BTreeMap::new();
|
||||
for (name, value) in resp.headers() {
|
||||
// Header names lowercased per the documented response shape.
|
||||
headers.insert(
|
||||
name.as_str().to_ascii_lowercase(),
|
||||
value.to_str().unwrap_or("").to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let cap = self.config.max_response_body_bytes;
|
||||
if let Some(len) = resp.content_length() {
|
||||
if len > cap as u64 {
|
||||
return Err(HttpError::BodyTooLarge("response"));
|
||||
}
|
||||
}
|
||||
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let mut resp = resp;
|
||||
while let Some(chunk) = resp.chunk().await.map_err(map_reqwest_err)? {
|
||||
if buf.len() + chunk.len() > cap {
|
||||
return Err(HttpError::BodyTooLarge("response"));
|
||||
}
|
||||
buf.extend_from_slice(&chunk);
|
||||
}
|
||||
let body_raw = String::from_utf8_lossy(&buf).into_owned();
|
||||
Ok(HttpResponse {
|
||||
status,
|
||||
headers,
|
||||
body_raw,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// http/https only; block the SSH + SMTP ports; apply the SSRF policy
|
||||
/// to **literal-IP** hosts (hostnames are filtered by the DNS resolver
|
||||
/// at connect time, but literal IPs never reach the resolver).
|
||||
fn validate_url(url: &url::Url, policy: SsrfPolicy) -> Result<(), HttpError> {
|
||||
match url.scheme() {
|
||||
"http" | "https" => {}
|
||||
other => return Err(HttpError::BlockedScheme(other.to_string())),
|
||||
}
|
||||
match url.host() {
|
||||
None => return Err(HttpError::InvalidUrl("missing host".into())),
|
||||
Some(url::Host::Ipv4(ip)) => {
|
||||
policy
|
||||
.check(std::net::IpAddr::V4(ip))
|
||||
.map_err(|reason| HttpError::Ssrf(reason.to_string()))?;
|
||||
}
|
||||
Some(url::Host::Ipv6(ip)) => {
|
||||
policy
|
||||
.check(std::net::IpAddr::V6(ip))
|
||||
.map_err(|reason| HttpError::Ssrf(reason.to_string()))?;
|
||||
}
|
||||
Some(url::Host::Domain(_)) => {}
|
||||
}
|
||||
let port = url
|
||||
.port_or_known_default()
|
||||
.unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
|
||||
if matches!(port, 22 | 25 | 465 | 587) {
|
||||
return Err(HttpError::BlockedPort(port));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the request header map: merge caller headers, then apply the
|
||||
/// default `User-Agent` (unless overridden) and the bridge-chosen
|
||||
/// `Content-Type` (unless overridden).
|
||||
fn build_headers(req: &HttpRequest, _url: &url::Url) -> Result<HeaderMap, HttpError> {
|
||||
let mut map = HeaderMap::new();
|
||||
let mut has_user_agent = false;
|
||||
let mut has_content_type = false;
|
||||
for (k, v) in &req.headers {
|
||||
let name = HeaderName::from_bytes(k.as_bytes())
|
||||
.map_err(|_| HttpError::Backend(format!("invalid header name: {k}")))?;
|
||||
let value = HeaderValue::from_str(v)
|
||||
.map_err(|_| HttpError::Backend(format!("invalid header value for {k}")))?;
|
||||
if name == USER_AGENT {
|
||||
has_user_agent = true;
|
||||
}
|
||||
if name == CONTENT_TYPE {
|
||||
has_content_type = true;
|
||||
}
|
||||
map.append(name, value);
|
||||
}
|
||||
|
||||
if !has_user_agent {
|
||||
let script = req.script_id.as_deref().unwrap_or("unknown");
|
||||
let ua = format!(
|
||||
"picloud/{} (script:{})",
|
||||
picloud_shared::PRODUCT_VERSION,
|
||||
script
|
||||
);
|
||||
if let Ok(value) = HeaderValue::from_str(&ua) {
|
||||
map.insert(USER_AGENT, value);
|
||||
}
|
||||
}
|
||||
|
||||
if !has_content_type {
|
||||
if let Some(ref ct) = req.content_type {
|
||||
if let Ok(value) = HeaderValue::from_str(ct) {
|
||||
map.insert(CONTENT_TYPE, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
const fn is_redirect(status: StatusCode) -> bool {
|
||||
matches!(status.as_u16(), 301..=303 | 307 | 308)
|
||||
}
|
||||
|
||||
/// Map a reqwest error to an `HttpError`, never leaking the resolved
|
||||
/// IP. SSRF blocks are detected by scanning the error source chain for
|
||||
/// the resolver's marker prefix.
|
||||
fn map_reqwest_err(err: reqwest::Error) -> HttpError {
|
||||
if let Some(reason) = ssrf_reason(&err) {
|
||||
return HttpError::Ssrf(reason);
|
||||
}
|
||||
if err.is_timeout() {
|
||||
return HttpError::Timeout;
|
||||
}
|
||||
if err.is_connect() {
|
||||
return HttpError::Network("connection failed".into());
|
||||
}
|
||||
if err.is_request() {
|
||||
return HttpError::Network("request failed".into());
|
||||
}
|
||||
HttpError::Network("network error".into())
|
||||
}
|
||||
|
||||
/// Walk the error source chain looking for the SSRF marker the resolver
|
||||
/// embeds. Returns the category reason (no IP) when found.
|
||||
fn ssrf_reason(err: &reqwest::Error) -> Option<String> {
|
||||
let mut src: Option<&(dyn std::error::Error + 'static)> = Some(err);
|
||||
while let Some(e) = src {
|
||||
let s = e.to_string();
|
||||
if let Some(idx) = s.find(SSRF_BLOCK_PREFIX) {
|
||||
return Some(s[idx + SSRF_BLOCK_PREFIX.len()..].to_string());
|
||||
}
|
||||
src = e.source();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::authz::AuthzError;
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
|
||||
UserId,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write as _;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
struct AllowAuthz;
|
||||
#[async_trait]
|
||||
impl AuthzRepo for AllowAuthz {
|
||||
async fn membership(&self, _u: UserId, _a: AppId) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(Some(AppRole::Editor))
|
||||
}
|
||||
}
|
||||
struct DenyAuthz;
|
||||
#[async_trait]
|
||||
impl AuthzRepo for DenyAuthz {
|
||||
async fn membership(&self, _u: UserId, _a: AppId) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn dev_service(authz: Arc<dyn AuthzRepo>) -> HttpServiceImpl {
|
||||
// allow_private so the test TcpListener on 127.0.0.1 is reachable.
|
||||
let mut config = HttpConfig::conservative();
|
||||
config.allow_private = true;
|
||||
HttpServiceImpl::new(config, authz)
|
||||
}
|
||||
|
||||
fn anon_cx() -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id: AppId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
principal: None,
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: ExecutionId::new(),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn member_cx() -> SdkCallCx {
|
||||
let mut cx = anon_cx();
|
||||
cx.principal = Some(Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Member,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
});
|
||||
cx
|
||||
}
|
||||
|
||||
fn req(method: &str, url: String) -> HttpRequest {
|
||||
HttpRequest {
|
||||
method: method.into(),
|
||||
url,
|
||||
headers: BTreeMap::new(),
|
||||
body: None,
|
||||
content_type: None,
|
||||
timeout_ms: 5000,
|
||||
follow_redirects: true,
|
||||
max_redirects: 5,
|
||||
script_id: Some("test-script".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal single-shot HTTP/1.1 server. Reads the request, runs
|
||||
/// `handler` to produce the raw response bytes, writes them, closes.
|
||||
/// Returns the bound address.
|
||||
async fn spawn_server<F>(handler: F) -> SocketAddr
|
||||
where
|
||||
F: Fn(String) -> Vec<u8> + Send + Sync + 'static,
|
||||
{
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else {
|
||||
break;
|
||||
};
|
||||
let mut buf = vec![0u8; 65536];
|
||||
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||
let request = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
let response = handler(request);
|
||||
let _ = sock.write_all(&response).await;
|
||||
let _ = sock.flush().await;
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
fn ok_response(body: &str, content_type: &str) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
write!(
|
||||
v,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
.unwrap();
|
||||
v
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_round_trip() {
|
||||
let addr = spawn_server(|_req| ok_response("hello", "text/plain")).await;
|
||||
let svc = dev_service(Arc::new(AllowAuthz));
|
||||
let resp = svc
|
||||
.request(&anon_cx(), req("GET", format!("http://{addr}/")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status, 200);
|
||||
assert_eq!(resp.body_raw, "hello");
|
||||
assert_eq!(
|
||||
resp.headers.get("content-type").map(String::as_str),
|
||||
Some("text/plain")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_sends_body_and_default_user_agent() {
|
||||
let addr = spawn_server(|request| {
|
||||
// Echo back whether the body + default UA were present.
|
||||
let has_ua = request.to_lowercase().contains("user-agent: picloud/");
|
||||
let has_body = request.contains("xyzzy");
|
||||
ok_response(&format!("ua={has_ua},body={has_body}"), "text/plain")
|
||||
})
|
||||
.await;
|
||||
let svc = dev_service(Arc::new(AllowAuthz));
|
||||
let mut r = req("POST", format!("http://{addr}/"));
|
||||
r.body = Some(b"xyzzy".to_vec());
|
||||
r.content_type = Some("text/plain".into());
|
||||
let resp = svc.request(&anon_cx(), r).await.unwrap();
|
||||
assert_eq!(resp.body_raw, "ua=true,body=true");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_user_agent_overrides_default() {
|
||||
let addr = spawn_server(|request| {
|
||||
let has_custom = request.to_lowercase().contains("user-agent: my-agent");
|
||||
let has_default = request.to_lowercase().contains("picloud/");
|
||||
ok_response(
|
||||
&format!("custom={has_custom},default={has_default}"),
|
||||
"text/plain",
|
||||
)
|
||||
})
|
||||
.await;
|
||||
let svc = dev_service(Arc::new(AllowAuthz));
|
||||
let mut r = req("GET", format!("http://{addr}/"));
|
||||
r.headers.insert("User-Agent".into(), "my-agent".into());
|
||||
let resp = svc.request(&anon_cx(), r).await.unwrap();
|
||||
assert_eq!(resp.body_raw, "custom=true,default=false");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_body_response() {
|
||||
let addr = spawn_server(|_r| {
|
||||
b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec()
|
||||
})
|
||||
.await;
|
||||
let svc = dev_service(Arc::new(AllowAuthz));
|
||||
let resp = svc
|
||||
.request(&anon_cx(), req("GET", format!("http://{addr}/")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status, 204);
|
||||
assert_eq!(resp.body_raw, "");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_2xx_does_not_error() {
|
||||
let addr = spawn_server(|_r| {
|
||||
b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 3\r\nConnection: close\r\n\r\nerr".to_vec()
|
||||
})
|
||||
.await;
|
||||
let svc = dev_service(Arc::new(AllowAuthz));
|
||||
let resp = svc
|
||||
.request(&anon_cx(), req("GET", format!("http://{addr}/")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status, 500);
|
||||
assert_eq!(resp.body_raw, "err");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_over_content_length_cap_rejected() {
|
||||
let addr = spawn_server(|_r| ok_response("0123456789", "text/plain")).await;
|
||||
let mut config = HttpConfig::conservative();
|
||||
config.allow_private = true;
|
||||
config.max_response_body_bytes = 5; // body is 10 bytes
|
||||
let svc = HttpServiceImpl::new(config, Arc::new(AllowAuthz));
|
||||
let err = svc
|
||||
.request(&anon_cx(), req("GET", format!("http://{addr}/")))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, HttpError::BodyTooLarge("response")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_over_cap_without_content_length_rejected_mid_stream() {
|
||||
// No Content-Length header → must be caught while streaming.
|
||||
let addr = spawn_server(|_r| {
|
||||
b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n0123456789ABCDEF".to_vec()
|
||||
})
|
||||
.await;
|
||||
let mut config = HttpConfig::conservative();
|
||||
config.allow_private = true;
|
||||
config.max_response_body_bytes = 4;
|
||||
let svc = HttpServiceImpl::new(config, Arc::new(AllowAuthz));
|
||||
let err = svc
|
||||
.request(&anon_cx(), req("GET", format!("http://{addr}/")))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, HttpError::BodyTooLarge("response")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_body_over_cap_rejected_before_send() {
|
||||
let mut config = HttpConfig::conservative();
|
||||
config.allow_private = true;
|
||||
config.max_request_body_bytes = 3;
|
||||
let svc = HttpServiceImpl::new(config, Arc::new(AllowAuthz));
|
||||
let mut r = req("POST", "http://127.0.0.1:1/".into());
|
||||
r.body = Some(b"too long".to_vec());
|
||||
let err = svc.request(&anon_cx(), r).await.unwrap_err();
|
||||
assert!(matches!(err, HttpError::BodyTooLarge("request")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn redirect_followed_up_to_then_throws_beyond_max() {
|
||||
// Server always 302s to itself → unbounded redirect loop,
|
||||
// bounded by max_redirects.
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else {
|
||||
break;
|
||||
};
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let _ = sock.read(&mut buf).await;
|
||||
let body = format!(
|
||||
"HTTP/1.1 302 Found\r\nLocation: http://{addr}/next\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
let _ = sock.write_all(body.as_bytes()).await;
|
||||
}
|
||||
});
|
||||
let svc = dev_service(Arc::new(AllowAuthz));
|
||||
let mut r = req("GET", format!("http://{addr}/"));
|
||||
r.max_redirects = 2;
|
||||
let err = svc.request(&anon_cx(), r).await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, HttpError::Backend(ref m) if m.contains("too many redirects")),
|
||||
"expected too-many-redirects, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheme_rejected() {
|
||||
let svc = dev_service(Arc::new(AllowAuthz));
|
||||
for url in ["file:///etc/passwd", "ftp://host/x", "gopher://host/"] {
|
||||
let err = svc
|
||||
.request(&anon_cx(), req("GET", url.into()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
HttpError::BlockedScheme(s) => {
|
||||
assert!(url.starts_with(&s), "scheme {s} not in url {url}");
|
||||
}
|
||||
other => panic!("expected BlockedScheme for {url}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ports_rejected() {
|
||||
let svc = dev_service(Arc::new(AllowAuthz));
|
||||
for port in [22u16, 25, 465, 587] {
|
||||
let err = svc
|
||||
.request(
|
||||
&anon_cx(),
|
||||
req("GET", format!("http://example.com:{port}/")),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, HttpError::BlockedPort(p) if p == port),
|
||||
"port {port} should be blocked, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ssrf_blocks_loopback_without_allow_private() {
|
||||
// Default config (deny-list ON). A request to a loopback host
|
||||
// must surface as Ssrf, not a generic network error.
|
||||
let svc = HttpServiceImpl::new(HttpConfig::conservative(), Arc::new(AllowAuthz));
|
||||
let err = svc
|
||||
.request(&anon_cx(), req("GET", "http://127.0.0.1:9/".into()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
HttpError::Ssrf(reason) => {
|
||||
assert_eq!(reason, "loopback");
|
||||
assert!(!reason.contains("127.0.0.1"), "reason must not leak the IP");
|
||||
}
|
||||
other => panic!("expected Ssrf, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ssrf_blocks_hostname_resolving_to_loopback() {
|
||||
// `localhost` resolves to 127.0.0.1 / ::1 — all denied. This
|
||||
// exercises the DNS-resolver path (vs the literal-IP path) and
|
||||
// must surface as Ssrf, not a generic DNS error.
|
||||
let svc = HttpServiceImpl::new(HttpConfig::conservative(), Arc::new(AllowAuthz));
|
||||
let err = svc
|
||||
.request(&anon_cx(), req("GET", "http://localhost:9/".into()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, HttpError::Ssrf(_)),
|
||||
"expected Ssrf for localhost, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_throws() {
|
||||
// Server that accepts then never responds.
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
if let Ok((sock, _)) = listener.accept().await {
|
||||
// Hold the socket open without replying.
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
drop(sock);
|
||||
}
|
||||
});
|
||||
let svc = dev_service(Arc::new(AllowAuthz));
|
||||
let mut r = req("GET", format!("http://{addr}/"));
|
||||
r.timeout_ms = 300;
|
||||
let err = svc.request(&anon_cx(), r).await.unwrap_err();
|
||||
assert!(matches!(err, HttpError::Timeout), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anon_skips_authz_member_without_scope_forbidden() {
|
||||
let addr = spawn_server(|_r| ok_response("ok", "text/plain")).await;
|
||||
// Anonymous principal → authz skipped even with DenyAuthz.
|
||||
let svc = dev_service(Arc::new(DenyAuthz));
|
||||
let ok = svc
|
||||
.request(&anon_cx(), req("GET", format!("http://{addr}/")))
|
||||
.await;
|
||||
assert!(ok.is_ok());
|
||||
// Authenticated member with no role → Forbidden.
|
||||
let err = svc
|
||||
.request(&member_cx(), req("GET", format!("http://{addr}/")))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, HttpError::Forbidden));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn member_with_role_allowed() {
|
||||
let addr = spawn_server(|_r| ok_response("ok", "text/plain")).await;
|
||||
let svc = dev_service(Arc::new(AllowAuthz));
|
||||
let resp = svc
|
||||
.request(&member_cx(), req("GET", format!("http://{addr}/")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status, 200);
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,7 @@ mod tests {
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, NoopEventEmitter, Principal,
|
||||
RequestId, UserId,
|
||||
RequestId, ScriptId, UserId,
|
||||
};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use tokio::sync::Mutex;
|
||||
@@ -301,6 +301,7 @@ mod tests {
|
||||
fn anon_cx(app_id: AppId) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
script_id: ScriptId::new(),
|
||||
principal: None,
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: RequestId::new(),
|
||||
@@ -314,6 +315,7 @@ mod tests {
|
||||
fn owner_cx(app_id: AppId) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
script_id: ScriptId::new(),
|
||||
principal: Some(Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
@@ -332,6 +334,7 @@ mod tests {
|
||||
fn member_no_role_cx(app_id: AppId) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
script_id: ScriptId::new(),
|
||||
principal: Some(Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Member,
|
||||
|
||||
@@ -16,12 +16,14 @@ pub mod app_domain_repo;
|
||||
pub mod app_members_api;
|
||||
pub mod app_members_repo;
|
||||
pub mod app_repo;
|
||||
pub mod app_secrets_repo;
|
||||
pub mod apps_api;
|
||||
pub mod auth;
|
||||
pub mod auth_api;
|
||||
pub mod auth_bootstrap;
|
||||
pub mod auth_middleware;
|
||||
pub mod authz;
|
||||
pub mod cron_scheduler;
|
||||
pub mod dead_letter_repo;
|
||||
pub mod dead_letter_service;
|
||||
pub mod dead_letters_api;
|
||||
@@ -29,19 +31,36 @@ pub mod dispatcher;
|
||||
pub mod docs_filter;
|
||||
pub mod docs_repo;
|
||||
pub mod docs_service;
|
||||
pub mod email_inbound_api;
|
||||
pub mod email_service;
|
||||
pub mod files_api;
|
||||
pub mod files_repo;
|
||||
pub mod files_service;
|
||||
pub mod files_sweep;
|
||||
pub mod gc;
|
||||
pub mod http_service;
|
||||
pub mod kv_repo;
|
||||
pub mod kv_service;
|
||||
pub mod log_sink;
|
||||
pub mod migrations;
|
||||
pub mod module_source;
|
||||
pub mod outbox_event_emitter;
|
||||
pub mod outbox_repo;
|
||||
pub mod principal_resolver;
|
||||
pub mod pubsub_repo;
|
||||
pub mod pubsub_service;
|
||||
pub mod realtime_authority;
|
||||
pub mod repo;
|
||||
pub mod route_admin;
|
||||
pub mod route_repo;
|
||||
pub mod sandbox;
|
||||
pub mod scheduler;
|
||||
pub mod secrets_api;
|
||||
pub mod secrets_repo;
|
||||
pub mod secrets_service;
|
||||
pub mod ssrf;
|
||||
pub mod topic_repo;
|
||||
pub mod topics_api;
|
||||
pub mod trigger_config;
|
||||
pub mod trigger_repo;
|
||||
pub mod triggers_api;
|
||||
@@ -72,6 +91,9 @@ pub use app_members_repo::{
|
||||
PostgresAppMembersRepository,
|
||||
};
|
||||
pub use app_repo::{resolve_app, AppLookup, AppRepository, PostgresAppRepository};
|
||||
pub use app_secrets_repo::{
|
||||
AppSecretsRepo, AppSecretsRepoError, PostgresAppSecretsRepo, SIGNING_KEY_LEN,
|
||||
};
|
||||
pub use apps_api::{apps_router, AppsState};
|
||||
pub use auth_api::auth_router;
|
||||
pub use auth_bootstrap::{
|
||||
@@ -83,6 +105,7 @@ pub use auth_middleware::{
|
||||
API_KEY_PREFIX, API_KEY_PREFIX_LEN, SESSION_COOKIE,
|
||||
};
|
||||
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
|
||||
pub use cron_scheduler::spawn_cron_scheduler;
|
||||
pub use dead_letter_repo::{
|
||||
DeadLetterRepo, DeadLetterRepoError, DeadLetterRow, NewDeadLetter, PostgresDeadLetterRepo,
|
||||
};
|
||||
@@ -91,15 +114,29 @@ pub use dead_letters_api::{dead_letters_router, DeadLettersApiError, DeadLetters
|
||||
pub use dispatcher::{compute_backoff, Dispatcher, DispatcherError};
|
||||
pub use docs_repo::{DocsRepo, DocsRepoError, PostgresDocsRepo};
|
||||
pub use docs_service::DocsServiceImpl;
|
||||
pub use email_inbound_api::{email_inbound_router, EmailInboundError, EmailInboundState};
|
||||
pub use email_service::{
|
||||
EmailConfig, EmailServiceImpl, EmailTransport, LettreEmailTransport, SmtpConfig, SmtpTls,
|
||||
DEFAULT_EMAIL_MAX_MESSAGE_BYTES,
|
||||
};
|
||||
pub use files_api::{files_admin_router, FilesAdminState};
|
||||
pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo};
|
||||
pub use files_service::FilesServiceImpl;
|
||||
pub use files_sweep::{spawn_files_orphan_sweep, sweep_orphan_tmp_files, SweepStats};
|
||||
pub use gc::{spawn_abandoned_gc, spawn_dead_letter_gc};
|
||||
pub use http_service::{HttpConfig, HttpServiceImpl};
|
||||
pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo};
|
||||
pub use kv_service::KvServiceImpl;
|
||||
pub use log_sink::PostgresExecutionLogSink;
|
||||
pub use module_source::PostgresModuleSource;
|
||||
pub use outbox_event_emitter::OutboxEventEmitter;
|
||||
pub use outbox_repo::{
|
||||
NewOutboxRow, OutboxRepo, OutboxRepoError, OutboxRow, OutboxSourceKind, PostgresOutboxRepo,
|
||||
};
|
||||
pub use principal_resolver::{AdminPrincipalResolver, PrincipalResolver, PrincipalResolverError};
|
||||
pub use pubsub_repo::{PostgresPubsubRepo, PublishCtx, PubsubRepo, PubsubRepoError};
|
||||
pub use pubsub_service::{PubsubServiceImpl, SubscriberTokenConfig};
|
||||
pub use realtime_authority::RealtimeAuthorityImpl;
|
||||
pub use repo::{
|
||||
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
|
||||
RepoResolver, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
||||
@@ -107,10 +144,22 @@ pub use repo::{
|
||||
pub use route_admin::{compile_routes, route_admin_router, RouteAdminState};
|
||||
pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository};
|
||||
pub use sandbox::{CeilingError, SandboxCeiling};
|
||||
pub use secrets_api::{secrets_router, SecretsApiError, SecretsState};
|
||||
pub use secrets_repo::{
|
||||
PostgresSecretsRepo, SecretMeta, SecretsMetaPage, SecretsNamePage, SecretsRepo,
|
||||
SecretsRepoError, StoredSecret,
|
||||
};
|
||||
pub use secrets_service::{
|
||||
open as open_secret, seal as seal_secret, SecretsConfig, SecretsServiceImpl,
|
||||
DEFAULT_SECRET_MAX_VALUE_BYTES,
|
||||
};
|
||||
pub use topic_repo::{PostgresTopicRepo, Topic, TopicAuthMode, TopicRepo, TopicRepoError};
|
||||
pub use topics_api::{topics_router, TopicsApiError, TopicsState};
|
||||
pub use trigger_config::{BackoffShape, TriggerConfig};
|
||||
pub use trigger_repo::{
|
||||
collection_matches, CreateDeadLetterTrigger, CreateDocsTrigger, CreateKvTrigger,
|
||||
DeadLetterTriggerMatch, DocsTriggerMatch, KvTriggerMatch, PostgresTriggerRepo, Trigger,
|
||||
TriggerDetails, TriggerDispatchMode, TriggerKind, TriggerRepo, TriggerRepoError,
|
||||
collection_matches, CreateDeadLetterTrigger, CreateDocsTrigger, CreateEmailTrigger,
|
||||
CreateFilesTrigger, CreateKvTrigger, CreatePubsubTrigger, DeadLetterTriggerMatch,
|
||||
DocsTriggerMatch, EmailInboundTarget, FilesTriggerMatch, KvTriggerMatch, PostgresTriggerRepo,
|
||||
Trigger, TriggerDetails, TriggerDispatchMode, TriggerKind, TriggerRepo, TriggerRepoError,
|
||||
};
|
||||
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};
|
||||
|
||||
74
crates/manager-core/src/module_source.rs
Normal file
74
crates/manager-core/src/module_source.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
//! `PostgresModuleSource` — the Postgres-backed `ModuleSource` impl.
|
||||
//!
|
||||
//! Mirrors the structure of [`crate::kv_repo::PostgresKvRepo`] /
|
||||
//! [`crate::docs_repo::PostgresDocsRepo`]: thin wrapper around a
|
||||
//! `PgPool` that owns a single statement returning the module by
|
||||
//! `(cx.app_id, name, kind = 'module')`. The resolver lives in
|
||||
//! `executor-core` and consumes this trait through the `Services`
|
||||
//! bundle, so manager-core stays the only crate that touches
|
||||
//! Postgres.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{ModuleScript, ModuleSource, ModuleSourceError, SdkCallCx};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct PostgresModuleSource {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresModuleSource {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ModuleRow {
|
||||
id: uuid::Uuid,
|
||||
app_id: uuid::Uuid,
|
||||
name: String,
|
||||
source: String,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<ModuleRow> for ModuleScript {
|
||||
fn from(r: ModuleRow) -> Self {
|
||||
Self {
|
||||
script_id: r.id.into(),
|
||||
app_id: r.app_id.into(),
|
||||
name: r.name,
|
||||
source: r.source,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModuleSource for PostgresModuleSource {
|
||||
async fn lookup(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
// The query is the cross-app isolation boundary: app_id comes
|
||||
// from cx (never from the script-passed argument), and the
|
||||
// CHECK constraint `kind IN ('endpoint','module')` plus the
|
||||
// `kind = 'module'` filter together guarantee endpoint scripts
|
||||
// are never importable. The `(app_id, kind)` index from
|
||||
// migration 0015 makes this an index scan returning at most
|
||||
// one row (per-app uniqueness on `name`).
|
||||
let row: Option<ModuleRow> = sqlx::query_as(
|
||||
"SELECT id, app_id, name, source, updated_at \
|
||||
FROM scripts \
|
||||
WHERE app_id = $1 AND kind = 'module' AND name = $2",
|
||||
)
|
||||
.bind(cx.app_id.into_inner())
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
DocsEventOp, EmitError, KvEventOp, SdkCallCx, ServiceEvent, ServiceEventEmitter, TriggerEvent,
|
||||
DocsEventOp, EmitError, FileMeta, FilesEventOp, KvEventOp, SdkCallCx, ServiceEvent,
|
||||
ServiceEventEmitter, TriggerEvent,
|
||||
};
|
||||
|
||||
use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxSourceKind};
|
||||
@@ -43,6 +44,7 @@ impl ServiceEventEmitter for OutboxEventEmitter {
|
||||
match event.source {
|
||||
"kv" => self.emit_kv(cx, event).await,
|
||||
"docs" => self.emit_docs(cx, event).await,
|
||||
"files" => self.emit_files(cx, event).await,
|
||||
// Future sources land here. For now, silently drop — the
|
||||
// SDK calls `events.emit(...)` unconditionally for forward
|
||||
// compat, so swallowing without an error is correct.
|
||||
@@ -154,4 +156,68 @@ impl OutboxEventEmitter {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v1.1.5. Fan out a files mutation across matching files triggers.
|
||||
/// The `ServiceEvent.payload` is the file **metadata** (never the
|
||||
/// blob bytes); `old_payload` is the prior metadata (the deleted
|
||||
/// row's metadata on delete). The `TriggerEvent::Files` carries the
|
||||
/// metadata fields explicitly + `prev` for the change-data-capture
|
||||
/// surface.
|
||||
async fn emit_files(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> {
|
||||
let Some(op) = FilesEventOp::from_wire(event.op) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(collection) = event.collection.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
// The payload is the FileMeta JSON the FilesServiceImpl emitted.
|
||||
let Some(meta) = event
|
||||
.payload
|
||||
.clone()
|
||||
.and_then(|v| serde_json::from_value::<FileMeta>(v).ok())
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let matches = self
|
||||
.triggers
|
||||
.list_matching_files(cx.app_id, &collection, op)
|
||||
.await
|
||||
.map_err(|e| EmitError::Unavailable(format!("trigger lookup: {e}")))?;
|
||||
|
||||
if matches.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let trigger_event = TriggerEvent::Files {
|
||||
op,
|
||||
collection,
|
||||
id: meta.id.to_string(),
|
||||
name: meta.name,
|
||||
content_type: meta.content_type,
|
||||
size: meta.size,
|
||||
checksum: meta.checksum,
|
||||
prev: event.old_payload.clone(),
|
||||
};
|
||||
let payload = serde_json::to_value(&trigger_event)
|
||||
.map_err(|e| EmitError::Rejected(format!("event serialize: {e}")))?;
|
||||
|
||||
for m in matches {
|
||||
self.outbox
|
||||
.insert(NewOutboxRow {
|
||||
app_id: cx.app_id,
|
||||
source_kind: OutboxSourceKind::Files,
|
||||
trigger_id: Some(m.trigger_id),
|
||||
script_id: Some(m.script_id),
|
||||
reply_to: None,
|
||||
payload: payload.clone(),
|
||||
origin_principal: cx.principal.as_ref().map(|p| p.user_id),
|
||||
trigger_depth: cx.trigger_depth.saturating_add(1),
|
||||
root_execution_id: Some(cx.root_execution_id),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| EmitError::Unavailable(format!("outbox insert: {e}")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,14 @@ pub enum OutboxSourceKind {
|
||||
/// v1.1.2.
|
||||
Docs,
|
||||
DeadLetter,
|
||||
/// v1.1.4.
|
||||
Cron,
|
||||
/// v1.1.5.
|
||||
Files,
|
||||
/// v1.1.5.
|
||||
Pubsub,
|
||||
/// v1.1.7. Inbound email POSTed to the webhook receiver.
|
||||
Email,
|
||||
}
|
||||
|
||||
impl OutboxSourceKind {
|
||||
@@ -35,6 +43,10 @@ impl OutboxSourceKind {
|
||||
Self::Kv => "kv",
|
||||
Self::Docs => "docs",
|
||||
Self::DeadLetter => "dead_letter",
|
||||
Self::Cron => "cron",
|
||||
Self::Files => "files",
|
||||
Self::Pubsub => "pubsub",
|
||||
Self::Email => "email",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +57,10 @@ impl OutboxSourceKind {
|
||||
"kv" => Some(Self::Kv),
|
||||
"docs" => Some(Self::Docs),
|
||||
"dead_letter" => Some(Self::DeadLetter),
|
||||
"cron" => Some(Self::Cron),
|
||||
"files" => Some(Self::Files),
|
||||
"pubsub" => Some(Self::Pubsub),
|
||||
"email" => Some(Self::Email),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
118
crates/manager-core/src/pubsub_repo.rs
Normal file
118
crates/manager-core/src/pubsub_repo.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
//! `PubsubRepo` — publish-time fan-out for the v1.1.5 `pubsub::*` SDK.
|
||||
//!
|
||||
//! `publish_durable` writes one outbox row per matching enabled `pubsub`
|
||||
//! trigger, all inside a single transaction so a partial fan-out (some
|
||||
//! subscribers got rows, others didn't, then a crash) can't happen.
|
||||
//! Each delivery row then retries / dead-letters independently through
|
||||
//! the existing dispatcher — no pub/sub-specific dispatch branching.
|
||||
//!
|
||||
//! Topic pattern matching runs in Rust (`picloud_shared::topic_matches`)
|
||||
//! against the small set of the app's enabled pubsub triggers, keeping
|
||||
//! the SELECT trivial. v1.2 can add a topic-trie index if fan-out
|
||||
//! becomes a hot path.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{topic_matches, AdminUserId, AppId, ExecutionId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PubsubRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
/// The execution-context bits a fan-out needs to stamp onto each outbox
|
||||
/// row. Derived from the publishing script's `SdkCallCx`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PublishCtx {
|
||||
pub app_id: AppId,
|
||||
pub origin_principal: Option<AdminUserId>,
|
||||
pub trigger_depth: u32,
|
||||
pub root_execution_id: ExecutionId,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PubsubRepo: Send + Sync {
|
||||
/// Fan out a publish to every matching enabled pubsub trigger in
|
||||
/// `ctx.app_id`, inserting one outbox row each in a SINGLE
|
||||
/// transaction. `event_payload` is the serialized
|
||||
/// `TriggerEvent::Pubsub`. Returns the number of delivery rows
|
||||
/// written (0 when no trigger matched — the publish still succeeds).
|
||||
async fn fan_out_publish(
|
||||
&self,
|
||||
ctx: PublishCtx,
|
||||
topic: &str,
|
||||
event_payload: serde_json::Value,
|
||||
) -> Result<u32, PubsubRepoError>;
|
||||
}
|
||||
|
||||
pub struct PostgresPubsubRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresPubsubRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct PubsubTriggerRow {
|
||||
id: Uuid,
|
||||
script_id: Uuid,
|
||||
topic_pattern: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PubsubRepo for PostgresPubsubRepo {
|
||||
async fn fan_out_publish(
|
||||
&self,
|
||||
ctx: PublishCtx,
|
||||
topic: &str,
|
||||
event_payload: serde_json::Value,
|
||||
) -> Result<u32, PubsubRepoError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
// Load all enabled pubsub triggers for the app; filter by topic
|
||||
// pattern in Rust (keeps the query simple, honours the
|
||||
// empty/`*`/prefix semantics without teaching SQL about globs).
|
||||
let rows: Vec<PubsubTriggerRow> = sqlx::query_as(
|
||||
"SELECT t.id, t.script_id, d.topic_pattern \
|
||||
FROM triggers t \
|
||||
JOIN pubsub_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.app_id = $1 AND t.kind = 'pubsub' AND t.enabled = TRUE",
|
||||
)
|
||||
.bind(ctx.app_id.into_inner())
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let mut written: u32 = 0;
|
||||
for r in rows {
|
||||
if !topic_matches(&r.topic_pattern, topic) {
|
||||
continue;
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO outbox ( \
|
||||
app_id, source_kind, trigger_id, script_id, reply_to, \
|
||||
payload, origin_principal, trigger_depth, root_execution_id \
|
||||
) VALUES ($1, 'pubsub', $2, $3, NULL, $4, $5, $6, $7)",
|
||||
)
|
||||
.bind(ctx.app_id.into_inner())
|
||||
.bind(r.id)
|
||||
.bind(r.script_id)
|
||||
.bind(&event_payload)
|
||||
.bind(ctx.origin_principal.map(AdminUserId::into_inner))
|
||||
.bind(i32::try_from(ctx.trigger_depth.saturating_add(1)).unwrap_or(1))
|
||||
.bind(ctx.root_execution_id.into_inner())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
written += 1;
|
||||
}
|
||||
|
||||
// Commit once — all rows or none.
|
||||
tx.commit().await?;
|
||||
Ok(written)
|
||||
}
|
||||
}
|
||||
726
crates/manager-core/src/pubsub_service.rs
Normal file
726
crates/manager-core/src/pubsub_service.rs
Normal file
@@ -0,0 +1,726 @@
|
||||
//! `PubsubServiceImpl` — wires `PubsubRepo` underneath the
|
||||
//! `picloud_shared::PubsubService` trait scripts see via the Rhai
|
||||
//! bridge.
|
||||
//!
|
||||
//! Mirrors the other stateful services: script-as-gate authz
|
||||
//! (`AppPubsubPublish`, skipped when `cx.principal` is `None`), with the
|
||||
//! backend doing a publish-time outbox fan-out instead of a row write.
|
||||
//! No `ServiceEventEmitter` here — pub/sub publishes directly to the
|
||||
//! outbox; it doesn't mutate local data that other triggers observe.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::subscriber_token::{self, TokenClaims};
|
||||
use picloud_shared::{
|
||||
PubsubError, PubsubService, RealtimeBroadcaster, RealtimeEvent, SdkCallCx, TriggerEvent,
|
||||
};
|
||||
|
||||
use crate::app_secrets_repo::AppSecretsRepo;
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::pubsub_repo::{PublishCtx, PubsubRepo, PubsubRepoError};
|
||||
use crate::topic_repo::TopicRepo;
|
||||
|
||||
/// TTL bounds (seconds) for `pubsub::subscriber_token`. Env-overridable
|
||||
/// via `PICLOUD_SUBSCRIBER_TOKEN_TTL_{MIN,MAX,DEFAULT}_SEC`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SubscriberTokenConfig {
|
||||
pub min_ttl: i64,
|
||||
pub max_ttl: i64,
|
||||
pub default_ttl: i64,
|
||||
}
|
||||
|
||||
impl SubscriberTokenConfig {
|
||||
#[must_use]
|
||||
pub const fn conservative() -> Self {
|
||||
Self {
|
||||
min_ttl: 10,
|
||||
max_ttl: 86_400,
|
||||
default_ttl: 3_600,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load from env, falling back to the conservative defaults for any
|
||||
/// missing / invalid value.
|
||||
#[must_use]
|
||||
pub fn from_env() -> Self {
|
||||
let mut c = Self::conservative();
|
||||
load_i64(&mut c.min_ttl, "PICLOUD_SUBSCRIBER_TOKEN_TTL_MIN_SEC");
|
||||
load_i64(&mut c.max_ttl, "PICLOUD_SUBSCRIBER_TOKEN_TTL_MAX_SEC");
|
||||
load_i64(
|
||||
&mut c.default_ttl,
|
||||
"PICLOUD_SUBSCRIBER_TOKEN_TTL_DEFAULT_SEC",
|
||||
);
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SubscriberTokenConfig {
|
||||
fn default() -> Self {
|
||||
Self::conservative()
|
||||
}
|
||||
}
|
||||
|
||||
fn load_i64(dst: &mut i64, key: &str) {
|
||||
if let Ok(v) = std::env::var(key) {
|
||||
match v.parse::<i64>() {
|
||||
Ok(n) if n > 0 => *dst = n,
|
||||
_ => tracing::warn!(env = key, value = %v, "ignoring invalid token-ttl value"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PubsubServiceImpl {
|
||||
repo: Arc<dyn PubsubRepo>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
// Realtime extras (v1.1.6) — optional so the existing two-arg
|
||||
// constructor (and its unit tests) keep working without them. The
|
||||
// production binary attaches them via `with_realtime`.
|
||||
realtime: Option<Arc<dyn RealtimeBroadcaster>>,
|
||||
topics: Option<Arc<dyn TopicRepo>>,
|
||||
secrets: Option<Arc<dyn AppSecretsRepo>>,
|
||||
token_config: SubscriberTokenConfig,
|
||||
}
|
||||
|
||||
impl PubsubServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(repo: Arc<dyn PubsubRepo>, authz: Arc<dyn AuthzRepo>) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
authz,
|
||||
realtime: None,
|
||||
topics: None,
|
||||
secrets: None,
|
||||
token_config: SubscriberTokenConfig::conservative(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach the v1.1.6 realtime surface: the in-process broadcaster
|
||||
/// (publish fan-out to SSE subscribers), the topic registry +
|
||||
/// app-secrets repo (subscriber-token minting), and the TTL config.
|
||||
#[must_use]
|
||||
pub fn with_realtime(
|
||||
mut self,
|
||||
broadcaster: Arc<dyn RealtimeBroadcaster>,
|
||||
topics: Arc<dyn TopicRepo>,
|
||||
secrets: Arc<dyn AppSecretsRepo>,
|
||||
token_config: SubscriberTokenConfig,
|
||||
) -> Self {
|
||||
self.realtime = Some(broadcaster);
|
||||
self.topics = Some(topics);
|
||||
self.secrets = Some(secrets);
|
||||
self.token_config = token_config;
|
||||
self
|
||||
}
|
||||
|
||||
async fn check_publish(&self, cx: &SdkCallCx) -> Result<(), PubsubError> {
|
||||
if let Some(ref principal) = cx.principal {
|
||||
authz::require(
|
||||
&*self.authz,
|
||||
principal,
|
||||
Capability::AppPubsubPublish(cx.app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| PubsubError::Forbidden)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PubsubRepoError> for PubsubError {
|
||||
fn from(e: PubsubRepoError) -> Self {
|
||||
Self::Unavailable(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PubsubService for PubsubServiceImpl {
|
||||
async fn publish_durable(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
topic: &str,
|
||||
message: serde_json::Value,
|
||||
) -> Result<(), PubsubError> {
|
||||
if topic.trim().is_empty() {
|
||||
return Err(PubsubError::EmptyTopic);
|
||||
}
|
||||
self.check_publish(cx).await?;
|
||||
|
||||
// `published_at` is stamped once on the manager side so every
|
||||
// delivery path — durable triggers AND the realtime broadcast —
|
||||
// agrees on one instant. The message is cloned into the trigger
|
||||
// event so the realtime path can reuse the original.
|
||||
let published_at = chrono::Utc::now();
|
||||
let event = TriggerEvent::Pubsub {
|
||||
topic: topic.to_string(),
|
||||
message: message.clone(),
|
||||
published_at,
|
||||
};
|
||||
let payload = serde_json::to_value(&event)
|
||||
.map_err(|e| PubsubError::Rejected(format!("event serialize: {e}")))?;
|
||||
|
||||
let publish_ctx = PublishCtx {
|
||||
app_id: cx.app_id,
|
||||
origin_principal: cx.principal.as_ref().map(|p| p.user_id),
|
||||
trigger_depth: cx.trigger_depth,
|
||||
root_execution_id: cx.root_execution_id,
|
||||
};
|
||||
// Order (design notes §8): transactional outbox fan-out + commit
|
||||
// FIRST; only then the best-effort realtime broadcast. If the
|
||||
// fan-out fails, the publish throws and no broadcast happens. If
|
||||
// the broadcast fails after a committed fan-out, trigger
|
||||
// deliveries still happen and only SSE subscribers miss this
|
||||
// event (no replay in v1.1.6).
|
||||
//
|
||||
// No matching triggers → 0 rows written, publish still succeeds.
|
||||
self.repo
|
||||
.fan_out_publish(publish_ctx, topic, payload)
|
||||
.await?;
|
||||
|
||||
// Non-transactional, best-effort fan-out to in-process SSE
|
||||
// subscribers. Run on a child task so a panicking broadcaster
|
||||
// (or a future cluster-mode resolver fault) becomes a warn log,
|
||||
// never a failed publish — the durable deliveries already
|
||||
// committed above.
|
||||
if let Some(realtime) = self.realtime.clone() {
|
||||
let app_id = cx.app_id;
|
||||
let topic_owned = topic.to_string();
|
||||
let realtime_event = RealtimeEvent {
|
||||
topic: topic_owned.clone(),
|
||||
message,
|
||||
published_at,
|
||||
};
|
||||
let handle = tokio::spawn(async move {
|
||||
realtime.publish(app_id, &topic_owned, realtime_event).await;
|
||||
});
|
||||
if let Err(e) = handle.await {
|
||||
tracing::warn!(error = %e, "realtime broadcast failed; publish unaffected");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mint_subscriber_token(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
topics: Vec<String>,
|
||||
ttl_seconds: Option<i64>,
|
||||
) -> Result<String, PubsubError> {
|
||||
// Anonymous (public-HTTP) scripts can't mint — that would bypass
|
||||
// the token-minting authz boundary.
|
||||
let Some(principal) = cx.principal.as_ref() else {
|
||||
return Err(PubsubError::SubscriberToken(
|
||||
"pubsub::subscriber_token: requires an authenticated principal \
|
||||
(a script on a public route cannot mint tokens)"
|
||||
.into(),
|
||||
));
|
||||
};
|
||||
// Minting reuses the existing pub/sub publish capability (no new
|
||||
// scope — the seven-scope commitment holds).
|
||||
authz::require(
|
||||
&*self.authz,
|
||||
principal,
|
||||
Capability::AppPubsubPublish(cx.app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| PubsubError::Forbidden)?;
|
||||
|
||||
let (Some(topic_repo), Some(secrets)) = (self.topics.as_ref(), self.secrets.as_ref())
|
||||
else {
|
||||
return Err(PubsubError::Unavailable(
|
||||
"subscriber tokens are not wired in".into(),
|
||||
));
|
||||
};
|
||||
|
||||
if topics.is_empty() {
|
||||
return Err(PubsubError::SubscriberToken(
|
||||
"pubsub::subscriber_token: topics list must not be empty".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let ttl = ttl_seconds.unwrap_or(self.token_config.default_ttl);
|
||||
if ttl < self.token_config.min_ttl || ttl > self.token_config.max_ttl {
|
||||
return Err(PubsubError::SubscriberToken(format!(
|
||||
"pubsub::subscriber_token: ttl_seconds must be between {} and {}",
|
||||
self.token_config.min_ttl, self.token_config.max_ttl
|
||||
)));
|
||||
}
|
||||
|
||||
// Every requested topic must be registered as externally
|
||||
// subscribable in this app — fail fast rather than mint a token
|
||||
// that won't work.
|
||||
for name in &topics {
|
||||
let registered = topic_repo
|
||||
.get(cx.app_id, name)
|
||||
.await
|
||||
.map_err(|e| PubsubError::Unavailable(e.to_string()))?;
|
||||
if !registered.is_some_and(|t| t.external_subscribable) {
|
||||
return Err(PubsubError::SubscriberToken(format!(
|
||||
"pubsub::subscriber_token: topic {name} is not externally subscribable"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let key = secrets
|
||||
.get_or_create_signing_key(cx.app_id)
|
||||
.await
|
||||
.map_err(|e| PubsubError::Unavailable(e.to_string()))?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let claims = TokenClaims {
|
||||
app_id: cx.app_id,
|
||||
topics,
|
||||
exp: now.saturating_add(ttl),
|
||||
iat: now,
|
||||
};
|
||||
Ok(subscriber_token::sign(&key, &claims))
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests — in-memory PubsubRepo so unit tests don't need Postgres. The
|
||||
// real transactional fan-out is covered against a live DB by the
|
||||
// integration suite; the in-memory fake models the all-or-nothing
|
||||
// commit so the rollback semantics can be asserted without a DB.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
topic_matches, AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal,
|
||||
RequestId, ScriptId, UserId,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// In-memory pubsub repo. Holds a set of `(app, pattern)`
|
||||
/// subscriptions and records the outbox rows a publish would write.
|
||||
/// `fail_at` simulates a mid-fan-out INSERT failure: when set to
|
||||
/// `Some(n)`, the n-th (1-indexed) matching row errors and NOTHING
|
||||
/// is recorded — modelling the single-transaction rollback.
|
||||
struct InMemoryPubsubRepo {
|
||||
subs: Vec<(AppId, String)>,
|
||||
written: Mutex<Vec<(AppId, String)>>,
|
||||
fail_at: Option<usize>,
|
||||
}
|
||||
|
||||
impl InMemoryPubsubRepo {
|
||||
fn new(subs: Vec<(AppId, String)>) -> Self {
|
||||
Self {
|
||||
subs,
|
||||
written: Mutex::new(Vec::new()),
|
||||
fail_at: None,
|
||||
}
|
||||
}
|
||||
fn written_count(&self) -> usize {
|
||||
self.written.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PubsubRepo for InMemoryPubsubRepo {
|
||||
async fn fan_out_publish(
|
||||
&self,
|
||||
ctx: PublishCtx,
|
||||
topic: &str,
|
||||
_event_payload: serde_json::Value,
|
||||
) -> Result<u32, PubsubRepoError> {
|
||||
let matches: Vec<&(AppId, String)> = self
|
||||
.subs
|
||||
.iter()
|
||||
.filter(|(a, pat)| *a == ctx.app_id && topic_matches(pat, topic))
|
||||
.collect();
|
||||
let mut staged = Vec::new();
|
||||
for (i, _) in matches.iter().enumerate() {
|
||||
if self.fail_at == Some(i + 1) {
|
||||
// Rollback: nothing was committed.
|
||||
return Err(PubsubRepoError::Db(sqlx::Error::Protocol(
|
||||
"simulated insert failure".into(),
|
||||
)));
|
||||
}
|
||||
staged.push((ctx.app_id, topic.to_string()));
|
||||
}
|
||||
let n = staged.len();
|
||||
self.written.lock().unwrap().extend(staged);
|
||||
Ok(u32::try_from(n).unwrap_or(u32::MAX))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DenyingAuthzRepo;
|
||||
#[async_trait]
|
||||
impl AuthzRepo for DenyingAuthzRepo {
|
||||
async fn membership(
|
||||
&self,
|
||||
_user_id: UserId,
|
||||
_app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct EditorAuthzRepo;
|
||||
#[async_trait]
|
||||
impl AuthzRepo for EditorAuthzRepo {
|
||||
async fn membership(
|
||||
&self,
|
||||
_user_id: UserId,
|
||||
_app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(Some(AppRole::Editor))
|
||||
}
|
||||
}
|
||||
|
||||
fn anon_cx(app_id: AppId) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
script_id: ScriptId::new(),
|
||||
principal: None,
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: ExecutionId::new(),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn member_cx(app_id: AppId) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
principal: Some(Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Member,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}),
|
||||
..anon_cx(app_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn svc(repo: Arc<dyn PubsubRepo>, authz: Arc<dyn AuthzRepo>) -> PubsubServiceImpl {
|
||||
PubsubServiceImpl::new(repo, authz)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_writes_one_row_per_matching_trigger() {
|
||||
let app = AppId::new();
|
||||
let repo = Arc::new(InMemoryPubsubRepo::new(vec![
|
||||
(app, "user.*".into()),
|
||||
(app, "user.created".into()),
|
||||
(app, "order.*".into()), // does not match
|
||||
]));
|
||||
let files = svc(repo.clone(), Arc::new(DenyingAuthzRepo));
|
||||
files
|
||||
.publish_durable(&anon_cx(app), "user.created", serde_json::json!({"id": 1}))
|
||||
.await
|
||||
.unwrap();
|
||||
// Two of the three subscriptions match "user.created".
|
||||
assert_eq!(repo.written_count(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_matching_trigger_succeeds_silently() {
|
||||
let app = AppId::new();
|
||||
let repo = Arc::new(InMemoryPubsubRepo::new(vec![(app, "order.*".into())]));
|
||||
let svc = svc(repo.clone(), Arc::new(DenyingAuthzRepo));
|
||||
svc.publish_durable(&anon_cx(app), "user.created", serde_json::json!(1))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(repo.written_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_topic_rejected() {
|
||||
let app = AppId::new();
|
||||
let repo = Arc::new(InMemoryPubsubRepo::new(vec![]));
|
||||
let svc = svc(repo, Arc::new(DenyingAuthzRepo));
|
||||
let err = svc
|
||||
.publish_durable(&anon_cx(app), " ", serde_json::json!(1))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, PubsubError::EmptyTopic));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_app_isolation() {
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
// The only subscription belongs to app B.
|
||||
let repo = Arc::new(InMemoryPubsubRepo::new(vec![(app_b, "*".into())]));
|
||||
let svc = svc(repo.clone(), Arc::new(DenyingAuthzRepo));
|
||||
// App A publishes — app B's trigger must NOT fire.
|
||||
svc.publish_durable(&anon_cx(app_a), "user.created", serde_json::json!(1))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(repo.written_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fan_out_is_transactional_all_or_nothing() {
|
||||
let app = AppId::new();
|
||||
let mut repo = InMemoryPubsubRepo::new(vec![
|
||||
(app, "*".into()),
|
||||
(app, "user.*".into()),
|
||||
(app, "user.created".into()),
|
||||
]);
|
||||
repo.fail_at = Some(3); // fail on the 3rd matching insert
|
||||
let repo = Arc::new(repo);
|
||||
let svc = svc(repo.clone(), Arc::new(DenyingAuthzRepo));
|
||||
let err = svc
|
||||
.publish_durable(&anon_cx(app), "user.created", serde_json::json!(1))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, PubsubError::Unavailable(_)));
|
||||
// Rollback: no partial fan-out survived.
|
||||
assert_eq!(repo.written_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anonymous_cx_skips_authz() {
|
||||
let app = AppId::new();
|
||||
let repo = Arc::new(InMemoryPubsubRepo::new(vec![]));
|
||||
let svc = svc(repo, Arc::new(DenyingAuthzRepo));
|
||||
// No principal → no authz check even with a denying repo.
|
||||
svc.publish_durable(&anon_cx(app), "t", serde_json::json!(1))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn member_without_role_is_forbidden() {
|
||||
let app = AppId::new();
|
||||
let repo = Arc::new(InMemoryPubsubRepo::new(vec![]));
|
||||
let svc = svc(repo, Arc::new(DenyingAuthzRepo));
|
||||
let err = svc
|
||||
.publish_durable(&member_cx(app), "t", serde_json::json!(1))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, PubsubError::Forbidden));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn member_with_editor_role_allowed() {
|
||||
let app = AppId::new();
|
||||
let repo = Arc::new(InMemoryPubsubRepo::new(vec![]));
|
||||
let svc = svc(repo, Arc::new(EditorAuthzRepo));
|
||||
svc.publish_durable(&member_cx(app), "t", serde_json::json!(1))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// v1.1.6 realtime broadcast + subscriber-token minting
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
use crate::app_secrets_repo::{AppSecretsRepo, AppSecretsRepoError};
|
||||
use crate::topic_repo::{Topic, TopicAuthMode, TopicRepo, TopicRepoError};
|
||||
use picloud_orchestrator_core::InProcessBroadcaster;
|
||||
use picloud_shared::{RealtimeBroadcaster, RealtimeEvent};
|
||||
|
||||
/// Topic repo fake: returns the configured topics as registered +
|
||||
/// externally-subscribable (unless absent).
|
||||
struct FakeTopicRepo(Vec<String>);
|
||||
#[async_trait]
|
||||
impl TopicRepo for FakeTopicRepo {
|
||||
async fn create(
|
||||
&self,
|
||||
_: AppId,
|
||||
_: &str,
|
||||
_: bool,
|
||||
_: TopicAuthMode,
|
||||
) -> Result<Topic, TopicRepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list(&self, _: AppId) -> Result<Vec<Topic>, TopicRepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get(&self, _: AppId, name: &str) -> Result<Option<Topic>, TopicRepoError> {
|
||||
Ok(self.0.iter().any(|t| t == name).then(|| Topic {
|
||||
name: name.to_string(),
|
||||
external_subscribable: true,
|
||||
auth_mode: TopicAuthMode::Token,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
}))
|
||||
}
|
||||
async fn update(
|
||||
&self,
|
||||
_: AppId,
|
||||
_: &str,
|
||||
_: Option<bool>,
|
||||
_: Option<TopicAuthMode>,
|
||||
) -> Result<Option<Topic>, TopicRepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn delete(&self, _: AppId, _: &str) -> Result<bool, TopicRepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeSecrets;
|
||||
#[async_trait]
|
||||
impl AppSecretsRepo for FakeSecrets {
|
||||
async fn get_or_create_signing_key(
|
||||
&self,
|
||||
_: AppId,
|
||||
) -> Result<Vec<u8>, AppSecretsRepoError> {
|
||||
Ok(vec![42u8; 32])
|
||||
}
|
||||
async fn signing_key(&self, _: AppId) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
|
||||
Ok(Some(vec![42u8; 32]))
|
||||
}
|
||||
}
|
||||
|
||||
/// Broadcaster that panics on publish — proves a broadcast fault
|
||||
/// can't fail the publish.
|
||||
struct PanicBroadcaster;
|
||||
#[async_trait]
|
||||
impl RealtimeBroadcaster for PanicBroadcaster {
|
||||
async fn subscribe(
|
||||
&self,
|
||||
_: AppId,
|
||||
_: &str,
|
||||
) -> Result<tokio::sync::broadcast::Receiver<RealtimeEvent>, picloud_shared::BroadcasterError>
|
||||
{
|
||||
unimplemented!()
|
||||
}
|
||||
async fn publish(&self, _: AppId, _: &str, _: RealtimeEvent) {
|
||||
panic!("boom");
|
||||
}
|
||||
async fn drop_topic(&self, _: AppId, _: &str) {}
|
||||
}
|
||||
|
||||
fn realtime_svc(
|
||||
repo: Arc<dyn PubsubRepo>,
|
||||
broadcaster: Arc<dyn RealtimeBroadcaster>,
|
||||
topics: Vec<String>,
|
||||
) -> PubsubServiceImpl {
|
||||
PubsubServiceImpl::new(repo, Arc::new(EditorAuthzRepo)).with_realtime(
|
||||
broadcaster,
|
||||
Arc::new(FakeTopicRepo(topics)),
|
||||
Arc::new(FakeSecrets),
|
||||
SubscriberTokenConfig::conservative(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn publish_broadcasts_to_in_process_subscribers() {
|
||||
let app = AppId::new();
|
||||
let broadcaster = Arc::new(InProcessBroadcaster::new(16));
|
||||
let mut rx = broadcaster.subscribe(app, "chat").await.unwrap();
|
||||
let svc = realtime_svc(
|
||||
Arc::new(InMemoryPubsubRepo::new(vec![])),
|
||||
broadcaster,
|
||||
vec![],
|
||||
);
|
||||
svc.publish_durable(&anon_cx(app), "chat", serde_json::json!({ "hi": 1 }))
|
||||
.await
|
||||
.unwrap();
|
||||
let ev = rx.recv().await.unwrap();
|
||||
assert_eq!(ev.topic, "chat");
|
||||
assert_eq!(ev.message, serde_json::json!({ "hi": 1 }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn panicking_broadcaster_does_not_fail_publish() {
|
||||
let app = AppId::new();
|
||||
let svc = realtime_svc(
|
||||
Arc::new(InMemoryPubsubRepo::new(vec![])),
|
||||
Arc::new(PanicBroadcaster),
|
||||
vec![],
|
||||
);
|
||||
// The outbox fan-out committed; the broadcast panic is swallowed.
|
||||
svc.publish_durable(&anon_cx(app), "chat", serde_json::json!(1))
|
||||
.await
|
||||
.expect("publish must succeed despite broadcast panic");
|
||||
}
|
||||
|
||||
fn mint_svc(topics: Vec<String>) -> PubsubServiceImpl {
|
||||
realtime_svc(
|
||||
Arc::new(InMemoryPubsubRepo::new(vec![])),
|
||||
Arc::new(picloud_shared::NoopRealtimeBroadcaster),
|
||||
topics,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mint_returns_token_scoped_to_topics() {
|
||||
let app = AppId::new();
|
||||
let svc = mint_svc(vec!["chat".into(), "notify".into()]);
|
||||
let token = svc
|
||||
.mint_subscriber_token(
|
||||
&member_cx(app),
|
||||
vec!["chat".into(), "notify".into()],
|
||||
Some(120),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Verify with the fake key; claims carry the topics + expiry.
|
||||
let claims = subscriber_token::verify(&[42u8; 32], &token, chrono::Utc::now().timestamp())
|
||||
.expect("token verifies");
|
||||
assert_eq!(claims.app_id, app);
|
||||
assert!(claims.allows_topic("chat") && claims.allows_topic("notify"));
|
||||
assert!(claims.exp > claims.iat);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mint_anonymous_principal_throws() {
|
||||
let app = AppId::new();
|
||||
let svc = mint_svc(vec!["chat".into()]);
|
||||
let err = svc
|
||||
.mint_subscriber_token(&anon_cx(app), vec!["chat".into()], None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, PubsubError::SubscriberToken(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mint_empty_topics_throws() {
|
||||
let app = AppId::new();
|
||||
let svc = mint_svc(vec!["chat".into()]);
|
||||
let err = svc
|
||||
.mint_subscriber_token(&member_cx(app), vec![], None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, PubsubError::SubscriberToken(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mint_ttl_below_min_and_above_max_throw() {
|
||||
let app = AppId::new();
|
||||
let svc = mint_svc(vec!["chat".into()]);
|
||||
for bad in [Some(5), Some(90_000)] {
|
||||
let err = svc
|
||||
.mint_subscriber_token(&member_cx(app), vec!["chat".into()], bad)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, PubsubError::SubscriberToken(_)),
|
||||
"ttl {bad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mint_unregistered_topic_throws_with_message() {
|
||||
let app = AppId::new();
|
||||
// "chat" registered; "secret" is not.
|
||||
let svc = mint_svc(vec!["chat".into()]);
|
||||
let err = svc
|
||||
.mint_subscriber_token(&member_cx(app), vec!["chat".into(), "secret".into()], None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
PubsubError::SubscriberToken(msg) => {
|
||||
assert!(
|
||||
msg.contains("topic secret is not externally subscribable"),
|
||||
"got: {msg}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected SubscriberToken, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
338
crates/manager-core/src/realtime_authority.rs
Normal file
338
crates/manager-core/src/realtime_authority.rs
Normal file
@@ -0,0 +1,338 @@
|
||||
//! `RealtimeAuthorityImpl` — the DB-backed SSE subscribe gate (v1.1.6).
|
||||
//!
|
||||
//! Backs the [`picloud_shared::RealtimeAuthority`] trait the SSE handler
|
||||
//! in orchestrator-core calls. All `topics`-table reads and signing-key
|
||||
//! material stay inside this impl so the data-plane crate never touches
|
||||
//! the key.
|
||||
//!
|
||||
//! Verdict mapping (see [`SubscribeDenied`]):
|
||||
//! * topic missing OR not externally subscribable → `NotFound` (404).
|
||||
//! Both collapse to 404 so the endpoint can't probe internal topics.
|
||||
//! * `auth_mode = 'public'` → allow.
|
||||
//! * `auth_mode = 'token'` → verify the HMAC token (present, signed by
|
||||
//! this app's key, unexpired, scoped to this topic) → allow, else
|
||||
//! `Unauthorized` (401, generic — never says which check failed).
|
||||
//!
|
||||
//! Signing keys never change in v1.1.6 (no rotation API), so a small
|
||||
//! in-memory cache avoids a per-subscribe DB read once an app's key has
|
||||
//! been seen. The cache is purely an optimization — a cold miss reads
|
||||
//! the row.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{subscriber_token, AppId, RealtimeAuthority, SubscribeDenied};
|
||||
|
||||
use crate::app_secrets_repo::AppSecretsRepo;
|
||||
use crate::topic_repo::{TopicAuthMode, TopicRepo};
|
||||
|
||||
pub struct RealtimeAuthorityImpl {
|
||||
topics: Arc<dyn TopicRepo>,
|
||||
secrets: Arc<dyn AppSecretsRepo>,
|
||||
key_cache: Mutex<HashMap<AppId, Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl RealtimeAuthorityImpl {
|
||||
#[must_use]
|
||||
pub fn new(topics: Arc<dyn TopicRepo>, secrets: Arc<dyn AppSecretsRepo>) -> Self {
|
||||
Self {
|
||||
topics,
|
||||
secrets,
|
||||
key_cache: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the app's signing key, consulting the cache first. Returns
|
||||
/// `None` when the app has no key (no token ever minted) — which the
|
||||
/// caller maps to `Unauthorized`.
|
||||
async fn signing_key(&self, app_id: AppId) -> Result<Option<Vec<u8>>, SubscribeDenied> {
|
||||
if let Ok(cache) = self.key_cache.lock() {
|
||||
if let Some(k) = cache.get(&app_id) {
|
||||
return Ok(Some(k.clone()));
|
||||
}
|
||||
}
|
||||
let key = self
|
||||
.secrets
|
||||
.signing_key(app_id)
|
||||
.await
|
||||
.map_err(|e| SubscribeDenied::Backend(e.to_string()))?;
|
||||
if let Some(ref k) = key {
|
||||
if let Ok(mut cache) = self.key_cache.lock() {
|
||||
cache.insert(app_id, k.clone());
|
||||
}
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RealtimeAuthority for RealtimeAuthorityImpl {
|
||||
async fn authorize_subscribe(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
topic: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<(), SubscribeDenied> {
|
||||
let registered = self
|
||||
.topics
|
||||
.get(app_id, topic)
|
||||
.await
|
||||
.map_err(|e| SubscribeDenied::Backend(e.to_string()))?;
|
||||
|
||||
// Missing topic AND internal-only topic both 404 — don't leak
|
||||
// which internal topics exist.
|
||||
let Some(t) = registered.filter(|t| t.external_subscribable) else {
|
||||
return Err(SubscribeDenied::NotFound);
|
||||
};
|
||||
|
||||
match t.auth_mode {
|
||||
TopicAuthMode::Public => Ok(()),
|
||||
TopicAuthMode::Token => {
|
||||
let token = token.ok_or(SubscribeDenied::Unauthorized)?;
|
||||
let key = self
|
||||
.signing_key(app_id)
|
||||
.await?
|
||||
.ok_or(SubscribeDenied::Unauthorized)?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let claims = subscriber_token::verify(&key, token, now)
|
||||
.map_err(|_| SubscribeDenied::Unauthorized)?;
|
||||
// Per-app key already makes a cross-app token fail the
|
||||
// signature check; this is belt-and-suspenders.
|
||||
if claims.app_id != app_id || !claims.allows_topic(topic) {
|
||||
return Err(SubscribeDenied::Unauthorized);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_secrets_repo::AppSecretsRepoError;
|
||||
use crate::topic_repo::{Topic, TopicRepoError};
|
||||
use chrono::Utc;
|
||||
use picloud_shared::subscriber_token::{sign, TokenClaims};
|
||||
|
||||
struct FakeTopics(Vec<(AppId, Topic)>);
|
||||
#[async_trait]
|
||||
impl TopicRepo for FakeTopics {
|
||||
async fn create(
|
||||
&self,
|
||||
_: AppId,
|
||||
_: &str,
|
||||
_: bool,
|
||||
_: TopicAuthMode,
|
||||
) -> Result<Topic, TopicRepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list(&self, _: AppId) -> Result<Vec<Topic>, TopicRepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get(&self, app_id: AppId, name: &str) -> Result<Option<Topic>, TopicRepoError> {
|
||||
Ok(self
|
||||
.0
|
||||
.iter()
|
||||
.find(|(a, t)| *a == app_id && t.name == name)
|
||||
.map(|(_, t)| t.clone()))
|
||||
}
|
||||
async fn update(
|
||||
&self,
|
||||
_: AppId,
|
||||
_: &str,
|
||||
_: Option<bool>,
|
||||
_: Option<TopicAuthMode>,
|
||||
) -> Result<Option<Topic>, TopicRepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn delete(&self, _: AppId, _: &str) -> Result<bool, TopicRepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeSecrets(AppId, Vec<u8>);
|
||||
#[async_trait]
|
||||
impl AppSecretsRepo for FakeSecrets {
|
||||
async fn get_or_create_signing_key(
|
||||
&self,
|
||||
_: AppId,
|
||||
) -> Result<Vec<u8>, AppSecretsRepoError> {
|
||||
Ok(self.1.clone())
|
||||
}
|
||||
async fn signing_key(&self, app_id: AppId) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
|
||||
Ok((app_id == self.0).then(|| self.1.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
fn topic(name: &str, external: bool, mode: TopicAuthMode) -> Topic {
|
||||
Topic {
|
||||
name: name.to_string(),
|
||||
external_subscribable: external,
|
||||
auth_mode: mode,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn authority(
|
||||
topics: Vec<(AppId, Topic)>,
|
||||
key_app: AppId,
|
||||
key: Vec<u8>,
|
||||
) -> RealtimeAuthorityImpl {
|
||||
RealtimeAuthorityImpl::new(
|
||||
Arc::new(FakeTopics(topics)),
|
||||
Arc::new(FakeSecrets(key_app, key)),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_topic_is_not_found() {
|
||||
let app = AppId::new();
|
||||
let a = authority(vec![], app, vec![0u8; 32]);
|
||||
assert_eq!(
|
||||
a.authorize_subscribe(app, "ghost", None).await,
|
||||
Err(SubscribeDenied::NotFound)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn internal_only_topic_is_not_found() {
|
||||
let app = AppId::new();
|
||||
let a = authority(
|
||||
vec![(app, topic("internal", false, TopicAuthMode::Public))],
|
||||
app,
|
||||
vec![0u8; 32],
|
||||
);
|
||||
assert_eq!(
|
||||
a.authorize_subscribe(app, "internal", None).await,
|
||||
Err(SubscribeDenied::NotFound)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_topic_allows_without_token() {
|
||||
let app = AppId::new();
|
||||
let a = authority(
|
||||
vec![(app, topic("news", true, TopicAuthMode::Public))],
|
||||
app,
|
||||
vec![0u8; 32],
|
||||
);
|
||||
assert!(a.authorize_subscribe(app, "news", None).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_topic_without_token_is_unauthorized() {
|
||||
let app = AppId::new();
|
||||
let a = authority(
|
||||
vec![(app, topic("chat", true, TopicAuthMode::Token))],
|
||||
app,
|
||||
vec![7u8; 32],
|
||||
);
|
||||
assert_eq!(
|
||||
a.authorize_subscribe(app, "chat", None).await,
|
||||
Err(SubscribeDenied::Unauthorized)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_topic_with_valid_token_allows() {
|
||||
let app = AppId::new();
|
||||
let key = vec![9u8; 32];
|
||||
let a = authority(
|
||||
vec![(app, topic("chat", true, TopicAuthMode::Token))],
|
||||
app,
|
||||
key.clone(),
|
||||
);
|
||||
let token = sign(
|
||||
&key,
|
||||
&TokenClaims {
|
||||
app_id: app,
|
||||
topics: vec!["chat".into()],
|
||||
iat: Utc::now().timestamp(),
|
||||
exp: Utc::now().timestamp() + 60,
|
||||
},
|
||||
);
|
||||
assert!(a
|
||||
.authorize_subscribe(app, "chat", Some(&token))
|
||||
.await
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_for_other_topic_is_unauthorized() {
|
||||
let app = AppId::new();
|
||||
let key = vec![9u8; 32];
|
||||
let a = authority(
|
||||
vec![(app, topic("chat", true, TopicAuthMode::Token))],
|
||||
app,
|
||||
key.clone(),
|
||||
);
|
||||
let token = sign(
|
||||
&key,
|
||||
&TokenClaims {
|
||||
app_id: app,
|
||||
topics: vec!["other".into()],
|
||||
iat: Utc::now().timestamp(),
|
||||
exp: Utc::now().timestamp() + 60,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
a.authorize_subscribe(app, "chat", Some(&token)).await,
|
||||
Err(SubscribeDenied::Unauthorized)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_token_is_unauthorized() {
|
||||
let app = AppId::new();
|
||||
let key = vec![9u8; 32];
|
||||
let a = authority(
|
||||
vec![(app, topic("chat", true, TopicAuthMode::Token))],
|
||||
app,
|
||||
key.clone(),
|
||||
);
|
||||
let token = sign(
|
||||
&key,
|
||||
&TokenClaims {
|
||||
app_id: app,
|
||||
topics: vec!["chat".into()],
|
||||
iat: Utc::now().timestamp() - 120,
|
||||
exp: Utc::now().timestamp() - 60,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
a.authorize_subscribe(app, "chat", Some(&token)).await,
|
||||
Err(SubscribeDenied::Unauthorized)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_signed_by_other_app_key_is_unauthorized() {
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
let key_a = vec![1u8; 32];
|
||||
let key_b = vec![2u8; 32];
|
||||
// Authority for app B; its key is key_b.
|
||||
let a = authority(
|
||||
vec![(app_b, topic("chat", true, TopicAuthMode::Token))],
|
||||
app_b,
|
||||
key_b,
|
||||
);
|
||||
// Token signed by app A's key, claiming app A.
|
||||
let token = sign(
|
||||
&key_a,
|
||||
&TokenClaims {
|
||||
app_id: app_a,
|
||||
topics: vec!["chat".into()],
|
||||
iat: Utc::now().timestamp(),
|
||||
exp: Utc::now().timestamp() + 60,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
a.authorize_subscribe(app_b, "chat", Some(&token)).await,
|
||||
Err(SubscribeDenied::Unauthorized)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@ use std::collections::BTreeMap;
|
||||
use async_trait::async_trait;
|
||||
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
|
||||
AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptKind,
|
||||
ScriptSandbox,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
@@ -42,6 +43,27 @@ pub trait ScriptRepository: Send + Sync {
|
||||
patch: ScriptPatch,
|
||||
) -> Result<Script, ScriptRepositoryError>;
|
||||
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError>;
|
||||
|
||||
/// v1.1.3: how many routes reference this script. Used by the
|
||||
/// API layer to refuse `endpoint → module` kind changes when the
|
||||
/// script is still bound to user-facing entry points.
|
||||
async fn count_routes_for_script(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
) -> Result<i64, ScriptRepositoryError>;
|
||||
|
||||
/// v1.1.3: how many triggers (kv / docs / dead-letter) target
|
||||
/// this script. Same purpose as `count_routes_for_script`.
|
||||
async fn count_triggers_for_script(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
) -> Result<i64, ScriptRepositoryError>;
|
||||
|
||||
/// v1.1.3: list module dependencies of this script — the rows in
|
||||
/// `script_imports` where `importer_script_id = script_id`. Used
|
||||
/// by tests and (eventually) a dashboard "Imports" panel.
|
||||
async fn list_imports(&self, script_id: ScriptId)
|
||||
-> Result<Vec<Script>, ScriptRepositoryError>;
|
||||
}
|
||||
|
||||
/// Inbound shape for create. Defaults match the migration's CHECK
|
||||
@@ -52,11 +74,19 @@ pub struct NewScript {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub source: String,
|
||||
/// Defaults to `Endpoint` if absent. `Module` scripts cannot be
|
||||
/// bound to routes or used as trigger targets.
|
||||
pub kind: ScriptKind,
|
||||
pub timeout_seconds: Option<i32>,
|
||||
pub memory_limit_mb: Option<i32>,
|
||||
/// Sandbox overrides; `None` means store an empty object (use
|
||||
/// platform defaults at exec time).
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
/// v1.1.3: literal-path `import "<name>"` declarations extracted
|
||||
/// from the source. The repo writes these into `script_imports`
|
||||
/// transactionally with the script row. Empty when validation
|
||||
/// found no imports (the common case for endpoints today).
|
||||
pub imports: Vec<String>,
|
||||
}
|
||||
|
||||
/// Inbound shape for update. `None` fields are left untouched.
|
||||
@@ -70,6 +100,15 @@ pub struct ScriptPatch {
|
||||
/// `Some(sandbox)` replaces the stored overrides wholesale (including
|
||||
/// `Some(empty)` to clear them); `None` leaves them untouched.
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
/// `Some(new_kind)` changes the script's role; the API layer
|
||||
/// rejects unsafe transitions (e.g. endpoint→module when routes
|
||||
/// or triggers reference the script).
|
||||
pub kind: Option<ScriptKind>,
|
||||
/// v1.1.3: when `source` is also `Some`, the repo replaces the
|
||||
/// `script_imports` edges for this script with these names.
|
||||
/// `None` keeps the existing edges untouched (a name/description
|
||||
/// edit alone shouldn't touch the dep graph).
|
||||
pub imports: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub struct PostgresScriptRepository {
|
||||
@@ -88,14 +127,18 @@ impl PostgresScriptRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Columns selected from `scripts` everywhere — kept in one constant so
|
||||
/// adding `kind` (v1.1.3) and future columns can't accidentally skip
|
||||
/// one query.
|
||||
const SCRIPT_SELECT_COLS: &str = "id, app_id, name, description, version, source, kind, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at";
|
||||
|
||||
#[async_trait]
|
||||
impl ScriptRepository for PostgresScriptRepository {
|
||||
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, ScriptRow>(
|
||||
"SELECT id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||
FROM scripts WHERE id = $1",
|
||||
)
|
||||
let row = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"SELECT {SCRIPT_SELECT_COLS} FROM scripts WHERE id = $1"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
@@ -103,22 +146,18 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
}
|
||||
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||
"SELECT id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||
FROM scripts ORDER BY name",
|
||||
)
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"SELECT {SCRIPT_SELECT_COLS} FROM scripts ORDER BY name"
|
||||
))
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||
"SELECT id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||
FROM scripts WHERE app_id = $1 ORDER BY name",
|
||||
)
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"SELECT {SCRIPT_SELECT_COLS} FROM scripts WHERE app_id = $1 ORDER BY name"
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
@@ -129,14 +168,17 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||
"SELECT s.id, s.app_id, s.name, s.description, s.version, s.source, \
|
||||
s.timeout_seconds, s.memory_limit_mb, s.sandbox, s.created_at, s.updated_at \
|
||||
FROM scripts s \
|
||||
let cols = SCRIPT_SELECT_COLS
|
||||
.split(", ")
|
||||
.map(|c| format!("s.{c}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"SELECT {cols} FROM scripts s \
|
||||
JOIN app_members m ON m.app_id = s.app_id \
|
||||
WHERE m.user_id = $1 \
|
||||
ORDER BY s.name",
|
||||
)
|
||||
ORDER BY s.name"
|
||||
))
|
||||
.bind(user_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
@@ -146,34 +188,42 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
||||
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let res = sqlx::query_as::<_, ScriptRow>(
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"INSERT INTO scripts ( \
|
||||
app_id, name, description, source, \
|
||||
app_id, name, description, source, kind, \
|
||||
timeout_seconds, memory_limit_mb, sandbox \
|
||||
) VALUES ($1, $2, $3, $4, COALESCE($5, 30), COALESCE($6, 256), $7) \
|
||||
RETURNING id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||
)
|
||||
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8) \
|
||||
RETURNING {SCRIPT_SELECT_COLS}"
|
||||
))
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(&input.name)
|
||||
.bind(input.description.as_deref())
|
||||
.bind(&input.source)
|
||||
.bind(input.kind.as_str())
|
||||
.bind(input.timeout_seconds)
|
||||
.bind(input.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.fetch_one(&self.pool)
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
let script: Script = match res {
|
||||
Ok(row) => row.into(),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
Err(ScriptRepositoryError::Conflict(format!(
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"a script named {:?} already exists in this app",
|
||||
input.name
|
||||
)))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
// Dep-graph: write any literal-path imports declared in the
|
||||
// source. Unresolved names (the referenced module doesn't
|
||||
// exist yet) are silently skipped — best-effort.
|
||||
replace_imports_tx(&mut tx, script.id, script.app_id, &input.imports).await?;
|
||||
tx.commit().await?;
|
||||
Ok(script)
|
||||
}
|
||||
|
||||
async fn update(
|
||||
@@ -192,7 +242,8 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
.sandbox
|
||||
.as_ref()
|
||||
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
||||
let res = sqlx::query_as::<_, ScriptRow>(
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"UPDATE scripts SET \
|
||||
name = COALESCE($2, name), \
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
@@ -200,12 +251,12 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
timeout_seconds = COALESCE($6, timeout_seconds), \
|
||||
memory_limit_mb = COALESCE($7, memory_limit_mb), \
|
||||
sandbox = COALESCE($8, sandbox), \
|
||||
kind = COALESCE($9, kind), \
|
||||
version = version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||
)
|
||||
RETURNING {SCRIPT_SELECT_COLS}"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.bind(patch.name.as_deref())
|
||||
.bind(patch.description.is_some())
|
||||
@@ -214,19 +265,30 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
.bind(patch.timeout_seconds)
|
||||
.bind(patch.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.fetch_optional(&self.pool)
|
||||
.bind(patch.kind.map(ScriptKind::as_str))
|
||||
.fetch_optional(&mut *tx)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(Some(row)) => Ok(row.into()),
|
||||
Ok(None) => Err(ScriptRepositoryError::NotFound(id)),
|
||||
let script: Script = match res {
|
||||
Ok(Some(row)) => row.into(),
|
||||
Ok(None) => return Err(ScriptRepositoryError::NotFound(id)),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
Err(ScriptRepositoryError::Conflict(
|
||||
return Err(ScriptRepositoryError::Conflict(
|
||||
"a script with that name already exists in this app".into(),
|
||||
))
|
||||
));
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
// Replace imports only when the caller has a fresh list (i.e.
|
||||
// the source actually changed and the validator re-extracted
|
||||
// imports). A name-only or description-only edit leaves the
|
||||
// dep graph alone.
|
||||
if let Some(imports) = patch.imports.as_deref() {
|
||||
replace_imports_tx(&mut tx, script.id, script.app_id, imports).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(script)
|
||||
}
|
||||
|
||||
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError> {
|
||||
@@ -239,6 +301,85 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count_routes_for_script(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
) -> Result<i64, ScriptRepositoryError> {
|
||||
let n: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM routes WHERE script_id = $1")
|
||||
.bind(script_id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(n.0)
|
||||
}
|
||||
|
||||
async fn count_triggers_for_script(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
) -> Result<i64, ScriptRepositoryError> {
|
||||
let n: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM triggers WHERE script_id = $1")
|
||||
.bind(script_id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(n.0)
|
||||
}
|
||||
|
||||
async fn list_imports(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
let cols = SCRIPT_SELECT_COLS
|
||||
.split(", ")
|
||||
.map(|c| format!("s.{c}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"SELECT {cols} FROM scripts s \
|
||||
JOIN script_imports i ON i.imported_script_id = s.id \
|
||||
WHERE i.importer_script_id = $1 \
|
||||
ORDER BY s.name"
|
||||
))
|
||||
.bind(script_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the `script_imports` edges for `importer` with rows derived
|
||||
/// from `import_names`. Names that don't resolve to a `kind = 'module'`
|
||||
/// script in the same app are silently skipped (best-effort dep graph).
|
||||
async fn replace_imports_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
importer: ScriptId,
|
||||
app_id: AppId,
|
||||
import_names: &[String],
|
||||
) -> Result<(), ScriptRepositoryError> {
|
||||
sqlx::query("DELETE FROM script_imports WHERE importer_script_id = $1")
|
||||
.bind(importer.into_inner())
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
if import_names.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// Insert with ON CONFLICT DO NOTHING in case the source declares
|
||||
// `import "x"` twice — the dep graph stores each pair at most once.
|
||||
sqlx::query(
|
||||
"INSERT INTO script_imports (app_id, importer_script_id, imported_script_id) \
|
||||
SELECT $1, $2, s.id \
|
||||
FROM scripts s \
|
||||
WHERE s.app_id = $1 \
|
||||
AND s.kind = 'module' \
|
||||
AND s.id <> $2 \
|
||||
AND s.name = ANY($3) \
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(importer.into_inner())
|
||||
.bind(import_names)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Row shape mirroring the `scripts` table for sqlx FromRow.
|
||||
@@ -250,6 +391,10 @@ struct ScriptRow {
|
||||
description: Option<String>,
|
||||
version: i32,
|
||||
source: String,
|
||||
/// v1.1.3: 'endpoint' | 'module'. Stored as TEXT with a CHECK
|
||||
/// constraint so we don't need a Postgres enum (avoiding the
|
||||
/// migration churn of adding values later).
|
||||
kind: String,
|
||||
timeout_seconds: i32,
|
||||
memory_limit_mb: i32,
|
||||
sandbox: serde_json::Value,
|
||||
@@ -264,6 +409,10 @@ impl From<ScriptRow> for Script {
|
||||
// fall back to an empty ScriptSandbox rather than poisoning a
|
||||
// list response.
|
||||
let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default();
|
||||
// Defensive: if a row's `kind` somehow falls outside the CHECK
|
||||
// constraint, treat it as Endpoint (the safe default — won't
|
||||
// grant a row import-target status it doesn't have).
|
||||
let kind = ScriptKind::parse_str(&r.kind).unwrap_or(ScriptKind::Endpoint);
|
||||
Self {
|
||||
id: r.id.into(),
|
||||
app_id: r.app_id.into(),
|
||||
@@ -271,6 +420,7 @@ impl From<ScriptRow> for Script {
|
||||
description: r.description,
|
||||
version: r.version,
|
||||
source: r.source,
|
||||
kind,
|
||||
timeout_seconds: u32::try_from(r.timeout_seconds).unwrap_or(30),
|
||||
memory_limit_mb: u32::try_from(r.memory_limit_mb).unwrap_or(256),
|
||||
sandbox,
|
||||
|
||||
@@ -184,6 +184,17 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// v1.1.3: module scripts have no executable entry point — they're
|
||||
// libraries imported by other scripts. Reject route bindings here
|
||||
// before we touch the routes table.
|
||||
if script.kind == picloud_shared::ScriptKind::Module {
|
||||
return Err(RouteApiError::BadRequest(format!(
|
||||
"script {script_id} has kind=module; modules are imported, \
|
||||
not bound to routes — switch the script to kind=endpoint \
|
||||
or attach this route to a different script"
|
||||
)));
|
||||
}
|
||||
|
||||
// Validate the route's host is consistent with one of the app's
|
||||
// domain claims. `HostKind::Any` is always permitted (catches every
|
||||
// host the app already owns). Specific hosts must match a claim.
|
||||
|
||||
232
crates/manager-core/src/secrets_api.rs
Normal file
232
crates/manager-core/src/secrets_api.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
//! `/api/v1/admin/apps/{id}/secrets*` — secrets admin endpoints
|
||||
//! (v1.1.7).
|
||||
//!
|
||||
//! * `GET /apps/{id}/secrets` — list names + updated_at
|
||||
//! (NEVER values).
|
||||
//! * `POST /apps/{id}/secrets` — set/overwrite a secret.
|
||||
//! * `DELETE /apps/{id}/secrets/{name}` — delete a secret.
|
||||
//!
|
||||
//! Set/delete are gated by `AppSecretsWrite` (→ `script:write`); list by
|
||||
//! `AppSecretsRead` (→ `script:read`). The list surface deliberately
|
||||
//! returns only names + timestamps — the dashboard never receives
|
||||
//! plaintext. Values are encrypted with the process master key before
|
||||
//! they touch the database (same envelope as the script `secrets::set`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{validate_secret_name, AppId, MasterKey, Principal, SecretsError};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::secrets_repo::{SecretsRepo, SecretsRepoError};
|
||||
use crate::secrets_service::seal;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SecretsState {
|
||||
pub repo: Arc<dyn SecretsRepo>,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
pub master_key: MasterKey,
|
||||
pub max_value_bytes: usize,
|
||||
}
|
||||
|
||||
pub fn secrets_router(state: SecretsState) -> Router {
|
||||
Router::new()
|
||||
.route("/apps/{app_id}/secrets", get(list_secrets).post(set_secret))
|
||||
.route(
|
||||
"/apps/{app_id}/secrets/{name}",
|
||||
axum::routing::delete(delete_secret),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListQuery {
|
||||
#[serde(default)]
|
||||
pub cursor: Option<String>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct SecretItem {
|
||||
name: String,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct ListSecretsResponse {
|
||||
secrets: Vec<SecretItem>,
|
||||
next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_secrets(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(app_id): Path<AppId>,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> Result<Json<ListSecretsResponse>, SecretsApiError> {
|
||||
ensure_app_exists(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppSecretsRead(app_id),
|
||||
)
|
||||
.await?;
|
||||
let page = s
|
||||
.repo
|
||||
.list_meta(app_id, q.cursor.as_deref(), q.limit.unwrap_or(0))
|
||||
.await?;
|
||||
Ok(Json(ListSecretsResponse {
|
||||
secrets: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|m| SecretItem {
|
||||
name: m.name,
|
||||
updated_at: m.updated_at,
|
||||
})
|
||||
.collect(),
|
||||
next_cursor: page.next_cursor,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetSecretRequest {
|
||||
pub name: String,
|
||||
/// Any JSON value — the dashboard sends a single-line string, but
|
||||
/// maps/arrays/numbers round-trip too (matching `secrets::set`).
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
async fn set_secret(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(app_id): Path<AppId>,
|
||||
Json(input): Json<SetSecretRequest>,
|
||||
) -> Result<StatusCode, SecretsApiError> {
|
||||
ensure_app_exists(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppSecretsWrite(app_id),
|
||||
)
|
||||
.await?;
|
||||
validate_secret_name(&input.name)?;
|
||||
let (ciphertext, nonce) = seal(&s.master_key, &input.value, s.max_value_bytes)?;
|
||||
s.repo.set(app_id, &input.name, &ciphertext, &nonce).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn delete_secret(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((app_id, name)): Path<(AppId, String)>,
|
||||
) -> Result<StatusCode, SecretsApiError> {
|
||||
ensure_app_exists(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppSecretsWrite(app_id),
|
||||
)
|
||||
.await?;
|
||||
if !s.repo.delete(app_id, &name).await? {
|
||||
return Err(SecretsApiError::NotFound);
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn ensure_app_exists(apps: &dyn AppRepository, app_id: AppId) -> Result<(), SecretsApiError> {
|
||||
apps.get_by_id(app_id)
|
||||
.await
|
||||
.map_err(|e| SecretsApiError::Backend(e.to_string()))?
|
||||
.ok_or(SecretsApiError::AppNotFound)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SecretsApiError {
|
||||
#[error("app not found")]
|
||||
AppNotFound,
|
||||
#[error("secret not found")]
|
||||
NotFound,
|
||||
#[error("invalid request: {0}")]
|
||||
Invalid(String),
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
#[error("secrets backend: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for SecretsApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthzError> for SecretsApiError {
|
||||
fn from(e: AuthzError) -> Self {
|
||||
Self::AuthzRepo(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SecretsRepoError> for SecretsApiError {
|
||||
fn from(e: SecretsRepoError) -> Self {
|
||||
match e {
|
||||
SecretsRepoError::InvalidCursor => Self::Invalid("invalid pagination cursor".into()),
|
||||
SecretsRepoError::Db(e) => Self::Backend(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SecretsError> for SecretsApiError {
|
||||
fn from(e: SecretsError) -> Self {
|
||||
match e {
|
||||
SecretsError::InvalidName(m) => Self::Invalid(m),
|
||||
SecretsError::TooLarge { .. } => Self::Invalid(e.to_string()),
|
||||
SecretsError::Forbidden => Self::Forbidden,
|
||||
other => Self::Backend(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for SecretsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound | Self::NotFound => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Invalid(_) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "secrets admin authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "secrets admin backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
246
crates/manager-core/src/secrets_repo.rs
Normal file
246
crates/manager-core/src/secrets_repo.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
//! Low-level Postgres CRUD over `secrets`. Storage-only: it moves
|
||||
//! opaque ciphertext + nonce blobs in and out. Encryption, JSON
|
||||
//! encoding, authorization, name validation, and the value-size cap all
|
||||
//! live one layer up in `SecretsServiceImpl` / `secrets_api`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::AppId;
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SecretsRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
#[error("invalid pagination cursor")]
|
||||
InvalidCursor,
|
||||
}
|
||||
|
||||
/// An encrypted secret as it lives on disk: ciphertext (auth tag
|
||||
/// appended) plus the nonce it was sealed with.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredSecret {
|
||||
pub encrypted_value: Vec<u8>,
|
||||
pub nonce: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Admin-surface metadata for one secret. Values are never returned —
|
||||
/// only the name and the last-modified timestamp.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SecretMeta {
|
||||
pub name: String,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// One page of names (SDK `list`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SecretsNamePage {
|
||||
pub names: Vec<String>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// One page of name + updated_at (admin `GET`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SecretsMetaPage {
|
||||
pub items: Vec<SecretMeta>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// Repo surface. Exposed as a trait so the service unit tests can
|
||||
/// substitute an in-memory backing without Postgres.
|
||||
#[async_trait]
|
||||
pub trait SecretsRepo: Send + Sync {
|
||||
async fn get(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<StoredSecret>, SecretsRepoError>;
|
||||
|
||||
/// Upsert (overwrite if present).
|
||||
async fn set(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
encrypted_value: &[u8],
|
||||
nonce: &[u8],
|
||||
) -> Result<(), SecretsRepoError>;
|
||||
|
||||
/// Delete; returns whether a row was present.
|
||||
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError>;
|
||||
|
||||
/// Names only — the SDK `list` surface.
|
||||
async fn list_names(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsNamePage, SecretsRepoError>;
|
||||
|
||||
/// Name + updated_at — the admin `GET` surface.
|
||||
async fn list_meta(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsMetaPage, SecretsRepoError>;
|
||||
}
|
||||
|
||||
pub struct PostgresSecretsRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresSecretsRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
const SECRETS_LIST_MAX_LIMIT: u32 = 1_000;
|
||||
const SECRETS_LIST_DEFAULT_LIMIT: u32 = 100;
|
||||
|
||||
fn clamp_limit(limit: u32) -> u32 {
|
||||
if limit == 0 {
|
||||
SECRETS_LIST_DEFAULT_LIMIT
|
||||
} else {
|
||||
limit.min(SECRETS_LIST_MAX_LIMIT)
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque keyset cursor: base64url of the last `name` returned.
|
||||
pub(crate) fn encode_cursor(last_name: &str) -> String {
|
||||
URL_SAFE_NO_PAD.encode(last_name.as_bytes())
|
||||
}
|
||||
|
||||
pub(crate) fn decode_cursor(cursor: &str) -> Result<String, SecretsRepoError> {
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(cursor)
|
||||
.map_err(|_| SecretsRepoError::InvalidCursor)?;
|
||||
String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SecretsRepo for PostgresSecretsRepo {
|
||||
async fn get(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<StoredSecret>, SecretsRepoError> {
|
||||
let row: Option<(Vec<u8>, Vec<u8>)> = sqlx::query_as(
|
||||
"SELECT encrypted_value, nonce FROM secrets WHERE app_id = $1 AND name = $2",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|(encrypted_value, nonce)| StoredSecret {
|
||||
encrypted_value,
|
||||
nonce,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
encrypted_value: &[u8],
|
||||
nonce: &[u8],
|
||||
) -> Result<(), SecretsRepoError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO secrets (app_id, name, encrypted_value, nonce) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
ON CONFLICT (app_id, name) DO UPDATE \
|
||||
SET encrypted_value = EXCLUDED.encrypted_value, \
|
||||
nonce = EXCLUDED.nonce, \
|
||||
updated_at = NOW()",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.bind(encrypted_value)
|
||||
.bind(nonce)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError> {
|
||||
let res = sqlx::query("DELETE FROM secrets WHERE app_id = $1 AND name = $2")
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
async fn list_names(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsNamePage, SecretsRepoError> {
|
||||
let limit = clamp_limit(limit);
|
||||
let last_name = match cursor {
|
||||
Some(c) => Some(decode_cursor(c)?),
|
||||
None => None,
|
||||
};
|
||||
let take = i64::from(limit) + 1;
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT name FROM secrets \
|
||||
WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \
|
||||
ORDER BY name ASC LIMIT $3",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(last_name.as_deref())
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut names: Vec<String> = rows.into_iter().map(|(n,)| n).collect();
|
||||
let next_cursor = if names.len() > limit as usize {
|
||||
names.truncate(limit as usize);
|
||||
names.last().map(|n| encode_cursor(n))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(SecretsNamePage { names, next_cursor })
|
||||
}
|
||||
|
||||
async fn list_meta(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsMetaPage, SecretsRepoError> {
|
||||
let limit = clamp_limit(limit);
|
||||
let last_name = match cursor {
|
||||
Some(c) => Some(decode_cursor(c)?),
|
||||
None => None,
|
||||
};
|
||||
let take = i64::from(limit) + 1;
|
||||
let rows: Vec<(String, DateTime<Utc>)> = sqlx::query_as(
|
||||
"SELECT name, updated_at FROM secrets \
|
||||
WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \
|
||||
ORDER BY name ASC LIMIT $3",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(last_name.as_deref())
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut items: Vec<SecretMeta> = rows
|
||||
.into_iter()
|
||||
.map(|(name, updated_at)| SecretMeta { name, updated_at })
|
||||
.collect();
|
||||
let next_cursor = if items.len() > limit as usize {
|
||||
items.truncate(limit as usize);
|
||||
items.last().map(|m| encode_cursor(&m.name))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(SecretsMetaPage { items, next_cursor })
|
||||
}
|
||||
}
|
||||
574
crates/manager-core/src/secrets_service.rs
Normal file
574
crates/manager-core/src/secrets_service.rs
Normal file
@@ -0,0 +1,574 @@
|
||||
//! `SecretsServiceImpl` — wires the `SecretsRepo` underneath the
|
||||
//! `picloud_shared::SecretsService` trait that scripts see via the Rhai
|
||||
//! bridge.
|
||||
//!
|
||||
//! Layers added here (vs the raw repo):
|
||||
//!
|
||||
//! 1. Name validation (non-empty, ≤255 bytes) at the SDK boundary.
|
||||
//! 2. **Script-as-gate authz**: when `cx.principal.is_some()` we run
|
||||
//! `authz::require(...)`; when it's `None` (public unauthenticated
|
||||
//! HTTP) we skip the check. Cross-app isolation is unaffected — every
|
||||
//! query is keyed by `cx.app_id`, never an argument.
|
||||
//! 3. **JSON ⇄ ciphertext**: `set` serializes the value to JSON bytes,
|
||||
//! enforces the per-secret size cap, and AES-256-GCM-seals it; `get`
|
||||
//! decrypts and deserializes back to the same JSON shape (a String
|
||||
//! round-trips to a String, not a JSON-quoted `"\"…\""`).
|
||||
//!
|
||||
//! Deliberately **no `ServiceEvent` emission** — secret writes do not
|
||||
//! fire triggers (footgun avoidance; see `docs/sdk-shape.md` + the
|
||||
//! v1.1.7 brief §2).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
crypto, validate_secret_name, MasterKey, SdkCallCx, SecretsError, SecretsListPage,
|
||||
SecretsService,
|
||||
};
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::secrets_repo::{SecretsRepo, SecretsRepoError, StoredSecret};
|
||||
|
||||
/// Default per-secret plaintext cap (64 KB). Override with
|
||||
/// `PICLOUD_SECRET_MAX_VALUE_BYTES`.
|
||||
pub const DEFAULT_SECRET_MAX_VALUE_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Process config for the secrets service.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SecretsConfig {
|
||||
/// Maximum size of the JSON-encoded plaintext, in bytes.
|
||||
pub max_value_bytes: usize,
|
||||
}
|
||||
|
||||
impl SecretsConfig {
|
||||
#[must_use]
|
||||
pub const fn conservative() -> Self {
|
||||
Self {
|
||||
max_value_bytes: DEFAULT_SECRET_MAX_VALUE_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read `PICLOUD_SECRET_MAX_VALUE_BYTES`; invalid values are ignored
|
||||
/// with a warning (keeps the conservative default).
|
||||
#[must_use]
|
||||
pub fn from_env() -> Self {
|
||||
let mut c = Self::conservative();
|
||||
if let Ok(v) = std::env::var("PICLOUD_SECRET_MAX_VALUE_BYTES") {
|
||||
match v.trim().parse::<usize>() {
|
||||
Ok(n) if n > 0 => c.max_value_bytes = n,
|
||||
_ => tracing::warn!(
|
||||
value = %v,
|
||||
"ignoring invalid PICLOUD_SECRET_MAX_VALUE_BYTES (want a positive integer)"
|
||||
),
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SecretsConfig {
|
||||
fn default() -> Self {
|
||||
Self::conservative()
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize + size-check + encrypt a value into `(ciphertext, nonce)`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`SecretsError::TooLarge`] when the encoded plaintext exceeds
|
||||
/// `max_value_bytes`; [`SecretsError::Backend`] on a serialization
|
||||
/// failure (should not happen for a `serde_json::Value`).
|
||||
pub fn seal(
|
||||
master_key: &MasterKey,
|
||||
value: &serde_json::Value,
|
||||
max_value_bytes: usize,
|
||||
) -> Result<(Vec<u8>, [u8; crypto::NONCE_LEN]), SecretsError> {
|
||||
let plaintext = serde_json::to_vec(value)
|
||||
.map_err(|e| SecretsError::Backend(format!("encode secret value: {e}")))?;
|
||||
if plaintext.len() > max_value_bytes {
|
||||
return Err(SecretsError::TooLarge {
|
||||
limit: max_value_bytes,
|
||||
actual: plaintext.len(),
|
||||
});
|
||||
}
|
||||
let enc = crypto::encrypt(&plaintext, master_key.as_bytes());
|
||||
Ok((enc.ciphertext, enc.nonce))
|
||||
}
|
||||
|
||||
/// Decrypt + deserialize a stored secret back to its JSON value.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`SecretsError::Corrupted`] when decryption or JSON decoding fails.
|
||||
pub fn open(
|
||||
master_key: &MasterKey,
|
||||
stored: &StoredSecret,
|
||||
) -> Result<serde_json::Value, SecretsError> {
|
||||
let plaintext = crypto::decrypt(
|
||||
&stored.encrypted_value,
|
||||
&stored.nonce,
|
||||
master_key.as_bytes(),
|
||||
)
|
||||
.map_err(|_| SecretsError::Corrupted)?;
|
||||
serde_json::from_slice(&plaintext).map_err(|_| SecretsError::Corrupted)
|
||||
}
|
||||
|
||||
pub struct SecretsServiceImpl {
|
||||
repo: Arc<dyn SecretsRepo>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
master_key: MasterKey,
|
||||
max_value_bytes: usize,
|
||||
}
|
||||
|
||||
impl SecretsServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repo: Arc<dyn SecretsRepo>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
master_key: MasterKey,
|
||||
config: SecretsConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
authz,
|
||||
master_key,
|
||||
max_value_bytes: config.max_value_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), SecretsError> {
|
||||
if let Some(ref principal) = cx.principal {
|
||||
authz::require(
|
||||
&*self.authz,
|
||||
principal,
|
||||
Capability::AppSecretsRead(cx.app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| SecretsError::Forbidden)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_write(&self, cx: &SdkCallCx) -> Result<(), SecretsError> {
|
||||
if let Some(ref principal) = cx.principal {
|
||||
authz::require(
|
||||
&*self.authz,
|
||||
principal,
|
||||
Capability::AppSecretsWrite(cx.app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| SecretsError::Forbidden)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SecretsRepoError> for SecretsError {
|
||||
fn from(e: SecretsRepoError) -> Self {
|
||||
Self::Backend(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SecretsService for SecretsServiceImpl {
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
name: &str,
|
||||
) -> Result<Option<serde_json::Value>, SecretsError> {
|
||||
validate_secret_name(name)?;
|
||||
self.check_read(cx).await?;
|
||||
let Some(stored) = self.repo.get(cx.app_id, name).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
match open(&self.master_key, &stored) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(e) => {
|
||||
// A decrypt failure is operationally significant — surface
|
||||
// the affected (app_id, name) so an operator can find the
|
||||
// bad row, but never log the ciphertext or key material.
|
||||
tracing::error!(
|
||||
app_id = %cx.app_id,
|
||||
secret = %name,
|
||||
"secret could not be decrypted (corrupted row or master-key mismatch)"
|
||||
);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
name: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<(), SecretsError> {
|
||||
validate_secret_name(name)?;
|
||||
self.check_write(cx).await?;
|
||||
let (ciphertext, nonce) = seal(&self.master_key, &value, self.max_value_bytes)?;
|
||||
self.repo.set(cx.app_id, name, &ciphertext, &nonce).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result<bool, SecretsError> {
|
||||
validate_secret_name(name)?;
|
||||
self.check_write(cx).await?;
|
||||
Ok(self.repo.delete(cx.app_id, name).await?)
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsListPage, SecretsError> {
|
||||
self.check_read(cx).await?;
|
||||
let page = self.repo.list_names(cx.app_id, cursor, limit).await?;
|
||||
Ok(SecretsListPage {
|
||||
names: page.names,
|
||||
next_cursor: page.next_cursor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests — in-memory SecretsRepo so unit tests don't need Postgres.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
use crate::secrets_repo::{SecretsMetaPage, SecretsNamePage};
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
|
||||
UserId,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Default)]
|
||||
struct InMemorySecretsRepo {
|
||||
data: Mutex<BTreeMap<(AppId, String), StoredSecret>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SecretsRepo for InMemorySecretsRepo {
|
||||
async fn get(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<StoredSecret>, SecretsRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(app_id, name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
async fn set(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
encrypted_value: &[u8],
|
||||
nonce: &[u8],
|
||||
) -> Result<(), SecretsRepoError> {
|
||||
self.data.lock().await.insert(
|
||||
(app_id, name.to_string()),
|
||||
StoredSecret {
|
||||
encrypted_value: encrypted_value.to_vec(),
|
||||
nonce: nonce.to_vec(),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.remove(&(app_id, name.to_string()))
|
||||
.is_some())
|
||||
}
|
||||
async fn list_names(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsNamePage, SecretsRepoError> {
|
||||
let data = self.data.lock().await;
|
||||
let last = cursor.map(std::string::ToString::to_string);
|
||||
let mut names: Vec<String> = data
|
||||
.iter()
|
||||
.filter(|((a, _), _)| *a == app_id)
|
||||
.map(|((_, n), _)| n.clone())
|
||||
.filter(|n| last.as_ref().is_none_or(|l| n > l))
|
||||
.collect();
|
||||
names.sort();
|
||||
let take = (limit as usize).max(1);
|
||||
let next_cursor = if names.len() > take {
|
||||
names.truncate(take);
|
||||
names.last().cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(SecretsNamePage { names, next_cursor })
|
||||
}
|
||||
async fn list_meta(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_cursor: Option<&str>,
|
||||
_limit: u32,
|
||||
) -> Result<SecretsMetaPage, SecretsRepoError> {
|
||||
unimplemented!("admin-only; not exercised in service tests")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DenyingAuthzRepo;
|
||||
#[async_trait]
|
||||
impl AuthzRepo for DenyingAuthzRepo {
|
||||
async fn membership(
|
||||
&self,
|
||||
_user_id: UserId,
|
||||
_app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn key() -> MasterKey {
|
||||
MasterKey::from_bytes([0x5au8; 32])
|
||||
}
|
||||
|
||||
fn svc() -> SecretsServiceImpl {
|
||||
SecretsServiceImpl::new(
|
||||
Arc::new(InMemorySecretsRepo::default()),
|
||||
Arc::new(DenyingAuthzRepo),
|
||||
key(),
|
||||
SecretsConfig::conservative(),
|
||||
)
|
||||
}
|
||||
|
||||
fn cx_with(app_id: AppId, principal: Option<Principal>) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
script_id: ScriptId::new(),
|
||||
principal,
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: ExecutionId::new(),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn anon_cx(app_id: AppId) -> SdkCallCx {
|
||||
cx_with(app_id, None)
|
||||
}
|
||||
|
||||
fn member_no_role_cx(app_id: AppId) -> SdkCallCx {
|
||||
cx_with(
|
||||
app_id,
|
||||
Some(Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Member,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn owner_cx(app_id: AppId) -> SdkCallCx {
|
||||
cx_with(
|
||||
app_id,
|
||||
Some(Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_get_delete_round_trip() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
s.set(&cx, "stripe_key", serde_json::json!("sk_live_xxx"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.get(&cx, "stripe_key").await.unwrap(),
|
||||
Some(serde_json::json!("sk_live_xxx"))
|
||||
);
|
||||
assert!(s.delete(&cx, "stripe_key").await.unwrap());
|
||||
assert_eq!(s.get(&cx, "stripe_key").await.unwrap(), None);
|
||||
// Idempotent delete.
|
||||
assert!(!s.delete(&cx, "stripe_key").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_missing_returns_none() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
assert_eq!(s.get(&cx, "nope").await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_name_rejected() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let err = s.set(&cx, "", serde_json::json!("x")).await.unwrap_err();
|
||||
assert!(matches!(err, SecretsError::InvalidName(_)));
|
||||
let err = s.get(&cx, "").await.unwrap_err();
|
||||
assert!(matches!(err, SecretsError::InvalidName(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn name_length_capped() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let long = "a".repeat(256);
|
||||
let err = s.set(&cx, &long, serde_json::json!(1)).await.unwrap_err();
|
||||
assert!(matches!(err, SecretsError::InvalidName(_)));
|
||||
// Exactly 255 is allowed.
|
||||
let ok = "b".repeat(255);
|
||||
s.set(&cx, &ok, serde_json::json!(1)).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn value_over_cap_rejected() {
|
||||
let s = SecretsServiceImpl::new(
|
||||
Arc::new(InMemorySecretsRepo::default()),
|
||||
Arc::new(DenyingAuthzRepo),
|
||||
key(),
|
||||
SecretsConfig {
|
||||
max_value_bytes: 16,
|
||||
},
|
||||
);
|
||||
let cx = anon_cx(AppId::new());
|
||||
let big = serde_json::json!("x".repeat(64));
|
||||
let err = s.set(&cx, "k", big).await.unwrap_err();
|
||||
assert!(matches!(err, SecretsError::TooLarge { limit: 16, .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_app_isolation() {
|
||||
let s = svc();
|
||||
let a = AppId::new();
|
||||
let b = AppId::new();
|
||||
s.set(&anon_cx(a), "shared", serde_json::json!("from-a"))
|
||||
.await
|
||||
.unwrap();
|
||||
s.set(&anon_cx(b), "shared", serde_json::json!("from-b"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.get(&anon_cx(a), "shared").await.unwrap(),
|
||||
Some(serde_json::json!("from-a"))
|
||||
);
|
||||
assert_eq!(
|
||||
s.get(&anon_cx(b), "shared").await.unwrap(),
|
||||
Some(serde_json::json!("from-b"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anonymous_skips_authz() {
|
||||
let s = svc();
|
||||
// DenyingAuthzRepo would deny an authed principal; anon skips it.
|
||||
s.set(&anon_cx(AppId::new()), "k", serde_json::json!(1))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authed_member_without_role_forbidden() {
|
||||
let s = svc();
|
||||
let err = s
|
||||
.set(&member_no_role_cx(AppId::new()), "k", serde_json::json!(1))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, SecretsError::Forbidden));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_can_write() {
|
||||
let s = svc();
|
||||
s.set(&owner_cx(AppId::new()), "k", serde_json::json!(1))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Type round-trip: a String comes back a String, a Map a Map, an
|
||||
/// Array an Array — the JSON encoding is transparent.
|
||||
#[tokio::test]
|
||||
async fn type_round_trip_preserves_shape() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
|
||||
s.set(&cx, "str", serde_json::json!("sk_live_xxx"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.get(&cx, "str").await.unwrap(),
|
||||
Some(serde_json::json!("sk_live_xxx"))
|
||||
);
|
||||
|
||||
let map = serde_json::json!({ "client_id": "abc", "client_secret": "xyz" });
|
||||
s.set(&cx, "oauth", map.clone()).await.unwrap();
|
||||
assert_eq!(s.get(&cx, "oauth").await.unwrap(), Some(map));
|
||||
|
||||
let arr = serde_json::json!([1, 2, 3]);
|
||||
s.set(&cx, "arr", arr.clone()).await.unwrap();
|
||||
assert_eq!(s.get(&cx, "arr").await.unwrap(), Some(arr));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn corrupted_ciphertext_surfaces_error() {
|
||||
let repo = Arc::new(InMemorySecretsRepo::default());
|
||||
let s = SecretsServiceImpl::new(
|
||||
repo.clone(),
|
||||
Arc::new(DenyingAuthzRepo),
|
||||
key(),
|
||||
SecretsConfig::conservative(),
|
||||
);
|
||||
let app = AppId::new();
|
||||
s.set(&anon_cx(app), "k", serde_json::json!("v"))
|
||||
.await
|
||||
.unwrap();
|
||||
// Corrupt the stored ciphertext directly.
|
||||
repo.data
|
||||
.lock()
|
||||
.await
|
||||
.get_mut(&(app, "k".to_string()))
|
||||
.unwrap()
|
||||
.encrypted_value[0] ^= 0xff;
|
||||
let err = s.get(&anon_cx(app), "k").await.unwrap_err();
|
||||
assert!(matches!(err, SecretsError::Corrupted));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_returns_names_paginated() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
for i in 0..5 {
|
||||
s.set(&cx, &format!("k{i:02}"), serde_json::json!(i))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let p1 = s.list(&cx, None, 2).await.unwrap();
|
||||
assert_eq!(p1.names, vec!["k00".to_string(), "k01".to_string()]);
|
||||
assert!(p1.next_cursor.is_some());
|
||||
let p2 = s.list(&cx, p1.next_cursor.as_deref(), 10).await.unwrap();
|
||||
assert_eq!(
|
||||
p2.names,
|
||||
vec!["k02".to_string(), "k03".to_string(), "k04".to_string()]
|
||||
);
|
||||
assert!(p2.next_cursor.is_none());
|
||||
}
|
||||
}
|
||||
457
crates/manager-core/src/ssrf.rs
Normal file
457
crates/manager-core/src/ssrf.rs
Normal file
@@ -0,0 +1,457 @@
|
||||
//! SSRF deny-list — the load-bearing security mechanism behind the
|
||||
//! v1.1.4 `http::*` SDK.
|
||||
//!
|
||||
//! The policy is applied to the **resolved IP address**, not the
|
||||
//! hostname. That is the DNS-rebinding defense: a hostname that
|
||||
//! resolves to a public IP at lookup time and a private IP at connect
|
||||
//! time is not exploitable, because reqwest re-runs every connection
|
||||
//! (including post-redirect hops) through [`SsrfResolver`], which
|
||||
//! filters the address list before the socket is opened.
|
||||
//!
|
||||
//! [`SsrfPolicy::check`] returns a CIDR-*category* reason on denial
|
||||
//! (e.g. `"loopback"`, `"private"`) — never the IP itself, so the
|
||||
//! script-visible error can't be used to map the internal network.
|
||||
//!
|
||||
//! `PICLOUD_HTTP_ALLOW_PRIVATE=true` flips `allow_private`, which
|
||||
//! short-circuits every check to allow. That is dev/test-only and the
|
||||
//! binary logs a startup warning when it's set.
|
||||
|
||||
use std::future::Future;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
|
||||
|
||||
/// Decision policy for a single resolved IP. Cheap to clone (one bool).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SsrfPolicy {
|
||||
/// When true, every address is allowed — the entire deny-list is
|
||||
/// disabled. Set from `PICLOUD_HTTP_ALLOW_PRIVATE`. Dev/test only.
|
||||
pub allow_private: bool,
|
||||
}
|
||||
|
||||
impl SsrfPolicy {
|
||||
#[must_use]
|
||||
pub const fn new(allow_private: bool) -> Self {
|
||||
Self { allow_private }
|
||||
}
|
||||
|
||||
/// `Ok(())` if the IP may be connected to; `Err(reason)` with a
|
||||
/// CIDR-category label otherwise. The reason is safe to surface to
|
||||
/// a script — it never contains the address.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns the deny reason when `ip` falls in a blocked range and
|
||||
/// `allow_private` is false.
|
||||
pub fn check(&self, ip: IpAddr) -> Result<(), &'static str> {
|
||||
if self.allow_private {
|
||||
return Ok(());
|
||||
}
|
||||
match ip {
|
||||
IpAddr::V4(v4) => check_v4(v4),
|
||||
IpAddr::V6(v6) => check_v6(v6),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_allowed(&self, ip: IpAddr) -> bool {
|
||||
self.check(ip).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// IPv4 deny-list. Order doesn't matter (ranges are disjoint by
|
||||
/// construction); first match wins for the reason label.
|
||||
// Several arms share a reason ("private") for distinct CIDRs — keeping
|
||||
// them separate documents each blocked range explicitly.
|
||||
#[allow(clippy::match_same_arms)]
|
||||
fn check_v4(ip: Ipv4Addr) -> Result<(), &'static str> {
|
||||
let o = ip.octets();
|
||||
match o {
|
||||
[127, ..] => Err("loopback"),
|
||||
[0, ..] => Err("unspecified"), // 0.0.0.0/8 "this network"
|
||||
[10, ..] => Err("private"),
|
||||
[172, b, ..] if (16..=31).contains(&b) => Err("private"),
|
||||
[192, 168, ..] => Err("private"),
|
||||
[169, 254, ..] => Err("link-local"), // includes cloud metadata 169.254.169.254
|
||||
[100, b, ..] if (64..=127).contains(&b) => Err("carrier-grade-nat"),
|
||||
[224..=239, ..] => Err("multicast"),
|
||||
[240..=255, ..] => Err("reserved"),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// IPv6 deny-list. IPv4-mapped addresses (`::ffff:0:0/96`) re-run the
|
||||
/// v4 deny-list against the embedded address.
|
||||
fn check_v6(ip: Ipv6Addr) -> Result<(), &'static str> {
|
||||
// IPv4-mapped (::ffff:a.b.c.d) — re-check the embedded v4 address
|
||||
// so a mapped private/loopback address can't sneak through.
|
||||
if let Some(v4) = ip.to_ipv4_mapped() {
|
||||
return check_v4(v4);
|
||||
}
|
||||
if ip == Ipv6Addr::LOCALHOST {
|
||||
return Err("loopback");
|
||||
}
|
||||
if ip == Ipv6Addr::UNSPECIFIED {
|
||||
return Err("unspecified");
|
||||
}
|
||||
let seg0 = ip.segments()[0];
|
||||
if seg0 & 0xffc0 == 0xfe80 {
|
||||
return Err("link-local"); // fe80::/10
|
||||
}
|
||||
if seg0 & 0xfe00 == 0xfc00 {
|
||||
return Err("unique-local"); // fc00::/7
|
||||
}
|
||||
if seg0 & 0xff00 == 0xff00 {
|
||||
return Err("multicast"); // ff00::/8
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Marker error returned by the resolver when *every* resolved address
|
||||
/// for a host was denied. reqwest wraps this into a connect error; the
|
||||
/// `http_service` impl walks the source chain for the
|
||||
/// `"blocked by SSRF policy:"` prefix to surface a clean
|
||||
/// [`crate::http_service::HttpError::Ssrf`] instead of a generic DNS
|
||||
/// failure. Keeping the reason a category label means no IP leaks.
|
||||
#[derive(Debug)]
|
||||
struct SsrfBlocked {
|
||||
reason: &'static str,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SsrfBlocked {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "blocked by SSRF policy: {}", self.reason)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SsrfBlocked {}
|
||||
|
||||
/// Prefix the resolver embeds in its error and the impl scans for.
|
||||
pub const SSRF_BLOCK_PREFIX: &str = "blocked by SSRF policy: ";
|
||||
|
||||
/// Pluggable host→addresses lookup. Production uses the system
|
||||
/// resolver; tests inject a closure (e.g. to simulate DNS rebinding —
|
||||
/// a different address on a later call).
|
||||
pub type LookupFn = Arc<
|
||||
dyn Fn(String) -> Pin<Box<dyn Future<Output = std::io::Result<Vec<SocketAddr>>> + Send>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
fn system_lookup(
|
||||
host: String,
|
||||
) -> Pin<Box<dyn Future<Output = std::io::Result<Vec<SocketAddr>>> + Send>> {
|
||||
Box::pin(async move {
|
||||
// Port 0 — reqwest overrides it with the real target port.
|
||||
Ok(tokio::net::lookup_host((host.as_str(), 0u16))
|
||||
.await?
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
/// reqwest DNS resolver that delegates to the system resolver, then
|
||||
/// filters the address list through [`SsrfPolicy`]. Plugged in via
|
||||
/// `ClientBuilder::dns_resolver`, so it runs at the actual connection
|
||||
/// point — including for every redirect hop. This is the DNS-rebinding
|
||||
/// defense: filtering happens at connect time, not at URL-parse time.
|
||||
#[derive(Clone)]
|
||||
pub struct SsrfResolver {
|
||||
policy: SsrfPolicy,
|
||||
lookup: LookupFn,
|
||||
}
|
||||
|
||||
impl SsrfResolver {
|
||||
#[must_use]
|
||||
pub fn new(policy: SsrfPolicy) -> Self {
|
||||
Self {
|
||||
policy,
|
||||
lookup: Arc::new(system_lookup),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct with an injected lookup (tests only).
|
||||
#[must_use]
|
||||
pub fn with_lookup(policy: SsrfPolicy, lookup: LookupFn) -> Self {
|
||||
Self { policy, lookup }
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve for SsrfResolver {
|
||||
fn resolve(&self, name: Name) -> Resolving {
|
||||
let policy = self.policy;
|
||||
let lookup = self.lookup.clone();
|
||||
let host = name.as_str().to_string();
|
||||
Box::pin(async move {
|
||||
let resolved: Vec<SocketAddr> = lookup(host)
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
|
||||
|
||||
// Empty resolution → genuine DNS miss; let reqwest surface
|
||||
// it as a normal "no addresses" error.
|
||||
if resolved.is_empty() {
|
||||
let addrs: Addrs = Box::new(std::iter::empty());
|
||||
return Ok(addrs);
|
||||
}
|
||||
|
||||
let mut allowed: Vec<SocketAddr> = Vec::with_capacity(resolved.len());
|
||||
let mut last_reason: &'static str = "denied";
|
||||
for sa in resolved {
|
||||
match policy.check(sa.ip()) {
|
||||
Ok(()) => allowed.push(sa),
|
||||
Err(reason) => last_reason = reason,
|
||||
}
|
||||
}
|
||||
|
||||
// Resolution returned addresses but the policy denied them
|
||||
// all → fail with the SSRF marker so the impl can report a
|
||||
// policy block (not a generic DNS error).
|
||||
if allowed.is_empty() {
|
||||
let err: Box<dyn std::error::Error + Send + Sync> = Box::new(SsrfBlocked {
|
||||
reason: last_reason,
|
||||
});
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let addrs: Addrs = Box::new(allowed.into_iter());
|
||||
Ok(addrs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the resolver. reqwest's `dns_resolver` is generic over a
|
||||
/// concrete `R: Resolve` (it stores `Arc<R>`), so this returns the
|
||||
/// concrete `Arc<SsrfResolver>` rather than a trait object.
|
||||
#[must_use]
|
||||
pub fn resolver(policy: SsrfPolicy) -> Arc<SsrfResolver> {
|
||||
Arc::new(SsrfResolver::new(policy))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn denied(ip: &str) -> &'static str {
|
||||
let policy = SsrfPolicy::new(false);
|
||||
policy
|
||||
.check(IpAddr::from_str(ip).unwrap())
|
||||
.expect_err(&format!("{ip} should be denied"))
|
||||
}
|
||||
|
||||
fn allowed(ip: &str) {
|
||||
let policy = SsrfPolicy::new(false);
|
||||
policy
|
||||
.check(IpAddr::from_str(ip).unwrap())
|
||||
.unwrap_or_else(|r| panic!("{ip} should be allowed, denied as {r}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_ipv4_loopback() {
|
||||
assert_eq!(denied("127.0.0.1"), "loopback");
|
||||
assert_eq!(denied("127.1.2.3"), "loopback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_ipv4_unspecified() {
|
||||
assert_eq!(denied("0.0.0.0"), "unspecified");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_rfc1918_private() {
|
||||
assert_eq!(denied("10.0.0.1"), "private");
|
||||
assert_eq!(denied("10.255.255.255"), "private");
|
||||
assert_eq!(denied("172.16.0.1"), "private");
|
||||
assert_eq!(denied("172.31.255.255"), "private");
|
||||
assert_eq!(denied("192.168.0.1"), "private");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_172_outside_private_range() {
|
||||
// 172.15.x and 172.32.x are public — only 172.16.0.0/12 is private.
|
||||
allowed("172.15.0.1");
|
||||
allowed("172.32.0.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_link_local_and_cloud_metadata() {
|
||||
assert_eq!(denied("169.254.0.1"), "link-local");
|
||||
// The cloud metadata endpoint is the canonical SSRF target.
|
||||
assert_eq!(denied("169.254.169.254"), "link-local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_carrier_grade_nat() {
|
||||
assert_eq!(denied("100.64.0.1"), "carrier-grade-nat");
|
||||
assert_eq!(denied("100.127.255.255"), "carrier-grade-nat");
|
||||
// 100.63.x and 100.128.x are outside 100.64.0.0/10.
|
||||
allowed("100.63.0.1");
|
||||
allowed("100.128.0.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_multicast_and_reserved() {
|
||||
assert_eq!(denied("224.0.0.1"), "multicast");
|
||||
assert_eq!(denied("239.255.255.255"), "multicast");
|
||||
assert_eq!(denied("240.0.0.1"), "reserved");
|
||||
assert_eq!(denied("255.255.255.255"), "reserved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_public_ipv4() {
|
||||
allowed("1.1.1.1");
|
||||
allowed("8.8.8.8");
|
||||
allowed("93.184.216.34"); // example.com
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_ipv6_loopback() {
|
||||
assert_eq!(denied("::1"), "loopback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_ipv6_unspecified() {
|
||||
assert_eq!(denied("::"), "unspecified");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_ipv6_link_local() {
|
||||
assert_eq!(denied("fe80::1"), "link-local");
|
||||
assert_eq!(denied("febf:ffff::1"), "link-local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_ipv6_unique_local() {
|
||||
assert_eq!(denied("fc00::1"), "unique-local");
|
||||
assert_eq!(denied("fd12:3456::1"), "unique-local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_ipv6_multicast() {
|
||||
assert_eq!(denied("ff00::1"), "multicast");
|
||||
assert_eq!(denied("ff02::1"), "multicast");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_public_ipv6() {
|
||||
allowed("2606:4700:4700::1111"); // cloudflare
|
||||
allowed("2001:4860:4860::8888"); // google
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv4_mapped_ipv6_rechecks_embedded_address() {
|
||||
// ::ffff:127.0.0.1 must be denied via the embedded-v4 re-check.
|
||||
assert_eq!(denied("::ffff:127.0.0.1"), "loopback");
|
||||
assert_eq!(denied("::ffff:10.0.0.1"), "private");
|
||||
assert_eq!(denied("::ffff:169.254.169.254"), "link-local");
|
||||
// A mapped *public* address stays allowed.
|
||||
allowed("::ffff:1.1.1.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_private_disables_all_denials() {
|
||||
let policy = SsrfPolicy::new(true);
|
||||
for ip in ["127.0.0.1", "10.0.0.1", "169.254.169.254", "::1", "fe80::1"] {
|
||||
assert!(policy.is_allowed(IpAddr::from_str(ip).unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolver-path tests (the connect-time filter) ---
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn name(s: &str) -> Name {
|
||||
Name::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn fixed_lookup(addrs: Vec<SocketAddr>) -> LookupFn {
|
||||
Arc::new(move |_host| {
|
||||
let addrs = addrs.clone();
|
||||
Box::pin(async move { Ok(addrs) })
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_returns_only_allowed_addresses() {
|
||||
// A host resolving to one public + one private IP yields only
|
||||
// the public one to reqwest.
|
||||
let public: SocketAddr = "1.1.1.1:0".parse().unwrap();
|
||||
let private: SocketAddr = "10.0.0.1:0".parse().unwrap();
|
||||
let resolver =
|
||||
SsrfResolver::with_lookup(SsrfPolicy::new(false), fixed_lookup(vec![public, private]));
|
||||
let got: Vec<SocketAddr> = resolver
|
||||
.resolve(name("mixed.example"))
|
||||
.await
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert_eq!(got, vec![public]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_all_denied_fails_with_ssrf_marker() {
|
||||
// A host resolving to ONLY private IPs fails with the SSRF
|
||||
// marker (not a generic empty/DNS result).
|
||||
let resolver = SsrfResolver::with_lookup(
|
||||
SsrfPolicy::new(false),
|
||||
fixed_lookup(vec![
|
||||
"10.0.0.1:0".parse().unwrap(),
|
||||
"127.0.0.1:0".parse().unwrap(),
|
||||
]),
|
||||
);
|
||||
let Err(err) = resolver.resolve(name("internal.example")).await else {
|
||||
panic!("all-denied resolution should error");
|
||||
};
|
||||
assert!(
|
||||
err.to_string().starts_with(SSRF_BLOCK_PREFIX),
|
||||
"expected SSRF marker, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_dns_rebinding_second_resolution_denied() {
|
||||
// Simulate rebinding: public IP on the first lookup, private on
|
||||
// the second. The connect-time filter denies the second.
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let calls2 = calls.clone();
|
||||
let lookup: LookupFn = Arc::new(move |_host| {
|
||||
let n = calls2.fetch_add(1, Ordering::SeqCst);
|
||||
Box::pin(async move {
|
||||
let addr: SocketAddr = if n == 0 {
|
||||
"1.1.1.1:0".parse().unwrap()
|
||||
} else {
|
||||
"127.0.0.1:0".parse().unwrap()
|
||||
};
|
||||
Ok(vec![addr])
|
||||
})
|
||||
});
|
||||
let resolver = SsrfResolver::with_lookup(SsrfPolicy::new(false), lookup);
|
||||
|
||||
// First resolution: public → allowed.
|
||||
let first: Vec<SocketAddr> = resolver
|
||||
.resolve(name("rebind.example"))
|
||||
.await
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert_eq!(first, vec!["1.1.1.1:0".parse::<SocketAddr>().unwrap()]);
|
||||
|
||||
// Second resolution: rebinding returns loopback → denied.
|
||||
let Err(err) = resolver.resolve(name("rebind.example")).await else {
|
||||
panic!("rebound private address must be denied");
|
||||
};
|
||||
assert!(err.to_string().contains("loopback"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_empty_resolution_is_not_ssrf() {
|
||||
// Genuine DNS miss (no addresses) returns an empty iterator,
|
||||
// NOT the SSRF marker — reqwest surfaces a normal DNS error.
|
||||
let resolver = SsrfResolver::with_lookup(SsrfPolicy::new(false), fixed_lookup(vec![]));
|
||||
let got: Vec<SocketAddr> = resolver
|
||||
.resolve(name("nxdomain.example"))
|
||||
.await
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert!(got.is_empty());
|
||||
}
|
||||
}
|
||||
212
crates/manager-core/src/topic_repo.rs
Normal file
212
crates/manager-core/src/topic_repo.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
//! `TopicRepo` — CRUD for the `topics` table (v1.1.6).
|
||||
//!
|
||||
//! This table holds ONLY topics that have been explicitly externalized
|
||||
//! for SSE subscription (design notes §5). Internal-only pub/sub topics
|
||||
//! stay implicit — they never get a row here, and the publish path never
|
||||
//! consults this table. The two readers are the topic admin endpoints
|
||||
//! ([`crate::topics_api`]) and the SSE subscribe authorization
|
||||
//! ([`crate::realtime_authority`]).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::AppId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
|
||||
/// External-subscriber auth gate for a topic. `'public'` + `'token'` in
|
||||
/// v1.1.6; `'session'` (v1.1.8) and `'script'` (v1.2) extend the DB
|
||||
/// CHECK constraint and this enum later.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TopicAuthMode {
|
||||
Public,
|
||||
Token,
|
||||
}
|
||||
|
||||
impl TopicAuthMode {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Public => "public",
|
||||
Self::Token => "token",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_db(s: &str) -> Result<Self, TopicRepoError> {
|
||||
match s {
|
||||
"public" => Ok(Self::Public),
|
||||
"token" => Ok(Self::Token),
|
||||
other => Err(TopicRepoError::Backend(format!(
|
||||
"unknown auth_mode in DB: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered, externally-subscribable topic row.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Topic {
|
||||
pub name: String,
|
||||
pub external_subscribable: bool,
|
||||
pub auth_mode: TopicAuthMode,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TopicRepoError {
|
||||
#[error("a topic named {0:?} already exists in this app")]
|
||||
AlreadyExists(String),
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
#[error("topic backend error: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait TopicRepo: Send + Sync {
|
||||
/// Register a topic. Errors `AlreadyExists` on PK conflict.
|
||||
async fn create(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
external_subscribable: bool,
|
||||
auth_mode: TopicAuthMode,
|
||||
) -> Result<Topic, TopicRepoError>;
|
||||
|
||||
/// List every registered topic in the app, ordered by name.
|
||||
async fn list(&self, app_id: AppId) -> Result<Vec<Topic>, TopicRepoError>;
|
||||
|
||||
/// Fetch one topic by name, `None` if not registered.
|
||||
async fn get(&self, app_id: AppId, name: &str) -> Result<Option<Topic>, TopicRepoError>;
|
||||
|
||||
/// Update `external_subscribable` and/or `auth_mode` (each `None`
|
||||
/// leaves the column unchanged). `None` return = no such topic.
|
||||
async fn update(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
external_subscribable: Option<bool>,
|
||||
auth_mode: Option<TopicAuthMode>,
|
||||
) -> Result<Option<Topic>, TopicRepoError>;
|
||||
|
||||
/// Unregister a topic. Returns `true` if a row was removed.
|
||||
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, TopicRepoError>;
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct TopicRow {
|
||||
name: String,
|
||||
external_subscribable: bool,
|
||||
auth_mode: String,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TopicRow {
|
||||
fn into_topic(self) -> Result<Topic, TopicRepoError> {
|
||||
Ok(Topic {
|
||||
auth_mode: TopicAuthMode::from_db(&self.auth_mode)?,
|
||||
name: self.name,
|
||||
external_subscribable: self.external_subscribable,
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const SELECT_COLS: &str = "name, external_subscribable, auth_mode, created_at, updated_at";
|
||||
|
||||
pub struct PostgresTopicRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresTopicRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TopicRepo for PostgresTopicRepo {
|
||||
async fn create(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
external_subscribable: bool,
|
||||
auth_mode: TopicAuthMode,
|
||||
) -> Result<Topic, TopicRepoError> {
|
||||
let row: Option<TopicRow> = sqlx::query_as(&format!(
|
||||
"INSERT INTO topics (app_id, name, external_subscribable, auth_mode) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
ON CONFLICT (app_id, name) DO NOTHING \
|
||||
RETURNING {SELECT_COLS}"
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.bind(external_subscribable)
|
||||
.bind(auth_mode.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
match row {
|
||||
Some(r) => r.into_topic(),
|
||||
None => Err(TopicRepoError::AlreadyExists(name.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(&self, app_id: AppId) -> Result<Vec<Topic>, TopicRepoError> {
|
||||
let rows: Vec<TopicRow> = sqlx::query_as(&format!(
|
||||
"SELECT {SELECT_COLS} FROM topics WHERE app_id = $1 ORDER BY name"
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(TopicRow::into_topic).collect()
|
||||
}
|
||||
|
||||
async fn get(&self, app_id: AppId, name: &str) -> Result<Option<Topic>, TopicRepoError> {
|
||||
let row: Option<TopicRow> = sqlx::query_as(&format!(
|
||||
"SELECT {SELECT_COLS} FROM topics WHERE app_id = $1 AND name = $2"
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(TopicRow::into_topic).transpose()
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
external_subscribable: Option<bool>,
|
||||
auth_mode: Option<TopicAuthMode>,
|
||||
) -> Result<Option<Topic>, TopicRepoError> {
|
||||
// COALESCE leaves a column untouched when its bind is NULL.
|
||||
let row: Option<TopicRow> = sqlx::query_as(&format!(
|
||||
"UPDATE topics SET \
|
||||
external_subscribable = COALESCE($3, external_subscribable), \
|
||||
auth_mode = COALESCE($4, auth_mode), \
|
||||
updated_at = NOW() \
|
||||
WHERE app_id = $1 AND name = $2 \
|
||||
RETURNING {SELECT_COLS}"
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.bind(external_subscribable)
|
||||
.bind(auth_mode.map(TopicAuthMode::as_str))
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(TopicRow::into_topic).transpose()
|
||||
}
|
||||
|
||||
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, TopicRepoError> {
|
||||
let res = sqlx::query("DELETE FROM topics WHERE app_id = $1 AND name = $2")
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
}
|
||||
629
crates/manager-core/src/topics_api.rs
Normal file
629
crates/manager-core/src/topics_api.rs
Normal file
@@ -0,0 +1,629 @@
|
||||
//! `/api/v1/admin/apps/{id}/topics*` — topic registration admin
|
||||
//! endpoints (v1.1.6).
|
||||
//!
|
||||
//! These manage the `topics` table: the explicit registry of which
|
||||
//! pub/sub topics are externally subscribable over SSE (design notes
|
||||
//! §5). Internal-only topics never appear here.
|
||||
//!
|
||||
//! * `POST /apps/{id}/topics` — register a topic.
|
||||
//! * `GET /apps/{id}/topics` — list registered topics.
|
||||
//! * `PATCH /apps/{id}/topics/{name}` — flip external/auth_mode.
|
||||
//! * `DELETE /apps/{id}/topics/{name}` — unregister + disconnect.
|
||||
//!
|
||||
//! The PATCH endpoint is deliberately its OWN surface (not folded into a
|
||||
//! generic topic update) so every change to externally-subscribable
|
||||
//! status is a discrete, watchable/auditable API call (§5 commitment).
|
||||
//!
|
||||
//! Create / update / delete are gated by `AppTopicManage` (→ `app:admin`
|
||||
//! scope); list is gated by the existing `AppRead`. DELETE also drops
|
||||
//! the topic's in-process broadcast channel so live SSE subscribers
|
||||
//! disconnect cleanly.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{get, patch};
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{AppId, Principal, RealtimeBroadcaster};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::topic_repo::{Topic, TopicAuthMode, TopicRepo, TopicRepoError};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TopicsState {
|
||||
pub topics: Arc<dyn TopicRepo>,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
pub broadcaster: Arc<dyn RealtimeBroadcaster>,
|
||||
}
|
||||
|
||||
pub fn topics_router(state: TopicsState) -> Router {
|
||||
Router::new()
|
||||
.route("/apps/{app_id}/topics", get(list_topics).post(create_topic))
|
||||
.route(
|
||||
"/apps/{app_id}/topics/{name}",
|
||||
patch(update_topic).delete(delete_topic),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateTopicRequest {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub external_subscribable: bool,
|
||||
#[serde(default = "default_auth_mode")]
|
||||
pub auth_mode: TopicAuthMode,
|
||||
}
|
||||
|
||||
const fn default_auth_mode() -> TopicAuthMode {
|
||||
TopicAuthMode::Public
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateTopicRequest {
|
||||
#[serde(default)]
|
||||
pub external_subscribable: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub auth_mode: Option<TopicAuthMode>,
|
||||
}
|
||||
|
||||
/// Topic names are concrete (external pattern subscription is v1.2), so
|
||||
/// reject empties and `*` wildcards at registration.
|
||||
fn validate_topic_name(name: &str) -> Result<(), TopicsApiError> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(TopicsApiError::Invalid(
|
||||
"topic name must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if name.contains('*') {
|
||||
return Err(TopicsApiError::Invalid(
|
||||
"topic name must be a concrete topic, not a pattern (no '*')".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_topic(
|
||||
State(s): State<TopicsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(app_id): Path<AppId>,
|
||||
Json(input): Json<CreateTopicRequest>,
|
||||
) -> Result<(StatusCode, Json<Topic>), TopicsApiError> {
|
||||
ensure_app_exists(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppTopicManage(app_id),
|
||||
)
|
||||
.await?;
|
||||
validate_topic_name(&input.name)?;
|
||||
let topic = s
|
||||
.topics
|
||||
.create(
|
||||
app_id,
|
||||
input.name.trim(),
|
||||
input.external_subscribable,
|
||||
input.auth_mode,
|
||||
)
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(topic)))
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct ListTopicsResponse {
|
||||
topics: Vec<Topic>,
|
||||
}
|
||||
|
||||
async fn list_topics(
|
||||
State(s): State<TopicsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(app_id): Path<AppId>,
|
||||
) -> Result<Json<ListTopicsResponse>, TopicsApiError> {
|
||||
ensure_app_exists(&*s.apps, app_id).await?;
|
||||
require(s.authz.as_ref(), &principal, Capability::AppRead(app_id)).await?;
|
||||
let topics = s.topics.list(app_id).await?;
|
||||
Ok(Json(ListTopicsResponse { topics }))
|
||||
}
|
||||
|
||||
async fn update_topic(
|
||||
State(s): State<TopicsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((app_id, name)): Path<(AppId, String)>,
|
||||
Json(input): Json<UpdateTopicRequest>,
|
||||
) -> Result<Json<Topic>, TopicsApiError> {
|
||||
ensure_app_exists(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppTopicManage(app_id),
|
||||
)
|
||||
.await?;
|
||||
let topic = s
|
||||
.topics
|
||||
.update(app_id, &name, input.external_subscribable, input.auth_mode)
|
||||
.await?
|
||||
.ok_or(TopicsApiError::NotFound)?;
|
||||
Ok(Json(topic))
|
||||
}
|
||||
|
||||
async fn delete_topic(
|
||||
State(s): State<TopicsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((app_id, name)): Path<(AppId, String)>,
|
||||
) -> Result<StatusCode, TopicsApiError> {
|
||||
ensure_app_exists(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppTopicManage(app_id),
|
||||
)
|
||||
.await?;
|
||||
if !s.topics.delete(app_id, &name).await? {
|
||||
return Err(TopicsApiError::NotFound);
|
||||
}
|
||||
// Disconnect any live SSE subscribers for the now-unregistered topic.
|
||||
s.broadcaster.drop_topic(app_id, &name).await;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn ensure_app_exists(apps: &dyn AppRepository, app_id: AppId) -> Result<(), TopicsApiError> {
|
||||
apps.get_by_id(app_id)
|
||||
.await
|
||||
.map_err(|e| TopicsApiError::Backend(e.to_string()))?
|
||||
.ok_or(TopicsApiError::AppNotFound)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TopicsApiError {
|
||||
#[error("app not found")]
|
||||
AppNotFound,
|
||||
#[error("topic not found")]
|
||||
NotFound,
|
||||
#[error("{0}")]
|
||||
AlreadyExists(String),
|
||||
#[error("invalid request: {0}")]
|
||||
Invalid(String),
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
#[error("topics backend: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for TopicsApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthzError> for TopicsApiError {
|
||||
fn from(e: AuthzError) -> Self {
|
||||
Self::AuthzRepo(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TopicRepoError> for TopicsApiError {
|
||||
fn from(e: TopicRepoError) -> Self {
|
||||
match e {
|
||||
TopicRepoError::AlreadyExists(name) => {
|
||||
Self::AlreadyExists(format!("a topic named {name:?} already exists in this app"))
|
||||
}
|
||||
other => Self::Backend(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for TopicsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound | Self::NotFound => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::AlreadyExists(_) => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
||||
Self::Invalid(_) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "topics admin authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "topics admin backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! In-memory handler tests: capability enforcement, the
|
||||
//! `external_subscribable` default, the flip being its own endpoint,
|
||||
//! cross-app isolation, and DELETE disconnecting subscribers. The
|
||||
//! Postgres repo is exercised by the schema + integration suites.
|
||||
|
||||
use super::*;
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use picloud_shared::{
|
||||
AdminUserId, App, AppRole, BroadcasterError, InstanceRole, RealtimeEvent, UserId,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Default)]
|
||||
struct InMemoryTopicRepo {
|
||||
inner: Mutex<HashMap<(AppId, String), Topic>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TopicRepo for InMemoryTopicRepo {
|
||||
async fn create(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
external_subscribable: bool,
|
||||
auth_mode: TopicAuthMode,
|
||||
) -> Result<Topic, TopicRepoError> {
|
||||
let mut g = self.inner.lock().await;
|
||||
if g.contains_key(&(app_id, name.to_string())) {
|
||||
return Err(TopicRepoError::AlreadyExists(name.to_string()));
|
||||
}
|
||||
let now = Utc::now();
|
||||
let t = Topic {
|
||||
name: name.to_string(),
|
||||
external_subscribable,
|
||||
auth_mode,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
g.insert((app_id, name.to_string()), t.clone());
|
||||
Ok(t)
|
||||
}
|
||||
async fn list(&self, app_id: AppId) -> Result<Vec<Topic>, TopicRepoError> {
|
||||
let g = self.inner.lock().await;
|
||||
let mut v: Vec<Topic> = g
|
||||
.iter()
|
||||
.filter(|((a, _), _)| *a == app_id)
|
||||
.map(|(_, t)| t.clone())
|
||||
.collect();
|
||||
v.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(v)
|
||||
}
|
||||
async fn get(&self, app_id: AppId, name: &str) -> Result<Option<Topic>, TopicRepoError> {
|
||||
Ok(self
|
||||
.inner
|
||||
.lock()
|
||||
.await
|
||||
.get(&(app_id, name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
async fn update(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
external_subscribable: Option<bool>,
|
||||
auth_mode: Option<TopicAuthMode>,
|
||||
) -> Result<Option<Topic>, TopicRepoError> {
|
||||
let mut g = self.inner.lock().await;
|
||||
let Some(t) = g.get_mut(&(app_id, name.to_string())) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(e) = external_subscribable {
|
||||
t.external_subscribable = e;
|
||||
}
|
||||
if let Some(m) = auth_mode {
|
||||
t.auth_mode = m;
|
||||
}
|
||||
t.updated_at = Utc::now();
|
||||
Ok(Some(t.clone()))
|
||||
}
|
||||
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, TopicRepoError> {
|
||||
Ok(self
|
||||
.inner
|
||||
.lock()
|
||||
.await
|
||||
.remove(&(app_id, name.to_string()))
|
||||
.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
struct InMemoryAppRepo(AppId);
|
||||
#[async_trait]
|
||||
impl AppRepository for InMemoryAppRepo {
|
||||
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_for_user(&self, _: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
if id != self.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let now = Utc::now();
|
||||
Ok(Some(App {
|
||||
id,
|
||||
slug: "test".into(),
|
||||
name: "test".into(),
|
||||
description: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}))
|
||||
}
|
||||
async fn get_by_slug(&self, _: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_by_slug_or_history(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Result<Option<crate::app_repo::AppLookup>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn slug_in_history(&self, _: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn create(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: Option<&str>,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn create_with_takeover(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: Option<&str>,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn update(
|
||||
&self,
|
||||
_: AppId,
|
||||
_: Option<&str>,
|
||||
_: Option<Option<&str>>,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn rename_slug(
|
||||
&self,
|
||||
_: AppId,
|
||||
_: &str,
|
||||
_: bool,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn delete(&self, _: AppId) -> Result<(), ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn delete_cascade(&self, _: AppId) -> Result<(), ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn count_scripts_in_app(&self, _: AppId) -> Result<i64, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Grants `AppAdmin` only for `granted_app`; denies elsewhere — used
|
||||
/// for the cross-app isolation test.
|
||||
struct PerAppAuthzRepo {
|
||||
granted_app: AppId,
|
||||
}
|
||||
#[async_trait]
|
||||
impl AuthzRepo for PerAppAuthzRepo {
|
||||
async fn membership(
|
||||
&self,
|
||||
_: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok((app_id == self.granted_app).then_some(AppRole::AppAdmin))
|
||||
}
|
||||
}
|
||||
|
||||
struct DenyAuthzRepo;
|
||||
#[async_trait]
|
||||
impl AuthzRepo for DenyAuthzRepo {
|
||||
async fn membership(&self, _: UserId, _: AppId) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingBroadcaster {
|
||||
dropped: StdMutex<Vec<(AppId, String)>>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl RealtimeBroadcaster for RecordingBroadcaster {
|
||||
async fn subscribe(
|
||||
&self,
|
||||
_: AppId,
|
||||
_: &str,
|
||||
) -> Result<tokio::sync::broadcast::Receiver<RealtimeEvent>, BroadcasterError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn publish(&self, _: AppId, _: &str, _: RealtimeEvent) {}
|
||||
async fn drop_topic(&self, app_id: AppId, topic: &str) {
|
||||
self.dropped
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((app_id, topic.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
fn member() -> Principal {
|
||||
Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Member,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn state(app_id: AppId, authz: Arc<dyn AuthzRepo>) -> (TopicsState, Arc<RecordingBroadcaster>) {
|
||||
let bc = Arc::new(RecordingBroadcaster::default());
|
||||
let state = TopicsState {
|
||||
topics: Arc::new(InMemoryTopicRepo::default()),
|
||||
apps: Arc::new(InMemoryAppRepo(app_id)),
|
||||
authz,
|
||||
broadcaster: bc.clone(),
|
||||
};
|
||||
(state, bc)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_defaults_external_subscribable_false() {
|
||||
let app = AppId::new();
|
||||
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
||||
let (status, Json(topic)) = create_topic(
|
||||
State(s),
|
||||
Extension(member()),
|
||||
Path(app),
|
||||
Json(CreateTopicRequest {
|
||||
name: "chat".into(),
|
||||
external_subscribable: false,
|
||||
auth_mode: TopicAuthMode::Public,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
assert!(!topic.external_subscribable);
|
||||
assert_eq!(topic.name, "chat");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flip_requires_app_admin_role() {
|
||||
let app = AppId::new();
|
||||
// Topic exists; the caller has no role → PATCH is forbidden.
|
||||
let (s, _) = state(app, Arc::new(DenyAuthzRepo));
|
||||
s.topics
|
||||
.create(app, "chat", false, TopicAuthMode::Public)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = update_topic(
|
||||
State(s),
|
||||
Extension(member()),
|
||||
Path((app, "chat".to_string())),
|
||||
Json(UpdateTopicRequest {
|
||||
external_subscribable: Some(true),
|
||||
auth_mode: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, TopicsApiError::Forbidden));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flip_is_its_own_endpoint_and_toggles_external() {
|
||||
// The PATCH handler is a distinct surface from create; flipping
|
||||
// external_subscribable false→true is a single discrete call.
|
||||
let app = AppId::new();
|
||||
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
||||
s.topics
|
||||
.create(app, "chat", false, TopicAuthMode::Public)
|
||||
.await
|
||||
.unwrap();
|
||||
let Json(updated) = update_topic(
|
||||
State(s),
|
||||
Extension(member()),
|
||||
Path((app, "chat".to_string())),
|
||||
Json(UpdateTopicRequest {
|
||||
external_subscribable: Some(true),
|
||||
auth_mode: Some(TopicAuthMode::Token),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(updated.external_subscribable);
|
||||
assert_eq!(updated.auth_mode, TopicAuthMode::Token);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_disconnects_subscribers() {
|
||||
let app = AppId::new();
|
||||
let (s, bc) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
||||
s.topics
|
||||
.create(app, "chat", true, TopicAuthMode::Public)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = delete_topic(
|
||||
State(s),
|
||||
Extension(member()),
|
||||
Path((app, "chat".to_string())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
assert_eq!(
|
||||
bc.dropped.lock().unwrap().as_slice(),
|
||||
&[(app, "chat".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_app_admin_cannot_manage_other_app() {
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
// Caller is admin of app A only; both apps exist via separate state.
|
||||
let authz = Arc::new(PerAppAuthzRepo { granted_app: app_a });
|
||||
// App-B-scoped state, but the caller only has A's grant.
|
||||
let (s, _) = state(app_b, authz);
|
||||
let err = create_topic(
|
||||
State(s),
|
||||
Extension(member()),
|
||||
Path(app_b),
|
||||
Json(CreateTopicRequest {
|
||||
name: "chat".into(),
|
||||
external_subscribable: true,
|
||||
auth_mode: TopicAuthMode::Public,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, TopicsApiError::Forbidden));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pattern_name_rejected() {
|
||||
let app = AppId::new();
|
||||
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
||||
let err = create_topic(
|
||||
State(s),
|
||||
Extension(member()),
|
||||
Path(app),
|
||||
Json(CreateTopicRequest {
|
||||
name: "user.*".into(),
|
||||
external_subscribable: true,
|
||||
auth_mode: TopicAuthMode::Public,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, TopicsApiError::Invalid(_)));
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,11 @@ pub struct TriggerConfig {
|
||||
pub dead_letter_retention_days: u32,
|
||||
/// abandoned-execution retention before GC, in days. Default 7.
|
||||
pub abandoned_retention_days: u32,
|
||||
|
||||
/// Cron scheduler poll cadence, in ms (v1.1.4). Default 30 000 —
|
||||
/// real-world cron precision is per-minute, so a 30s tick is fine.
|
||||
/// Floored at 1s by the scheduler.
|
||||
pub cron_tick_interval_ms: u32,
|
||||
}
|
||||
|
||||
impl TriggerConfig {
|
||||
@@ -69,6 +74,7 @@ impl TriggerConfig {
|
||||
retry_jitter_pct: 20,
|
||||
dead_letter_retention_days: 30,
|
||||
abandoned_retention_days: 7,
|
||||
cron_tick_interval_ms: 30_000,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +97,10 @@ impl TriggerConfig {
|
||||
&mut c.abandoned_retention_days,
|
||||
"PICLOUD_ABANDONED_EXECUTIONS_RETENTION_DAYS",
|
||||
);
|
||||
load_u32(
|
||||
&mut c.cron_tick_interval_ms,
|
||||
"PICLOUD_CRON_TICK_INTERVAL_MS",
|
||||
);
|
||||
c
|
||||
}
|
||||
}
|
||||
@@ -141,6 +151,7 @@ mod tests {
|
||||
assert_eq!(c.retry_jitter_pct, 20);
|
||||
assert_eq!(c.dead_letter_retention_days, 30);
|
||||
assert_eq!(c.abandoned_retention_days, 7);
|
||||
assert_eq!(c.cron_tick_interval_ms, 30_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AdminUserId, AppId, DocsEventOp, KvEventOp, ScriptId, TriggerId};
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, DocsEventOp, FilesEventOp, KvEventOp, ScriptId, TriggerId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
@@ -49,6 +51,14 @@ pub enum TriggerKind {
|
||||
Kv,
|
||||
Docs,
|
||||
DeadLetter,
|
||||
/// v1.1.4.
|
||||
Cron,
|
||||
/// v1.1.5.
|
||||
Files,
|
||||
/// v1.1.5.
|
||||
Pubsub,
|
||||
/// v1.1.7. Inbound email via the webhook receiver.
|
||||
Email,
|
||||
}
|
||||
|
||||
impl TriggerKind {
|
||||
@@ -58,6 +68,10 @@ impl TriggerKind {
|
||||
Self::Kv => "kv",
|
||||
Self::Docs => "docs",
|
||||
Self::DeadLetter => "dead_letter",
|
||||
Self::Cron => "cron",
|
||||
Self::Files => "files",
|
||||
Self::Pubsub => "pubsub",
|
||||
Self::Email => "email",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +81,10 @@ impl TriggerKind {
|
||||
"kv" => Some(Self::Kv),
|
||||
"docs" => Some(Self::Docs),
|
||||
"dead_letter" => Some(Self::DeadLetter),
|
||||
"cron" => Some(Self::Cron),
|
||||
"files" => Some(Self::Files),
|
||||
"pubsub" => Some(Self::Pubsub),
|
||||
"email" => Some(Self::Email),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -108,6 +126,25 @@ pub enum TriggerDetails {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
script_id_filter: Option<ScriptId>,
|
||||
},
|
||||
/// v1.1.4. The 6-field cron schedule + IANA timezone the trigger
|
||||
/// fires on, plus the last enqueue time (for dashboard display).
|
||||
Cron {
|
||||
schedule: String,
|
||||
timezone: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
last_fired_at: Option<DateTime<Utc>>,
|
||||
},
|
||||
/// v1.1.5. Same shape as KV/docs: a collection glob + op subset.
|
||||
Files {
|
||||
collection_glob: String,
|
||||
ops: Vec<FilesEventOp>,
|
||||
},
|
||||
/// v1.1.5. A topic pattern: exact, `<prefix>.*`, or `*`.
|
||||
Pubsub { topic_pattern: String },
|
||||
/// v1.1.7. Inbound email. The HMAC `inbound_secret` is never
|
||||
/// surfaced (it's encrypted at rest); we expose only whether one is
|
||||
/// configured so the admin UI can show "signed" vs "unsigned".
|
||||
Email { has_inbound_secret: bool },
|
||||
}
|
||||
|
||||
/// Create payload for a KV trigger. Defaults applied at the admin
|
||||
@@ -148,6 +185,88 @@ pub struct CreateDeadLetterTrigger {
|
||||
pub registered_by_principal: AdminUserId,
|
||||
}
|
||||
|
||||
/// Create payload for a cron trigger (v1.1.4). `schedule` is a 6-field
|
||||
/// cron expression and `timezone` an IANA name; both are validated
|
||||
/// (by the admin endpoint and defensively by the repo) before insert.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreateCronTrigger {
|
||||
pub script_id: ScriptId,
|
||||
pub schedule: String,
|
||||
pub timezone: String,
|
||||
pub dispatch_mode: TriggerDispatchMode,
|
||||
pub retry_max_attempts: u32,
|
||||
pub retry_backoff: BackoffShape,
|
||||
pub retry_base_ms: u32,
|
||||
pub registered_by_principal: AdminUserId,
|
||||
}
|
||||
|
||||
/// Create payload for a files trigger (v1.1.5). Same shape as KV with
|
||||
/// `FilesEventOp` ops.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreateFilesTrigger {
|
||||
pub script_id: ScriptId,
|
||||
pub collection_glob: String,
|
||||
pub ops: Vec<FilesEventOp>,
|
||||
pub dispatch_mode: TriggerDispatchMode,
|
||||
pub retry_max_attempts: u32,
|
||||
pub retry_backoff: BackoffShape,
|
||||
pub retry_base_ms: u32,
|
||||
pub registered_by_principal: AdminUserId,
|
||||
}
|
||||
|
||||
/// One match for the dispatcher's files trigger fan-out lookup
|
||||
/// (v1.1.5). Same shape as `KvTriggerMatch`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FilesTriggerMatch {
|
||||
pub trigger_id: TriggerId,
|
||||
pub script_id: ScriptId,
|
||||
pub dispatch_mode: TriggerDispatchMode,
|
||||
pub retry_max_attempts: u32,
|
||||
pub retry_backoff: BackoffShape,
|
||||
pub retry_base_ms: u32,
|
||||
pub registered_by_principal: AdminUserId,
|
||||
}
|
||||
|
||||
/// Create payload for a pubsub trigger (v1.1.5). `topic_pattern` is
|
||||
/// validated (exact / `<prefix>.*` / `*`) before insert.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreatePubsubTrigger {
|
||||
pub script_id: ScriptId,
|
||||
pub topic_pattern: String,
|
||||
pub dispatch_mode: TriggerDispatchMode,
|
||||
pub retry_max_attempts: u32,
|
||||
pub retry_backoff: BackoffShape,
|
||||
pub retry_base_ms: u32,
|
||||
pub registered_by_principal: AdminUserId,
|
||||
}
|
||||
|
||||
/// Create payload for an email trigger (v1.1.7). `inbound_secret_*` is
|
||||
/// the already-encrypted HMAC secret (sealed by the admin layer with the
|
||||
/// process master key) or `None` for an unsigned trigger.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreateEmailTrigger {
|
||||
pub script_id: ScriptId,
|
||||
pub inbound_secret_encrypted: Option<Vec<u8>>,
|
||||
pub inbound_secret_nonce: Option<Vec<u8>>,
|
||||
pub registered_by_principal: AdminUserId,
|
||||
}
|
||||
|
||||
/// What the inbound-email webhook receiver needs to verify + dispatch a
|
||||
/// POST. Returned by `email_inbound_target`; `None` when the trigger
|
||||
/// doesn't exist or isn't `kind = 'email'`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EmailInboundTarget {
|
||||
pub app_id: AppId,
|
||||
pub script_id: ScriptId,
|
||||
pub enabled: bool,
|
||||
pub dispatch_mode: TriggerDispatchMode,
|
||||
pub registered_by_principal: AdminUserId,
|
||||
/// Encrypted HMAC secret + nonce; both `None` for an unsigned
|
||||
/// trigger (accepts any POST).
|
||||
pub inbound_secret_encrypted: Option<Vec<u8>>,
|
||||
pub inbound_secret_nonce: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// One match for the dispatcher's "which KV triggers fire on this
|
||||
/// event" lookup. Carries everything the dispatcher needs to construct
|
||||
/// the outbox row.
|
||||
@@ -206,6 +325,46 @@ pub trait TriggerRepo: Send + Sync {
|
||||
req: CreateDeadLetterTrigger,
|
||||
) -> Result<Trigger, TriggerRepoError>;
|
||||
|
||||
/// v1.1.4. `schedule` + `timezone` are validated before insert; an
|
||||
/// invalid expression or unknown IANA name returns
|
||||
/// `TriggerRepoError::Invalid`.
|
||||
async fn create_cron_trigger(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
req: CreateCronTrigger,
|
||||
) -> Result<Trigger, TriggerRepoError>;
|
||||
|
||||
/// v1.1.5.
|
||||
async fn create_files_trigger(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
req: CreateFilesTrigger,
|
||||
) -> Result<Trigger, TriggerRepoError>;
|
||||
|
||||
/// v1.1.5. `topic_pattern` is validated before insert.
|
||||
async fn create_pubsub_trigger(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
req: CreatePubsubTrigger,
|
||||
) -> Result<Trigger, TriggerRepoError>;
|
||||
|
||||
/// v1.1.7. Inbound email trigger. The `inbound_secret` is stored
|
||||
/// already-encrypted (the admin layer seals it).
|
||||
async fn create_email_trigger(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
req: CreateEmailTrigger,
|
||||
) -> Result<Trigger, TriggerRepoError>;
|
||||
|
||||
/// v1.1.7. The webhook receiver's hot-path lookup: resolve a
|
||||
/// `kind = 'email'` trigger to its app, handler script, dispatch
|
||||
/// mode, and (encrypted) HMAC secret. Returns `None` when the
|
||||
/// trigger doesn't exist or isn't an email trigger.
|
||||
async fn email_inbound_target(
|
||||
&self,
|
||||
trigger_id: TriggerId,
|
||||
) -> Result<Option<EmailInboundTarget>, TriggerRepoError>;
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError>;
|
||||
|
||||
async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError>;
|
||||
@@ -233,6 +392,16 @@ pub trait TriggerRepo: Send + Sync {
|
||||
op: DocsEventOp,
|
||||
) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError>;
|
||||
|
||||
/// Dispatcher hot path for files fan-out (v1.1.5). Mirrors the KV
|
||||
/// fan-out logic: pull every enabled files trigger, filter glob +
|
||||
/// ops in Rust (empty ops array means "any op").
|
||||
async fn list_matching_files(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
op: FilesEventOp,
|
||||
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError>;
|
||||
|
||||
/// Dispatcher hot path for dead-letter fan-out. Filters: source
|
||||
/// (or any-source), originating trigger_id (or any), originating
|
||||
/// script_id (or any). Each filter is "match OR is_null".
|
||||
@@ -453,6 +622,280 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_cron_trigger(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
req: CreateCronTrigger,
|
||||
) -> Result<Trigger, TriggerRepoError> {
|
||||
// Defense-in-depth validation (the admin endpoint validates too).
|
||||
crate::cron_scheduler::validate_schedule(&req.schedule)
|
||||
.map_err(|e| TriggerRepoError::Invalid(format!("invalid cron schedule: {e}")))?;
|
||||
crate::cron_scheduler::validate_timezone(&req.timezone)
|
||||
.map_err(|e| TriggerRepoError::Invalid(format!("invalid timezone: {e}")))?;
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let parent: TriggerRow = sqlx::query_as(
|
||||
"INSERT INTO triggers ( \
|
||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'cron', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(req.script_id.into_inner())
|
||||
.bind(req.dispatch_mode.as_str())
|
||||
.bind(i32::try_from(req.retry_max_attempts).unwrap_or(3))
|
||||
.bind(req.retry_backoff.as_str())
|
||||
.bind(i32::try_from(req.retry_base_ms).unwrap_or(1000))
|
||||
.bind(req.registered_by_principal.into_inner())
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO cron_trigger_details (trigger_id, schedule, timezone) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(parent.id)
|
||||
.bind(&req.schedule)
|
||||
.bind(&req.timezone)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Trigger {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
kind: TriggerKind::Cron,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
.unwrap_or(BackoffShape::Exponential),
|
||||
retry_base_ms: u32::try_from(parent.retry_base_ms).unwrap_or(1000),
|
||||
registered_by_principal: parent.registered_by_principal.into(),
|
||||
created_at: parent.created_at,
|
||||
updated_at: parent.updated_at,
|
||||
details: TriggerDetails::Cron {
|
||||
schedule: req.schedule,
|
||||
timezone: req.timezone,
|
||||
last_fired_at: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_files_trigger(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
req: CreateFilesTrigger,
|
||||
) -> Result<Trigger, TriggerRepoError> {
|
||||
if req.collection_glob.is_empty() {
|
||||
return Err(TriggerRepoError::Invalid(
|
||||
"collection_glob must not be empty".into(),
|
||||
));
|
||||
}
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let parent: TriggerRow = sqlx::query_as(
|
||||
"INSERT INTO triggers ( \
|
||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'files', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(req.script_id.into_inner())
|
||||
.bind(req.dispatch_mode.as_str())
|
||||
.bind(i32::try_from(req.retry_max_attempts).unwrap_or(3))
|
||||
.bind(req.retry_backoff.as_str())
|
||||
.bind(i32::try_from(req.retry_base_ms).unwrap_or(1000))
|
||||
.bind(req.registered_by_principal.into_inner())
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let ops_str: Vec<String> = req.ops.iter().map(|o| o.as_str().to_string()).collect();
|
||||
sqlx::query(
|
||||
"INSERT INTO files_trigger_details (trigger_id, collection_glob, ops) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(parent.id)
|
||||
.bind(&req.collection_glob)
|
||||
.bind(&ops_str)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Trigger {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
kind: TriggerKind::Files,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
.unwrap_or(BackoffShape::Exponential),
|
||||
retry_base_ms: u32::try_from(parent.retry_base_ms).unwrap_or(1000),
|
||||
registered_by_principal: parent.registered_by_principal.into(),
|
||||
created_at: parent.created_at,
|
||||
updated_at: parent.updated_at,
|
||||
details: TriggerDetails::Files {
|
||||
collection_glob: req.collection_glob,
|
||||
ops: req.ops,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_pubsub_trigger(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
req: CreatePubsubTrigger,
|
||||
) -> Result<Trigger, TriggerRepoError> {
|
||||
// Defense-in-depth validation (the admin endpoint validates too).
|
||||
picloud_shared::validate_topic_pattern(&req.topic_pattern)
|
||||
.map_err(TriggerRepoError::Invalid)?;
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let parent: TriggerRow = sqlx::query_as(
|
||||
"INSERT INTO triggers ( \
|
||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'pubsub', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(req.script_id.into_inner())
|
||||
.bind(req.dispatch_mode.as_str())
|
||||
.bind(i32::try_from(req.retry_max_attempts).unwrap_or(3))
|
||||
.bind(req.retry_backoff.as_str())
|
||||
.bind(i32::try_from(req.retry_base_ms).unwrap_or(1000))
|
||||
.bind(req.registered_by_principal.into_inner())
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO pubsub_trigger_details (trigger_id, topic_pattern) VALUES ($1, $2)",
|
||||
)
|
||||
.bind(parent.id)
|
||||
.bind(&req.topic_pattern)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Trigger {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
kind: TriggerKind::Pubsub,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
.unwrap_or(BackoffShape::Exponential),
|
||||
retry_base_ms: u32::try_from(parent.retry_base_ms).unwrap_or(1000),
|
||||
registered_by_principal: parent.registered_by_principal.into(),
|
||||
created_at: parent.created_at,
|
||||
updated_at: parent.updated_at,
|
||||
details: TriggerDetails::Pubsub {
|
||||
topic_pattern: req.topic_pattern,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_email_trigger(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
req: CreateEmailTrigger,
|
||||
) -> Result<Trigger, TriggerRepoError> {
|
||||
let has_inbound_secret = req.inbound_secret_encrypted.is_some();
|
||||
let mut tx = self.pool.begin().await?;
|
||||
// Inbound email is delivered async like every other fan-out
|
||||
// event; the receiver enqueues an outbox row the dispatcher
|
||||
// picks up. Retry settings use the standard defaults.
|
||||
let parent: TriggerRow = sqlx::query_as(
|
||||
"INSERT INTO triggers ( \
|
||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(req.script_id.into_inner())
|
||||
.bind(req.registered_by_principal.into_inner())
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO email_trigger_details \
|
||||
(trigger_id, inbound_secret_encrypted, inbound_secret_nonce) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(parent.id)
|
||||
.bind(req.inbound_secret_encrypted.as_deref())
|
||||
.bind(req.inbound_secret_nonce.as_deref())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Trigger {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
kind: TriggerKind::Email,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
.unwrap_or(BackoffShape::Exponential),
|
||||
retry_base_ms: u32::try_from(parent.retry_base_ms).unwrap_or(1000),
|
||||
registered_by_principal: parent.registered_by_principal.into(),
|
||||
created_at: parent.created_at,
|
||||
updated_at: parent.updated_at,
|
||||
details: TriggerDetails::Email { has_inbound_secret },
|
||||
})
|
||||
}
|
||||
|
||||
async fn email_inbound_target(
|
||||
&self,
|
||||
trigger_id: TriggerId,
|
||||
) -> Result<Option<EmailInboundTarget>, TriggerRepoError> {
|
||||
let row: Option<EmailInboundRow> = sqlx::query_as(
|
||||
"SELECT t.app_id, t.script_id, t.enabled, t.dispatch_mode, \
|
||||
t.registered_by_principal, \
|
||||
d.inbound_secret_encrypted, d.inbound_secret_nonce \
|
||||
FROM triggers t \
|
||||
JOIN email_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.id = $1 AND t.kind = 'email'",
|
||||
)
|
||||
.bind(trigger_id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|r| EmailInboundTarget {
|
||||
app_id: r.app_id.into(),
|
||||
script_id: r.script_id.into(),
|
||||
enabled: r.enabled,
|
||||
dispatch_mode: dispatch_from_str(&r.dispatch_mode),
|
||||
registered_by_principal: r.registered_by_principal.into(),
|
||||
inbound_secret_encrypted: r.inbound_secret_encrypted,
|
||||
inbound_secret_nonce: r.inbound_secret_nonce,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
|
||||
let parents: Vec<TriggerRow> = sqlx::query_as(
|
||||
"SELECT id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
@@ -591,6 +1034,51 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn list_matching_files(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
op: FilesEventOp,
|
||||
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError> {
|
||||
// Mirrors list_matching_kv: pull every enabled files trigger,
|
||||
// filter glob + ops in Rust (empty ops array means "any op").
|
||||
let rows: Vec<KvMatchRow> = sqlx::query_as(
|
||||
"SELECT t.id, t.script_id, t.dispatch_mode, \
|
||||
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
|
||||
t.registered_by_principal, \
|
||||
d.collection_glob, d.ops \
|
||||
FROM triggers t \
|
||||
JOIN files_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.app_id = $1 AND t.kind = 'files' AND t.enabled = TRUE",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let op_str = op.as_str();
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
if !collection_matches(&r.collection_glob, collection) {
|
||||
continue;
|
||||
}
|
||||
let any_op = r.ops.is_empty();
|
||||
if !any_op && !r.ops.iter().any(|o| o == op_str) {
|
||||
continue;
|
||||
}
|
||||
out.push(FilesTriggerMatch {
|
||||
trigger_id: r.id.into(),
|
||||
script_id: r.script_id.into(),
|
||||
dispatch_mode: dispatch_from_str(&r.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(r.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&r.retry_backoff)
|
||||
.unwrap_or(BackoffShape::Exponential),
|
||||
retry_base_ms: u32::try_from(r.retry_base_ms).unwrap_or(1000),
|
||||
registered_by_principal: r.registered_by_principal.into(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn list_matching_dead_letter(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
@@ -627,6 +1115,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, TriggerRepoError> {
|
||||
let kind = TriggerKind::from_wire(&parent.kind).ok_or_else(|| {
|
||||
TriggerRepoError::Invalid(format!("unknown trigger kind {}", parent.kind))
|
||||
@@ -681,6 +1170,59 @@ async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, Trigg
|
||||
script_id_filter: row.script_id_filter.map(Into::into),
|
||||
}
|
||||
}
|
||||
TriggerKind::Cron => {
|
||||
let row: CronDetailRow = sqlx::query_as(
|
||||
"SELECT schedule, timezone, last_fired_at \
|
||||
FROM cron_trigger_details WHERE trigger_id = $1",
|
||||
)
|
||||
.bind(parent.id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
TriggerDetails::Cron {
|
||||
schedule: row.schedule,
|
||||
timezone: row.timezone,
|
||||
last_fired_at: row.last_fired_at,
|
||||
}
|
||||
}
|
||||
TriggerKind::Files => {
|
||||
let row: KvDetailRow = sqlx::query_as(
|
||||
"SELECT collection_glob, ops FROM files_trigger_details WHERE trigger_id = $1",
|
||||
)
|
||||
.bind(parent.id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
let ops = row
|
||||
.ops
|
||||
.iter()
|
||||
.filter_map(|s| FilesEventOp::from_wire(s))
|
||||
.collect();
|
||||
TriggerDetails::Files {
|
||||
collection_glob: row.collection_glob,
|
||||
ops,
|
||||
}
|
||||
}
|
||||
TriggerKind::Pubsub => {
|
||||
let row: PubsubDetailRow = sqlx::query_as(
|
||||
"SELECT topic_pattern FROM pubsub_trigger_details WHERE trigger_id = $1",
|
||||
)
|
||||
.bind(parent.id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
TriggerDetails::Pubsub {
|
||||
topic_pattern: row.topic_pattern,
|
||||
}
|
||||
}
|
||||
TriggerKind::Email => {
|
||||
let row: EmailDetailRow = sqlx::query_as(
|
||||
"SELECT inbound_secret_encrypted FROM email_trigger_details WHERE trigger_id = $1",
|
||||
)
|
||||
.bind(parent.id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
TriggerDetails::Email {
|
||||
has_inbound_secret: row.inbound_secret_encrypted.is_some(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Trigger {
|
||||
@@ -746,6 +1288,34 @@ struct KvDetailRow {
|
||||
ops: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CronDetailRow {
|
||||
schedule: String,
|
||||
timezone: String,
|
||||
last_fired_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct PubsubDetailRow {
|
||||
topic_pattern: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct EmailDetailRow {
|
||||
inbound_secret_encrypted: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct EmailInboundRow {
|
||||
app_id: Uuid,
|
||||
script_id: Uuid,
|
||||
enabled: bool,
|
||||
dispatch_mode: String,
|
||||
registered_by_principal: Uuid,
|
||||
inbound_secret_encrypted: Option<Vec<u8>>,
|
||||
inbound_secret_nonce: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
struct DlDetailRow {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,16 @@
|
||||
|
||||
## tables
|
||||
|
||||
table: abandoned_executions
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
app_id: uuid NOT NULL
|
||||
outbox_id: uuid NOT NULL
|
||||
script_id: uuid NULL
|
||||
inbox_id: uuid NOT NULL
|
||||
status_code: integer NOT NULL
|
||||
result_summary: text NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: admin_sessions
|
||||
token_hash: text NOT NULL
|
||||
user_id: uuid NOT NULL
|
||||
@@ -48,6 +58,14 @@ table: app_members
|
||||
role: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: app_secrets
|
||||
app_id: uuid NOT NULL
|
||||
realtime_signing_key: bytea NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
realtime_signing_key_encrypted: bytea NULL
|
||||
realtime_signing_key_nonce: bytea NULL
|
||||
|
||||
table: app_slug_history
|
||||
slug: text NOT NULL
|
||||
current_app_id: uuid NOT NULL
|
||||
@@ -61,6 +79,53 @@ table: apps
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: cron_trigger_details
|
||||
trigger_id: uuid NOT NULL
|
||||
schedule: text NOT NULL
|
||||
timezone: text NOT NULL default='UTC'::text
|
||||
last_fired_at: timestamp with time zone NULL
|
||||
|
||||
table: dead_letter_trigger_details
|
||||
trigger_id: uuid NOT NULL
|
||||
source_filter: text NULL
|
||||
trigger_id_filter: uuid NULL
|
||||
script_id_filter: uuid NULL
|
||||
|
||||
table: dead_letters
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
app_id: uuid NOT NULL
|
||||
original_event_id: uuid NOT NULL
|
||||
source: text NOT NULL
|
||||
op: text NOT NULL
|
||||
trigger_id: uuid NULL
|
||||
script_id: uuid NULL
|
||||
payload: jsonb NOT NULL
|
||||
attempt_count: integer NOT NULL
|
||||
first_attempt_at: timestamp with time zone NOT NULL
|
||||
last_attempt_at: timestamp with time zone NOT NULL
|
||||
last_error: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
resolved_at: timestamp with time zone NULL
|
||||
resolution: text NULL
|
||||
|
||||
table: docs
|
||||
app_id: uuid NOT NULL
|
||||
collection: text NOT NULL
|
||||
id: uuid NOT NULL
|
||||
data: jsonb NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: docs_trigger_details
|
||||
trigger_id: uuid NOT NULL
|
||||
collection_glob: text NOT NULL
|
||||
ops: ARRAY NOT NULL
|
||||
|
||||
table: email_trigger_details
|
||||
trigger_id: uuid NOT NULL
|
||||
inbound_secret_encrypted: bytea NULL
|
||||
inbound_secret_nonce: bytea NULL
|
||||
|
||||
table: execution_logs
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
script_id: uuid NOT NULL
|
||||
@@ -76,6 +141,56 @@ table: execution_logs
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
app_id: uuid NOT NULL
|
||||
|
||||
table: files
|
||||
app_id: uuid NOT NULL
|
||||
collection: text NOT NULL
|
||||
id: uuid NOT NULL
|
||||
name: text NOT NULL
|
||||
content_type: text NOT NULL
|
||||
size_bytes: bigint NOT NULL
|
||||
checksum_sha256: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: files_trigger_details
|
||||
trigger_id: uuid NOT NULL
|
||||
collection_glob: text NOT NULL
|
||||
ops: ARRAY NOT NULL
|
||||
|
||||
table: kv_entries
|
||||
app_id: uuid NOT NULL
|
||||
collection: text NOT NULL
|
||||
key: text NOT NULL
|
||||
value: jsonb NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: kv_trigger_details
|
||||
trigger_id: uuid NOT NULL
|
||||
collection_glob: text NOT NULL
|
||||
ops: ARRAY NOT NULL
|
||||
|
||||
table: outbox
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
app_id: uuid NOT NULL
|
||||
source_kind: text NOT NULL
|
||||
trigger_id: uuid NULL
|
||||
script_id: uuid NULL
|
||||
reply_to: uuid NULL
|
||||
payload: jsonb NOT NULL
|
||||
origin_principal: uuid NULL
|
||||
trigger_depth: integer NOT NULL default=0
|
||||
root_execution_id: uuid NULL
|
||||
attempt_count: integer NOT NULL default=0
|
||||
next_attempt_at: timestamp with time zone NOT NULL default=now()
|
||||
claimed_at: timestamp with time zone NULL
|
||||
claimed_by: text NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: pubsub_trigger_details
|
||||
trigger_id: uuid NOT NULL
|
||||
topic_pattern: text NOT NULL
|
||||
|
||||
table: routes
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
script_id: uuid NOT NULL
|
||||
@@ -87,6 +202,13 @@ table: routes
|
||||
method: text NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
app_id: uuid NOT NULL
|
||||
dispatch_mode: text NOT NULL default='sync'::text
|
||||
|
||||
table: script_imports
|
||||
app_id: uuid NOT NULL
|
||||
importer_script_id: uuid NOT NULL
|
||||
imported_script_id: uuid NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: scripts
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
@@ -100,9 +222,44 @@ table: scripts
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
sandbox: jsonb NOT NULL default='{}'::jsonb
|
||||
app_id: uuid NOT NULL
|
||||
kind: text NOT NULL default='endpoint'::text
|
||||
|
||||
table: secrets
|
||||
app_id: uuid NOT NULL
|
||||
name: text NOT NULL
|
||||
encrypted_value: bytea NOT NULL
|
||||
nonce: bytea NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: topics
|
||||
app_id: uuid NOT NULL
|
||||
name: text NOT NULL
|
||||
external_subscribable: boolean NOT NULL default=false
|
||||
auth_mode: text NOT NULL default='public'::text
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: triggers
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
app_id: uuid NOT NULL
|
||||
script_id: uuid NOT NULL
|
||||
kind: text NOT NULL
|
||||
enabled: boolean NOT NULL default=true
|
||||
dispatch_mode: text NOT NULL default='async'::text
|
||||
retry_max_attempts: integer NOT NULL
|
||||
retry_backoff: text NOT NULL
|
||||
retry_base_ms: integer NOT NULL
|
||||
registered_by_principal: uuid NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
## indexes
|
||||
|
||||
indexes on abandoned_executions:
|
||||
abandoned_executions_pkey: public.abandoned_executions USING btree (id)
|
||||
idx_abandoned_executions_gc: public.abandoned_executions USING btree (created_at)
|
||||
|
||||
indexes on admin_sessions:
|
||||
admin_sessions_expiry_idx: public.admin_sessions USING btree (expires_at)
|
||||
admin_sessions_pkey: public.admin_sessions USING btree (token_hash)
|
||||
@@ -128,6 +285,9 @@ indexes on app_members:
|
||||
app_members_pkey: public.app_members USING btree (app_id, user_id)
|
||||
app_members_user_id_idx: public.app_members USING btree (user_id)
|
||||
|
||||
indexes on app_secrets:
|
||||
app_secrets_pkey: public.app_secrets USING btree (app_id)
|
||||
|
||||
indexes on app_slug_history:
|
||||
app_slug_history_pkey: public.app_slug_history USING btree (slug)
|
||||
|
||||
@@ -135,11 +295,56 @@ indexes on apps:
|
||||
apps_pkey: public.apps USING btree (id)
|
||||
apps_slug_key: public.apps USING btree (slug)
|
||||
|
||||
indexes on cron_trigger_details:
|
||||
cron_trigger_details_pkey: public.cron_trigger_details USING btree (trigger_id)
|
||||
idx_cron_triggers_due: public.cron_trigger_details USING btree (last_fired_at)
|
||||
|
||||
indexes on dead_letter_trigger_details:
|
||||
dead_letter_trigger_details_pkey: public.dead_letter_trigger_details USING btree (trigger_id)
|
||||
|
||||
indexes on dead_letters:
|
||||
dead_letters_pkey: public.dead_letters USING btree (id)
|
||||
idx_dead_letters_app_unresolved: public.dead_letters USING btree (app_id) WHERE (resolved_at IS NULL)
|
||||
idx_dead_letters_gc: public.dead_letters USING btree (created_at)
|
||||
|
||||
indexes on docs:
|
||||
docs_pkey: public.docs USING btree (app_id, collection, id)
|
||||
idx_docs_app_collection: public.docs USING btree (app_id, collection)
|
||||
idx_docs_data_gin: public.docs USING gin (data jsonb_path_ops)
|
||||
|
||||
indexes on docs_trigger_details:
|
||||
docs_trigger_details_pkey: public.docs_trigger_details USING btree (trigger_id)
|
||||
|
||||
indexes on email_trigger_details:
|
||||
email_trigger_details_pkey: public.email_trigger_details USING btree (trigger_id)
|
||||
|
||||
indexes on execution_logs:
|
||||
execution_logs_app_id_created_at_idx: public.execution_logs USING btree (app_id, created_at DESC)
|
||||
execution_logs_pkey: public.execution_logs USING btree (id)
|
||||
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
|
||||
|
||||
indexes on files:
|
||||
files_pkey: public.files USING btree (app_id, collection, id)
|
||||
idx_files_app_collection: public.files USING btree (app_id, collection)
|
||||
|
||||
indexes on files_trigger_details:
|
||||
files_trigger_details_pkey: public.files_trigger_details USING btree (trigger_id)
|
||||
|
||||
indexes on kv_entries:
|
||||
idx_kv_entries_app_collection: public.kv_entries USING btree (app_id, collection)
|
||||
kv_entries_pkey: public.kv_entries USING btree (app_id, collection, key)
|
||||
|
||||
indexes on kv_trigger_details:
|
||||
kv_trigger_details_pkey: public.kv_trigger_details USING btree (trigger_id)
|
||||
|
||||
indexes on outbox:
|
||||
idx_outbox_app: public.outbox USING btree (app_id)
|
||||
idx_outbox_due: public.outbox USING btree (next_attempt_at) WHERE (claimed_at IS NULL)
|
||||
outbox_pkey: public.outbox USING btree (id)
|
||||
|
||||
indexes on pubsub_trigger_details:
|
||||
pubsub_trigger_details_pkey: public.pubsub_trigger_details USING btree (trigger_id)
|
||||
|
||||
indexes on routes:
|
||||
routes_app_id_idx: public.routes USING btree (app_id)
|
||||
routes_lookup_idx: public.routes USING btree (host_kind, host)
|
||||
@@ -147,13 +352,35 @@ indexes on routes:
|
||||
routes_script_id_idx: public.routes USING btree (script_id)
|
||||
routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text))
|
||||
|
||||
indexes on script_imports:
|
||||
idx_script_imports_app: public.script_imports USING btree (app_id)
|
||||
idx_script_imports_imported: public.script_imports USING btree (imported_script_id)
|
||||
script_imports_pkey: public.script_imports USING btree (importer_script_id, imported_script_id)
|
||||
|
||||
indexes on scripts:
|
||||
idx_scripts_app_kind: public.scripts USING btree (app_id, kind)
|
||||
scripts_app_id_idx: public.scripts USING btree (app_id)
|
||||
scripts_name_uidx: public.scripts USING btree (app_id, lower(name))
|
||||
scripts_pkey: public.scripts USING btree (id)
|
||||
|
||||
indexes on secrets:
|
||||
idx_secrets_app: public.secrets USING btree (app_id)
|
||||
secrets_pkey: public.secrets USING btree (app_id, name)
|
||||
|
||||
indexes on topics:
|
||||
topics_pkey: public.topics USING btree (app_id, name)
|
||||
|
||||
indexes on triggers:
|
||||
idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE (enabled = true)
|
||||
idx_triggers_app_pubsub_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (kind = 'pubsub'::text))
|
||||
triggers_pkey: public.triggers USING btree (id)
|
||||
|
||||
## constraints
|
||||
|
||||
constraints on abandoned_executions:
|
||||
[FOREIGN KEY] abandoned_executions_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] abandoned_executions_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on admin_sessions:
|
||||
[FOREIGN KEY] admin_sessions_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] admin_sessions_pkey: PRIMARY KEY (token_hash)
|
||||
@@ -181,6 +408,10 @@ constraints on app_members:
|
||||
[FOREIGN KEY] app_members_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] app_members_pkey: PRIMARY KEY (app_id, user_id)
|
||||
|
||||
constraints on app_secrets:
|
||||
[FOREIGN KEY] app_secrets_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] app_secrets_pkey: PRIMARY KEY (app_id)
|
||||
|
||||
constraints on app_slug_history:
|
||||
[FOREIGN KEY] app_slug_history_current_app_id_fkey: FOREIGN KEY (current_app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] app_slug_history_pkey: PRIMARY KEY (slug)
|
||||
@@ -189,25 +420,102 @@ constraints on apps:
|
||||
[PRIMARY KEY] apps_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] apps_slug_key: UNIQUE (slug)
|
||||
|
||||
constraints on cron_trigger_details:
|
||||
[FOREIGN KEY] cron_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] cron_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||
|
||||
constraints on dead_letter_trigger_details:
|
||||
[FOREIGN KEY] dead_letter_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] dead_letter_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||
|
||||
constraints on dead_letters:
|
||||
[CHECK] dead_letters_resolution_check: CHECK ((resolution = ANY (ARRAY['replayed'::text, 'ignored'::text, 'handled_by_script'::text, 'handler_failed'::text])))
|
||||
[FOREIGN KEY] dead_letters_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] dead_letters_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on docs:
|
||||
[FOREIGN KEY] docs_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] docs_pkey: PRIMARY KEY (app_id, collection, id)
|
||||
|
||||
constraints on docs_trigger_details:
|
||||
[FOREIGN KEY] docs_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] docs_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||
|
||||
constraints on email_trigger_details:
|
||||
[FOREIGN KEY] email_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] email_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||
|
||||
constraints on execution_logs:
|
||||
[CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text])))
|
||||
[FOREIGN KEY] execution_logs_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on files:
|
||||
[FOREIGN KEY] files_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] files_pkey: PRIMARY KEY (app_id, collection, id)
|
||||
|
||||
constraints on files_trigger_details:
|
||||
[FOREIGN KEY] files_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] files_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||
|
||||
constraints on kv_entries:
|
||||
[FOREIGN KEY] kv_entries_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] kv_entries_pkey: PRIMARY KEY (app_id, collection, key)
|
||||
|
||||
constraints on kv_trigger_details:
|
||||
[FOREIGN KEY] kv_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] kv_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||
|
||||
constraints on outbox:
|
||||
[CHECK] outbox_source_kind_check: CHECK ((source_kind = ANY (ARRAY['http'::text, 'kv'::text, 'dead_letter'::text, 'docs'::text, 'cron'::text, 'files'::text, 'pubsub'::text, 'email'::text])))
|
||||
[FOREIGN KEY] outbox_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] outbox_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on pubsub_trigger_details:
|
||||
[FOREIGN KEY] pubsub_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] pubsub_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||
|
||||
constraints on routes:
|
||||
[CHECK] routes_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
|
||||
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
||||
[CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
|
||||
[FOREIGN KEY] routes_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on script_imports:
|
||||
[FOREIGN KEY] script_imports_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] script_imports_imported_script_id_fkey: FOREIGN KEY (imported_script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] script_imports_importer_script_id_fkey: FOREIGN KEY (importer_script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] script_imports_pkey: PRIMARY KEY (importer_script_id, imported_script_id)
|
||||
|
||||
constraints on scripts:
|
||||
[CHECK] scripts_kind_check: CHECK ((kind = ANY (ARRAY['endpoint'::text, 'module'::text])))
|
||||
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
|
||||
[CHECK] scripts_module_name_shape: CHECK (((kind <> 'module'::text) OR (name ~ '^[a-zA-Z_][a-zA-Z0-9_]{0,63}$'::text)))
|
||||
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
|
||||
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
|
||||
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on secrets:
|
||||
[FOREIGN KEY] secrets_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] secrets_pkey: PRIMARY KEY (app_id, name)
|
||||
|
||||
constraints on topics:
|
||||
[CHECK] topics_auth_mode_check: CHECK ((auth_mode = ANY (ARRAY['public'::text, 'token'::text])))
|
||||
[FOREIGN KEY] topics_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] topics_pkey: PRIMARY KEY (app_id, name)
|
||||
|
||||
constraints on triggers:
|
||||
[CHECK] triggers_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
|
||||
[CHECK] triggers_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'dead_letter'::text, 'docs'::text, 'cron'::text, 'files'::text, 'pubsub'::text, 'email'::text])))
|
||||
[CHECK] triggers_retry_backoff_check: CHECK ((retry_backoff = ANY (ARRAY['exponential'::text, 'linear'::text, 'constant'::text])))
|
||||
[FOREIGN KEY] triggers_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] triggers_registered_by_principal_fkey: FOREIGN KEY (registered_by_principal) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] triggers_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] triggers_pkey: PRIMARY KEY (id)
|
||||
|
||||
## applied migrations
|
||||
0001: init
|
||||
0002: sandbox
|
||||
@@ -215,3 +523,22 @@ constraints on scripts:
|
||||
0004: admin auth
|
||||
0005: apps
|
||||
0006: users authz
|
||||
0007: kv
|
||||
0008: triggers
|
||||
0009: outbox
|
||||
0010: dead letters
|
||||
0011: abandoned executions
|
||||
0012: routes dispatch mode
|
||||
0013: docs
|
||||
0014: docs triggers
|
||||
0015: scripts kind
|
||||
0016: script imports
|
||||
0017: cron triggers
|
||||
0018: files
|
||||
0019: files triggers
|
||||
0020: pubsub triggers
|
||||
0021: topics
|
||||
0022: app secrets
|
||||
0023: secrets
|
||||
0024: email triggers
|
||||
0025: encrypt realtime keys
|
||||
|
||||
@@ -25,22 +25,46 @@
|
||||
//!
|
||||
//! Review the resulting diff in the same PR as the new migration.
|
||||
//!
|
||||
//! Like the orchestrator integration tests, this is `#[ignore]`'d by
|
||||
//! default so plain `cargo test --workspace` stays green without
|
||||
//! infrastructure.
|
||||
//! v1.1.5: this test is no longer `#[ignore]`'d. It runs whenever
|
||||
//! `DATABASE_URL` is set (CI wires a `postgres:15` service) and **skips
|
||||
//! cleanly** when it's absent, so plain `cargo test --workspace` stays
|
||||
//! green on machines without Postgres. Unlike the previous
|
||||
//! `#[sqlx::test]` form (which spun up an isolated throwaway database),
|
||||
//! it now applies the migrations against the `DATABASE_URL` database
|
||||
//! directly — migrations are forward-only and idempotent, and CI's
|
||||
//! Postgres is fresh, so the structural dump is identical either way.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::{PgPool, Row};
|
||||
|
||||
const SCHEMA: &str = "public";
|
||||
|
||||
const SNAPSHOT_PATH: &str = "tests/expected_schema.txt";
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn schema_after_replay_matches_snapshot(pool: PgPool) {
|
||||
#[tokio::test]
|
||||
async fn schema_after_replay_matches_snapshot() {
|
||||
// Skip cleanly when DATABASE_URL is unset so `cargo test --workspace`
|
||||
// stays green without Postgres. CI sets it (postgres:15 service).
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
eprintln!(
|
||||
"schema_snapshot: DATABASE_URL unset — skipping. Set it (e.g. \
|
||||
postgres://picloud:picloud@localhost:5432/picloud) to run this guardrail."
|
||||
);
|
||||
return;
|
||||
};
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect to DATABASE_URL");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("apply migrations");
|
||||
|
||||
let actual = dump_schema(&pool).await;
|
||||
|
||||
let snapshot_file = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(SNAPSHOT_PATH);
|
||||
|
||||
@@ -21,5 +21,15 @@ tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
reqwest.workspace = true
|
||||
rhai.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
urlencoding.workspace = true
|
||||
|
||||
# v1.1.3 — top-level script AST cache lives in orchestrator-core's
|
||||
# LocalExecutorClient; key is ScriptId, value is `(updated_at, Arc<rhai::AST>)`.
|
||||
lru.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# `ServiceExt::oneshot` for driving the SSE router in unit tests.
|
||||
tower.workspace = true
|
||||
|
||||
@@ -129,7 +129,14 @@ where
|
||||
|
||||
let timeout = Duration::from_secs(u64::from(script.timeout_seconds));
|
||||
let started = Utc::now();
|
||||
let outcome = state.executor.execute(&script.source, req, timeout).await;
|
||||
let identity = crate::client::ScriptIdentity {
|
||||
script_id: script.id,
|
||||
updated_at: script.updated_at,
|
||||
};
|
||||
let outcome = state
|
||||
.executor
|
||||
.execute_with_identity(identity, &script.source, req, timeout)
|
||||
.await;
|
||||
let finished = Utc::now();
|
||||
|
||||
// Build and dispatch the audit log regardless of outcome. We await
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user