Compare commits
109 Commits
feat/app-m
...
handoff/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7040f0df83 | ||
|
|
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 | ||
|
|
5bbbc26c84 | ||
|
|
fedc63bc96 | ||
|
|
bf26a256e8 | ||
|
|
dee23ff682 | ||
|
|
277ba34e21 | ||
|
|
2a047f1f85 | ||
|
|
a66d4af34f | ||
|
|
ef5930910b | ||
|
|
06678f4496 | ||
|
|
3af8cc38c9 | ||
|
|
28a3bbd37f | ||
|
|
2796f36fef | ||
|
|
5a95ff2d07 | ||
|
|
66b661f64c | ||
|
|
6b7ff78730 | ||
|
|
1795dfc98a | ||
|
|
20f1b5e64d | ||
|
|
77b2cb58bb | ||
|
|
6a2971ac70 | ||
|
|
2e92691ee1 | ||
|
|
545d863199 | ||
|
|
6b99f74c48 | ||
|
|
434fb63cd2 | ||
|
|
1efb350b54 | ||
|
|
10cfde9e40 | ||
|
|
bb88b024d2 | ||
|
|
9d01f42d5e | ||
|
|
1a6324078c | ||
|
|
54efe61167 | ||
|
|
1d2e99e42c | ||
|
|
9e54b7f875 | ||
|
|
a685674dbf | ||
|
|
a8aab22163 | ||
|
|
e375735796 | ||
|
|
098e18a989 | ||
|
|
9b4a834627 | ||
|
|
5302bd3192 | ||
|
|
902dd78027 | ||
|
|
dea776b2a3 | ||
|
|
fe1dd90836 | ||
|
|
aaba58dee1 | ||
|
|
2669714a51 | ||
|
|
662d5a2cf8 | ||
|
|
fc8d473416 | ||
|
|
c73e3c80c0 | ||
|
|
f147665157 | ||
|
|
e4851b3deb | ||
|
|
5d08974876 | ||
|
|
ca278bddc8 | ||
|
|
7b50047730 | ||
|
|
b42e273479 | ||
|
|
f32ed73561 | ||
|
|
64799b73ff | ||
|
|
beb3bcb97c | ||
|
|
79c8db2cb7 | ||
|
|
f4cd883d76 | ||
|
|
b459b99fe9 | ||
|
|
f694a6d504 | ||
|
|
70b66451d6 | ||
|
|
c4fa53052d | ||
|
|
2f6840fe3e | ||
|
|
75c815d02a | ||
|
|
d9c3d4d661 | ||
|
|
bef4d34c43 | ||
|
|
99a3ed1b6b | ||
|
|
4644ea4919 | ||
|
|
ec3c768262 | ||
|
|
3e72ddde78 | ||
|
|
cd20ffb580 | ||
|
|
cddd479fd2 | ||
|
|
8bbcdd86aa | ||
|
|
2d56e42699 | ||
|
|
f9d9ed8cb4 | ||
|
|
c17f8a5bd9 | ||
|
|
7198fb4d0e | ||
|
|
029a4a199f | ||
|
|
74f7b3b631 |
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
|
||||
14
.gitignore
vendored
14
.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
|
||||
@@ -30,6 +33,17 @@ config.local.toml
|
||||
/dashboard/build
|
||||
/dashboard/.env
|
||||
|
||||
# Dashboard — Playwright E2E
|
||||
/dashboard/tests/e2e/.auth
|
||||
/dashboard/tests/e2e/.results
|
||||
/dashboard/playwright-report
|
||||
/dashboard/test-results
|
||||
/dashboard/.playwright
|
||||
# When playwright is invoked from the repo root by accident, these
|
||||
# also land here.
|
||||
/playwright-report
|
||||
/test-results
|
||||
|
||||
# Caddy
|
||||
/caddy/data
|
||||
/caddy/config
|
||||
|
||||
634
CHANGELOG.md
Normal file
634
CHANGELOG.md
Normal file
@@ -0,0 +1,634 @@
|
||||
# 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
|
||||
query DSL — plus `docs:*` triggers as the second concrete kind on the
|
||||
v1.1.1 triggers framework. Sets the precedent for the v1.2 query DSL
|
||||
expansion and `dead_letters::list`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Docs store** — `docs` table keyed `(app_id, collection, id)` with
|
||||
JSONB values and a GIN-on-`jsonb_path_ops` index. Rhai SDK exposes
|
||||
the handle pattern:
|
||||
`docs::collection(name).{create,get,find,find_one,update,delete,list}`.
|
||||
Cursor-style pagination on `list`. Cross-app isolation enforced via
|
||||
`cx.app_id` (never script-passed). Document envelope shape returned
|
||||
by reads: `#{ id, data: #{...}, created_at, updated_at }` — explicit
|
||||
metadata + user-data separation (sets precedent for v1.2
|
||||
`dead_letters::list`).
|
||||
- **Query DSL (v1.1.2 subset)** — implicit equality at top level
|
||||
(`#{ tier: "gold" }`), operator-object form
|
||||
(`#{ created_at: #{ "$gt": "..." } }`), dotted field paths up to 5
|
||||
levels (`"user.email"`), and operators `$eq`/`$ne`/`$gt`/`$gte`/
|
||||
`$lt`/`$lte`/`$in`. Filter modifiers `$sort` (single field) and
|
||||
`$limit`. Unsupported operators (`$or`, `$regex`, etc.) reject with
|
||||
a clear v1.2-pointer error.
|
||||
- **Docs triggers (`docs:*`)** — `docs_trigger_details` table mirrors
|
||||
`kv_trigger_details`. Admin endpoint
|
||||
`POST /api/v1/admin/apps/{id}/triggers/docs` accepts the same DTO
|
||||
shape as the KV endpoint with `ops` of `DocsEventOp` (create /
|
||||
update / delete). Dispatcher routes `OutboxSourceKind::Docs` through
|
||||
the same generic path as KV + dead-letter.
|
||||
- **`ctx.event.docs.prev_data`** — change-data-capture surface for
|
||||
docs trigger handlers. `prev_data` carries the document state prior
|
||||
to the mutation (`None` for create), letting handlers see what
|
||||
changed. The repo reads the old row in the same SQL statement as
|
||||
the write so the trigger event has the prior value.
|
||||
- **`Capability::AppDocsRead(AppId)`** + `AppDocsWrite(AppId)` —
|
||||
granted to Viewer / Editor respectively in the per-app role table.
|
||||
Same trust shape as KV's `AppKvRead` / `AppKvWrite`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Workspace version**: `1.1.1` → `1.1.2`.
|
||||
- **Rhai SDK version**: `1.2` → `1.3` (additive — every v1.2 script
|
||||
still runs unchanged; new surfaces: `docs::collection(name).{...}`,
|
||||
`ctx.event.docs` for triggered handlers).
|
||||
- **Dashboard version**: `0.7.0` → `0.8.0`. Workspace alignment; no
|
||||
docs-specific UI in v1.1.2 (the dashboard's Rhai-mode hints don't
|
||||
list KV completions either — focused UX pass is a separate task).
|
||||
- **`Services` bundle** — grows a `docs: Arc<dyn DocsService>` field.
|
||||
Constructor signature becomes
|
||||
`Services::new(kv, docs, dead_letters, events)`.
|
||||
- **Scope mapping**: API keys with `script:read` scope can call
|
||||
`docs::find` / `get` / `list`; `script:write` can call
|
||||
`docs::create` / `update` / `delete`. Same trust shape as KV —
|
||||
honors the seven-scope commitment from v1.1.0.
|
||||
|
||||
### Migrations
|
||||
|
||||
- `0013_docs.sql` — `docs` table + per-`(app_id, collection)` index +
|
||||
GIN-on-`jsonb_path_ops` index.
|
||||
- `0014_docs_triggers.sql` — extends `triggers.kind` and
|
||||
`outbox.source_kind` CHECK constraints to include `'docs'`; adds
|
||||
`docs_trigger_details` table.
|
||||
|
||||
### Downgrade caveats
|
||||
|
||||
Rolling a deployment back from v1.1.2 → v1.1.1 with `docs`-source
|
||||
outbox rows still queued will cause the v1.1.1 dispatcher to fail
|
||||
deserialising `TriggerEvent::Docs` (`#[serde(tag = "source")]`
|
||||
rejects unknown variants). Drain or delete
|
||||
`outbox WHERE source_kind = 'docs'` before downgrading. Trunk-only
|
||||
deployments don't hit this.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- Text-lex comparison for `$gt` / `$gte` / `$lt` / `$lte` is
|
||||
incorrect for unpadded numbers crossing digit-count boundaries
|
||||
(`'10' < '9'` is TRUE under any text collation). Workaround:
|
||||
zero-pad numeric strings. v1.2's advanced query expansion adds
|
||||
numeric-aware operators.
|
||||
- Concurrent `update()`s on the same doc may both emit the
|
||||
pre-update `prev_data` (last-writer-wins). Inherited from KV's
|
||||
`set` pattern; documented for forensic-trace use cases.
|
||||
- v1.1.2 has no partial-update DSL — scripts that want partial
|
||||
update do `get + modify + update`. Planned for v1.2.
|
||||
|
||||
## v1.1.1 — Storage & Events (unreleased)
|
||||
|
||||
The triggers framework — KV store + universal outbox + dispatcher +
|
||||
NATS-style sync HTTP + per-route async dispatch + dead-letter
|
||||
handling + dashboard surface. Every subsequent v1.1.x service module
|
||||
(docs, files, pubsub, …) hangs off the dispatcher built here.
|
||||
|
||||
### Added
|
||||
|
||||
- **KV store** — `kv_entries` table keyed `(app_id, collection, key)`
|
||||
with JSONB values. Rhai SDK exposes the handle pattern:
|
||||
`kv::collection(name).{get,set,has,delete,list}`. Cursor-style
|
||||
pagination with opaque base64 cursors. Cross-app isolation
|
||||
enforced via `cx.app_id` (never script-passed).
|
||||
- **Triggers framework (Layout E)** — parent `triggers` table +
|
||||
per-kind detail tables (`kv_trigger_details`,
|
||||
`dead_letter_trigger_details`). Trigger CRUD admin endpoints
|
||||
(`/api/v1/admin/apps/{id}/triggers/{kv,dead_letter}`) +
|
||||
`Capability::AppManageTriggers(AppId)`.
|
||||
- **Universal outbox + dispatcher** — single tokio task that polls
|
||||
the outbox via `FOR UPDATE SKIP LOCKED`, routes due rows to the
|
||||
executor through the shared `ExecutionGate`. Retry with
|
||||
exponential backoff + ±jitter; on exhaustion, dead-letter.
|
||||
- **NATS-style sync HTTP via outbox** — `InboxRegistry` (in-process
|
||||
oneshot map) lets the orchestrator await dispatcher delivery on
|
||||
every sync HTTP request. Cluster mode (v1.3+) swaps this for
|
||||
`LISTEN/NOTIFY` behind the same `InboxResolver` trait.
|
||||
- **`dispatch_mode: async` on routes** — `POST` to a route with
|
||||
`dispatch_mode = 'async'` returns `202 Accepted` immediately;
|
||||
the script runs via the dispatcher (with retries / dead-letter).
|
||||
- **Dead-letter handling** — separate `dead_letters` table per
|
||||
design notes §4. `dead_letters::{replay,resolve}` Rhai SDK +
|
||||
admin endpoints + `Capability::AppDeadLetterManage(AppId)`.
|
||||
Recursion-stop rule: dead-letter handler failures annotate the
|
||||
original row as `resolution = 'handler_failed'` and never produce
|
||||
a new dead-letter or retry.
|
||||
- **Dashboard surface for dead letters** — unresolved-count red
|
||||
badge on the apps list + per-app page; per-app dead-letters list
|
||||
view at `/admin/apps/{slug}/dead-letters` with Replay + Mark
|
||||
resolved per-row actions and expandable payload detail.
|
||||
- **`abandoned_executions` table** — forensic row written by the
|
||||
dispatcher when it tries to resolve an inbox the orchestrator
|
||||
already abandoned (timed out). Counter metric path reserved.
|
||||
- **Trigger-depth limit** — `cx.trigger_depth > max_trigger_depth`
|
||||
(default 8) skips execution + logs; does NOT dead-letter
|
||||
(depth-exceeded means "you built a loop").
|
||||
- **GC sweepers** — weekly retention sweeps for `dead_letters`
|
||||
(30 days) and `abandoned_executions` (7 days), both with
|
||||
`FOR UPDATE SKIP LOCKED` for cluster-mode safety.
|
||||
- **Env-overridable trigger config** — `TriggerConfig::from_env`
|
||||
reads `PICLOUD_MAX_TRIGGER_DEPTH`, `PICLOUD_TRIGGER_RETRY_*`,
|
||||
`PICLOUD_DEAD_LETTER_RETENTION_DAYS`,
|
||||
`PICLOUD_ABANDONED_EXECUTIONS_RETENTION_DAYS`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Workspace version**: `1.1.0` → `1.1.1`.
|
||||
- **Rhai SDK version**: `1.1` → `1.2` (additive — every v1.1 script
|
||||
still runs unchanged; new surfaces: `kv::*`, `dead_letters::*`,
|
||||
`ctx.event` for triggered handlers).
|
||||
- **Dashboard version**: `0.6.0` → `0.7.0` for the dead-letters UI.
|
||||
- **`Services` bundle** — replaces v1.1.0's no-arg `Services::new()`
|
||||
with explicit `Services::new(kv, dead_letters, events)`. Tests
|
||||
use `Services::default()` for an all-noop bundle.
|
||||
- **`SdkCallCx`** grows `is_dead_letter_handler: bool` and
|
||||
`event: Option<TriggerEvent>` fields.
|
||||
- **`ExecRequest`** mirrors the new `SdkCallCx` fields and grows
|
||||
`event` for serializable trigger payload transport.
|
||||
- **Routes table** grows `dispatch_mode TEXT NOT NULL DEFAULT 'sync'`
|
||||
(CHECK in {sync, async}).
|
||||
- **Schema version**: 6 → 12 (migrations 0007 through 0012).
|
||||
|
||||
### Migrations
|
||||
|
||||
- `0007_kv.sql` — `kv_entries` table + index
|
||||
- `0008_triggers.sql` — `triggers` + `kv_trigger_details` +
|
||||
`dead_letter_trigger_details`
|
||||
- `0009_outbox.sql` — universal `outbox` table + due-row partial index
|
||||
- `0010_dead_letters.sql` — `dead_letters` table + unresolved partial
|
||||
index + GC index
|
||||
- `0011_abandoned_executions.sql` — forensic table + GC index
|
||||
- `0012_routes_dispatch_mode.sql` — `routes.dispatch_mode` column
|
||||
|
||||
## v1.1.0 — Foundation & Standard Library
|
||||
|
||||
See `docs/v1.1.x-design-notes.md` §7 for the full v1.1.x roadmap.
|
||||
21
CLAUDE.md
21
CLAUDE.md
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
|
||||
|
||||
**Current focus (Phase 4, v1.1):** data-plane SDKs — KV store, then document store, then HTTP client, then cron triggers. See blueprint §12. Phase 3 (admin auth + multi-app scoping) shipped; every v1.1+ table starts with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE` and every Rhai SDK call resolves its app from the execution context.
|
||||
**Current focus (Phase 4, v1.1.0):** SDK foundation + stdlib utilities — the shape every v1.1.x service module hangs off, see [docs/sdk-shape.md](docs/sdk-shape.md). Stdlib reference at [docs/stdlib-reference.md](docs/stdlib-reference.md). Subsequent v1.1.x releases (KV in v1.1.1, docs in v1.1.2, …) fill it in; see blueprint §12 for the full table. Phase 3 shipped end-to-end: admin auth, multi-app scoping, and Phase 3.5 capability gating (`manager-core::authz::{can, require, Capability}` + migration `0006_users_authz.sql`). Every v1.1+ table starts with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE` and every Rhai SDK call resolves its app from the execution context.
|
||||
|
||||
## Three-Service Architecture
|
||||
|
||||
@@ -48,7 +48,7 @@ Caddy fronts everything. Same Caddyfile shape works for single-node and cluster
|
||||
- **Rust 1.92+** workspace, pinned via `rust-toolchain.toml`
|
||||
- **Axum** for HTTP, **Tokio** async, **sqlx** for Postgres
|
||||
- **Rhai** embedded scripting (in `executor-core`)
|
||||
- **PostgreSQL 15+** with `pgcrypto` and (v1.1+) `hstore`
|
||||
- **PostgreSQL 15+** with `pgcrypto`. v1.1+ data-plane tables use JSONB for value columns (hstore was considered for KV and rejected — see blueprint §8.1).
|
||||
- **SvelteKit** dashboard, static adapter, CodeMirror 6 for the script editor
|
||||
- **Caddy 2** reverse proxy (auto-HTTPS in prod)
|
||||
- **Docker Compose** for dev and single-node prod
|
||||
@@ -100,12 +100,27 @@ docs/
|
||||
|
||||
## Working Rules
|
||||
|
||||
- **Honor the three-service boundary.** Don't reach across `*-core` crates. If `orchestrator-core` needs something from `manager-core`, define a trait in `shared` and inject the impl.
|
||||
- **Honor the three-service boundary.** Don't reach across `*-core` crates *for behavior*. If `orchestrator-core` needs to invoke logic from `manager-core`, define a trait in `shared` and inject the impl — keep implementations decoupled. **Transport DTOs are not behavior**: types like `ExecRequest` / `ExecResponse` / `ExecError` represent values produced or consumed across the wire, and depending on the originating crate's type definitions is fine. The bright line is "don't call across crates," not "don't import types." When in doubt: if the imported item is a `struct`/`enum`/`type alias` with no methods (or only data-shape methods), it's a DTO and crossing is fine; if it's a trait, function, or service, define the abstraction in `shared` and inject.
|
||||
- **`executor-core` has no Postgres dependency.** Data-plane services (kv, docs, users — v1.1+) come in via injected `ServiceProvider` traits.
|
||||
- **Database writes only from `manager-core`.** `orchestrator-core` reads scripts (cached); `executor-core` doesn't touch the DB.
|
||||
- **Stateful SDK services use the handle pattern + `SdkCallCx`.** Collection-scoped surfaces look like `kv::collection("x").get(k)`, not `kv::get("x", k)`. Every service trait method takes `&SdkCallCx` and **MUST** derive `app_id` from `cx.app_id` — never trust a script-passed `app_id`. That is the cross-app isolation boundary. See [docs/sdk-shape.md](docs/sdk-shape.md).
|
||||
- **MVP builds only the `picloud` all-in-one binary.** The three split binaries exist as skeletons so the crate boundaries stay honest; flesh them out only when cluster mode is being implemented.
|
||||
- **Trunk-based dev.** See [docs/git-workflow.md](docs/git-workflow.md). No long-lived branches. Feature flags for incomplete work.
|
||||
|
||||
## Runtime configuration
|
||||
|
||||
Environment variables consumed by the `picloud` binary:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `PICLOUD_BIND` | `0.0.0.0:8080` | HTTP listen address. Port 8080 is owned by another process on this host — override locally. |
|
||||
| `PICLOUD_MAX_CONCURRENT_EXECUTIONS` | `32` | Global concurrency cap on data-plane script executions. Overflow returns HTTP 503 with `Retry-After: 1` immediately (no queue). |
|
||||
| `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
|
||||
|
||||
Queue triggers, cron triggers, SMTP ingress, KV / docs / email / users / HTTP SDKs in scripts, interceptors, workflows, function-to-function `invoke()`, secrets, metrics dashboard. All deferred to v1.1+ per the blueprint. Don't pre-build for them — but don't make decisions that close the door on them either.
|
||||
|
||||
698
Cargo.lock
generated
698
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
32
Cargo.toml
32
Cargo.toml
@@ -9,10 +9,11 @@ members = [
|
||||
"crates/picloud-manager",
|
||||
"crates/picloud-orchestrator",
|
||||
"crates/picloud-executor",
|
||||
"crates/picloud-cli",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.0"
|
||||
version = "1.1.7"
|
||||
edition = "2021"
|
||||
rust-version = "1.92"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -28,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"] }
|
||||
@@ -46,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"] }
|
||||
@@ -70,8 +77,27 @@ 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)
|
||||
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"
|
||||
|
||||
330
HANDBACK.md
Normal file
330
HANDBACK.md
Normal file
@@ -0,0 +1,330 @@
|
||||
# v1.1.7 — Configuration & Email — HANDBACK
|
||||
|
||||
**Branch:** `feat/v1.1.7-secrets-email` (9 commits off `main`, not pushed)
|
||||
**Status:** ready for review. NOT merged, NOT pushed, no PR opened.
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope coverage
|
||||
|
||||
| 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** |
|
||||
|
||||
Nothing deferred from scope-in. Inbound email (the deferrable-if-scope-
|
||||
blew-up piece) was implemented in full.
|
||||
|
||||
---
|
||||
|
||||
## 2. Encryption infrastructure notes
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 3. Secrets notes
|
||||
|
||||
- `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).
|
||||
|
||||
---
|
||||
|
||||
## 4. Email implementation notes
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 5. Dead-letter handler fix notes
|
||||
|
||||
- **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).
|
||||
|
||||
---
|
||||
|
||||
## 6. Realtime signing-key migration notes
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 7. Decisions beyond the brief / deviations flagged
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
No other deviations from prompt-specified defaults.
|
||||
|
||||
---
|
||||
|
||||
## 8. How to verify locally — §8 attestation (sourced from cargo's literal output)
|
||||
|
||||
All gates run on the handed-back HEAD (`a7d3dad`):
|
||||
|
||||
```sh
|
||||
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)
|
||||
```
|
||||
|
||||
Full test run **with `DATABASE_URL` set** so the DB-gated suites
|
||||
(schema_snapshot, dispatcher_e2e ×9, email_inbound ×8) execute:
|
||||
|
||||
```sh
|
||||
DATABASE_URL='postgres://picloud:picloud@127.0.0.1:15432/picloud' \
|
||||
cargo test --workspace -- --test-threads=2
|
||||
```
|
||||
|
||||
**Pass count, summed from cargo's literal output (NOT hand-counted):**
|
||||
|
||||
```sh
|
||||
DATABASE_URL=... cargo test --workspace -- --test-threads=2 2>&1 | \
|
||||
awk '/test result: ok\./ { gsub(";", ""); sum += $4 } END { print sum }'
|
||||
# => 617
|
||||
```
|
||||
|
||||
**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).
|
||||
|
||||
**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).
|
||||
|
||||
**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.
|
||||
382
HANDOFF.md
Normal file
382
HANDOFF.md
Normal file
@@ -0,0 +1,382 @@
|
||||
# Handoff — 2026-06-05
|
||||
|
||||
Machine-switch handoff. This document is the entry point for picking up
|
||||
PiCloud work on a different machine. It captures session state, what
|
||||
shipped, what's queued, and how to continue.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **`main` is at v1.1.7** — seven minor releases (v1.1.1 → v1.1.7)
|
||||
shipped this session via the dispatch-and-review workflow.
|
||||
- Working tree is clean.
|
||||
- Next release is **v1.1.8** (User Management). A draft dispatch prompt
|
||||
is sketched in §6 below; ready to send to a dev agent.
|
||||
- One dev Postgres container (`picloud-postgres-1` on port 15432) is
|
||||
still running on the source machine — tear it down with
|
||||
`docker compose down -v` before the source machine goes offline.
|
||||
|
||||
---
|
||||
|
||||
## How to resume on the new machine
|
||||
|
||||
```sh
|
||||
git clone https://git.mc02.dev/fabi/PiCloud.git
|
||||
cd PiCloud
|
||||
git checkout main
|
||||
git log --oneline -10 # should show v1.1.7 reviewer commit at HEAD
|
||||
docker compose up -d # local Postgres for DB-gated tests
|
||||
export DATABASE_URL='postgres://picloud:picloud@127.0.0.1:5432/picloud'
|
||||
cargo test --workspace -- --test-threads=2
|
||||
```
|
||||
|
||||
If you're starting from this branch (`handoff/2026-06-05`), it points at
|
||||
the same `main` HEAD with this `HANDOFF.md` added; merge or just read it
|
||||
and continue work on `main`.
|
||||
|
||||
For the master encryption key needed by v1.1.7+ secrets:
|
||||
|
||||
```sh
|
||||
export PICLOUD_SECRET_KEY="$(openssl rand -base64 32)"
|
||||
# OR, for dev only:
|
||||
export PICLOUD_DEV_MODE=true
|
||||
```
|
||||
|
||||
The dev fallback uses a deterministic key (`SHA-256` of a hardcoded
|
||||
string) — fine for local testing, fatal for any real deployment.
|
||||
|
||||
---
|
||||
|
||||
## Session summary: v1.1.1 → v1.1.7
|
||||
|
||||
All seven minor releases completed in one session via the dispatch
|
||||
workflow you set up: I draft a prompt, you dispatch it to a fresh
|
||||
agent in another session, the agent implements and writes `HANDBACK.md`,
|
||||
you bounce the report back to me, I audit the branch and write
|
||||
`REVIEW.md` with a verdict, you bounce-back-for-fixes-if-needed, and on
|
||||
approve I fast-forward merge into `main`.
|
||||
|
||||
| Release | Capability | Iterations | Status |
|
||||
|---|---|---|---|
|
||||
| **v1.1.1** | Storage & Events (KV + triggers framework + outbox + dispatcher + NATS-style sync HTTP + dead-letter table + dashboard surface) | 1 | ✅ merged |
|
||||
| **v1.1.2** | Documents (`docs::*` SDK + query DSL + `docs:*` triggers) | 2 | ✅ merged (iteration 2 fixed a fmt diff) |
|
||||
| **v1.1.3** | Modules (`scripts.kind` + `PicloudModuleResolver` + AST caches + `script_imports`) | 1 | ✅ merged |
|
||||
| **v1.1.4** | Outbound HTTP & Scheduled Tasks (`http::*` with SSRF deny-list + cron triggers) | 1 | ✅ merged |
|
||||
| **v1.1.5** | Files & Pub/Sub (filesystem-backed blobs + `pubsub::publish_durable` + first CI workflow) | 1 | ✅ merged |
|
||||
| **v1.1.6** | Realtime Channels & Client Library (SSE + topics + HMAC subscriber tokens + `@picloud/client@1.0.0`) | 1 | ✅ merged |
|
||||
| **v1.1.7** | Configuration & Email (encrypted secrets + outbound/inbound email + dead-letter handler fix) | 1 | ✅ merged |
|
||||
|
||||
**Versioning state on `main`:**
|
||||
- Workspace `1.1.7`
|
||||
- SDK schema `1.8`
|
||||
- Dashboard `0.13.0`
|
||||
- `@picloud/client` `1.0.0`
|
||||
- Migrations applied through `0025`
|
||||
|
||||
**Test counts at HEAD:** `cargo test --workspace --test-threads=2` with
|
||||
`DATABASE_URL` set → **617 passed / 0 failed**. The `--test-threads=2`
|
||||
is required on shared dev Postgres (~9 concurrent `build_app`s
|
||||
otherwise exhaust connections); CI's dedicated Postgres doesn't hit
|
||||
this.
|
||||
|
||||
---
|
||||
|
||||
## Branches on this machine
|
||||
|
||||
### v1.1.x feature branches (all merged into main, kept locally for traceability)
|
||||
|
||||
| Branch | HEAD | What it contains |
|
||||
|---|---|---|
|
||||
| `feat/v1.1.1-storage-and-events` | `2796f36` | v1.1.1 work + HANDBACK + REVIEW |
|
||||
| `feat/v1.1.2-documents` | `5bbbc26` | v1.1.2 work (2 iterations) + HANDBACK + REVIEW |
|
||||
| `feat/v1.1.3-modules` | `6f17259` | v1.1.3 work + HANDBACK + REVIEW |
|
||||
| `feat/v1.1.4-http-cron` | `03d03ea` | v1.1.4 work + HANDBACK + REVIEW |
|
||||
| `feat/v1.1.5-files-pubsub` | `d064681` | v1.1.5 work + HANDBACK + REVIEW |
|
||||
| `feat/v1.1.6-realtime-client` | `64ad978` | v1.1.6 work + HANDBACK + REVIEW |
|
||||
| `feat/v1.1.7-secrets-email` | `5cbb6ca` | v1.1.7 work + HANDBACK + REVIEW |
|
||||
|
||||
All seven HEADs are reachable from `main` (fast-forward merges). Keeping
|
||||
the branches makes it easy to inspect the per-release commit slice
|
||||
without git log filtering.
|
||||
|
||||
### Older branches predating this session (state uncertain)
|
||||
|
||||
These appeared in `git branch` at session start and weren't touched by
|
||||
v1.1.x work. I don't know which are abandoned, in-flight, or already
|
||||
merged under different names. **On the new machine, decide for each:**
|
||||
|
||||
| Branch | Last commit | Tracking |
|
||||
|---|---|---|
|
||||
| `chore/ui-hardening` | `b42e273 fix(test): admin_is_implicit_app_admin uses force=true on app delete` | local-only |
|
||||
| `feat/app-members` | `e6fc6e6 test(picloud): close two app_members test gaps` | local-only |
|
||||
| `feat/cli` | `5d08974 style(cli): re-fmt one stray format! line in the integration test` | tracks `origin/feat/cli` (up to date) |
|
||||
| `feat/multi-app-scoping` | `a393f11 feat(dashboard): auto-slug app names and infer route host kind from input` | tracks `origin/feat/multi-app-scoping` (ahead 3) |
|
||||
| `feat/users-and-keys-ui` | `6eb32a7 feat(dashboard): adopt ActionMenu for user row actions` | local-only |
|
||||
| `feat/users-authz` | `2aab92a style: cargo fmt across Phase 3.5 changes` | local-only |
|
||||
| `test/cli-journeys` | `e4851b3 test(cli): extract shared Fixture into tests/common` | tracks `origin/test/cli-journeys` (up to date) |
|
||||
| `test/frontend-e2e` | `ec3c768 test(dashboard): add full-stack integration specs` | local-only |
|
||||
|
||||
**Push these if you want them mirrored on the new machine** — see §3
|
||||
below for the push commands. If any are obsolete, delete them locally
|
||||
before resuming.
|
||||
|
||||
---
|
||||
|
||||
## §3 — Push instructions
|
||||
|
||||
Push was denied in this session (sandbox restriction). Run these on the
|
||||
source machine to mirror state to `origin`:
|
||||
|
||||
```sh
|
||||
# 1. The v1.1.x releases on main (55 commits)
|
||||
git push origin main
|
||||
|
||||
# 2. The seven v1.1.x feature branches (preserves per-release history)
|
||||
git push origin feat/v1.1.1-storage-and-events
|
||||
git push origin feat/v1.1.2-documents
|
||||
git push origin feat/v1.1.3-modules
|
||||
git push origin feat/v1.1.4-http-cron
|
||||
git push origin feat/v1.1.5-files-pubsub
|
||||
git push origin feat/v1.1.6-realtime-client
|
||||
git push origin feat/v1.1.7-secrets-email
|
||||
|
||||
# 3. This handoff branch
|
||||
git push -u origin handoff/2026-06-05
|
||||
|
||||
# 4. OPTIONAL — push the older branches you want on the new machine
|
||||
# (decide per-branch; some may be abandoned)
|
||||
git push origin chore/ui-hardening
|
||||
git push origin feat/app-members
|
||||
git push origin feat/multi-app-scoping # ahead 3 of remote
|
||||
git push origin feat/users-and-keys-ui
|
||||
git push origin feat/users-authz
|
||||
git push origin test/frontend-e2e
|
||||
```
|
||||
|
||||
After pushing, on the new machine: `git fetch --all` brings everything
|
||||
down. `git checkout main` puts you at v1.1.7 HEAD.
|
||||
|
||||
---
|
||||
|
||||
## §4 — Workflow context (read before dispatching v1.1.8)
|
||||
|
||||
The dispatch-and-review workflow you've been using:
|
||||
|
||||
1. **You ask me to draft the dispatch prompt** for the next release.
|
||||
2. **I draft the prompt** based on:
|
||||
- The roadmap in [`docs/v1.1.x-design-notes.md` §7](docs/v1.1.x-design-notes.md)
|
||||
- Three or so follow-ups identified in the prior release's REVIEW.md
|
||||
- Discipline lessons carried forward from prior retros
|
||||
3. **You dispatch the prompt to a fresh agent in another session** —
|
||||
that agent gets no prior conversation context; the prompt + the
|
||||
docs it points at are everything they have.
|
||||
4. **The agent implements + writes `HANDBACK.md`** at the repo root,
|
||||
then stops.
|
||||
5. **You bounce the HANDBACK back to me.**
|
||||
6. **I audit the branch and write `REVIEW.md`** with a verdict
|
||||
(`APPROVE` or `NEEDS CHANGES`).
|
||||
7. **If `NEEDS CHANGES`:** you bounce the REVIEW back to the agent;
|
||||
they iterate; back to step 5.
|
||||
8. **If `APPROVE`:** I fast-forward merge the branch into `main` and
|
||||
pause for your next instruction.
|
||||
|
||||
What's worked well across seven releases:
|
||||
|
||||
- The discipline reminders compound. Each release's retro identifies
|
||||
one small habit the agent dropped (§8 attestation hand-counting,
|
||||
silent prompt-default deviations, brief-internal contradictions
|
||||
silently reinterpreted, clippy run without `--all-targets`); the
|
||||
next release's prompt explicitly addresses it. By v1.1.7 the agent
|
||||
was catching their own latent findings without prompting.
|
||||
- Explicit "deviations beyond the brief" sections in HANDBACK make
|
||||
audits fast — every meaningful judgment call is in one place.
|
||||
- The "this is the deferrable piece under scope pressure" clause in
|
||||
big releases (v1.1.6 client lib, v1.1.7 inbound email) gave the
|
||||
agent a clean escape hatch they never actually needed but worked
|
||||
as intended.
|
||||
- Latent findings discovered during implementation (v1.1.3 cross-app
|
||||
trigger gap, v1.1.4 SSRF literal-IP bypass, v1.1.6 dead_letter
|
||||
handler never firing, v1.1.7 clippy regression at v1.1.6 HEAD) all
|
||||
surfaced honestly rather than being silently worked around.
|
||||
|
||||
What to do differently in v1.1.8:
|
||||
|
||||
- **Walk through each code example in the prompt** before sending. v1.1.4
|
||||
brief said `(url, opts)` but its example was `http::post(url, body)` —
|
||||
the agent had to fix it during implementation. v1.1.7 brief sketched
|
||||
`TriggerEvent::DeadLetter` field names that didn't match the actual
|
||||
variant. Both flagged correctly, but pre-resolution saves agent
|
||||
effort.
|
||||
- **Pin the clippy gate**: `cargo clean` before `cargo clippy
|
||||
--all-targets` to defeat incremental-cache false-greens. See v1.1.7
|
||||
REVIEW §3.3 for context.
|
||||
|
||||
---
|
||||
|
||||
## §5 — Pending follow-ups for v1.1.8
|
||||
|
||||
From the v1.1.7 REVIEW.md, three load-bearing items to fold into the
|
||||
v1.1.8 dispatch prompt:
|
||||
|
||||
### 5.1 Drop the plaintext `realtime_signing_key` column
|
||||
|
||||
The v1.1.7 phase-2 commitment. v1.1.7 added NULL-able encrypted columns
|
||||
+ DROP NOT NULL on the plaintext column; the startup task encrypts
|
||||
existing rows. v1.1.8 drops the plaintext column entirely.
|
||||
|
||||
**Pre-flight check:** scan for any remaining non-NULL rows on the
|
||||
plaintext column. If found, run the encryption migration before the
|
||||
drop. If the v1.1.7 startup task ran on the operator's deploy, all
|
||||
rows should already be encrypted.
|
||||
|
||||
**CHANGELOG must note** that v1.1.8 requires v1.1.7 to have been
|
||||
applied first. No skipping versions.
|
||||
|
||||
### 5.2 Clippy `--all-targets` discipline refinement
|
||||
|
||||
The v1.1.7 audit caught a real regression: four warnings predated
|
||||
v1.1.7 that the v1.1.6 audit reported as clippy-green. Likely cause:
|
||||
cargo's incremental cache leaving test binaries unchecked.
|
||||
|
||||
v1.1.8 prompt should require either:
|
||||
- `cargo clean` before `cargo clippy --all-targets`, OR
|
||||
- Explicit verification that the clippy output includes `Checking`
|
||||
lines for test crates.
|
||||
|
||||
CI's `.github/workflows/ci.yml` (added in v1.1.5) might also benefit
|
||||
from a clippy-cache-check step.
|
||||
|
||||
### 5.3 `auth_mode = 'session'` for realtime subscriber tokens
|
||||
|
||||
v1.1.7's CHECK constraint on `topics.auth_mode` only allows
|
||||
`('public', 'token')`. v1.1.8's `users::*` work needs to:
|
||||
- Extend the CHECK to include `'session'`.
|
||||
- Add a session-token validator alongside the existing HMAC validator
|
||||
behind the unchanged `RealtimeAuthority` trait.
|
||||
|
||||
The trait shape from v1.1.6 already supports this — natural extension.
|
||||
|
||||
---
|
||||
|
||||
## §6 — Draft v1.1.8 dispatch prompt outline
|
||||
|
||||
Not the full prompt — just the scope sketch so you can ask me to expand
|
||||
it on the new machine.
|
||||
|
||||
**v1.1.8 — User Management (`users::*`)**
|
||||
|
||||
Core scope:
|
||||
- `users::create / get / find / update / delete / list` SDK
|
||||
- Password hashing (argon2id)
|
||||
- `users` table per-app
|
||||
- Sessions: `users::login(email, password)` → returns a session token;
|
||||
`users::verify(session_token)` returns the user or `()`
|
||||
- Sessions table with TTL + revocation
|
||||
- Email verification flow (uses v1.1.7 email::send)
|
||||
- Password reset flow (uses v1.1.7 email::send + tokens)
|
||||
- Invitations (admin creates an invite → email link → user accepts +
|
||||
sets password)
|
||||
- Roles: per-app role assignments on users
|
||||
- `Capability::AppUsersRead/Write/Admin` mapped to existing scopes
|
||||
- Dashboard: Users tab on app detail page (list, invite, role-edit)
|
||||
|
||||
Follow-ups from v1.1.7 retro (fold in):
|
||||
- Drop plaintext `realtime_signing_key` column (phase-2)
|
||||
- Clippy `--all-targets` discipline refinement
|
||||
- `auth_mode = 'session'` for realtime subscriber tokens (uses v1.1.8
|
||||
sessions)
|
||||
|
||||
Out of scope:
|
||||
- OAuth providers (defer to v1.2+)
|
||||
- 2FA / MFA (defer to v1.2+)
|
||||
- SSO / SAML (defer)
|
||||
- Password policy customization (defer; ship with sensible default)
|
||||
- User-to-user messaging (defer; userland)
|
||||
|
||||
Ask me to expand this into a full prompt when you're ready.
|
||||
|
||||
---
|
||||
|
||||
## §7 — Environmental notes
|
||||
|
||||
- **Dev Postgres container** `picloud-postgres-1` (port 15432) was
|
||||
running at session end on the source machine. The v1.1.5/v1.1.6/
|
||||
v1.1.7 agents started it for live DB-gated tests. **Tear down with
|
||||
`docker compose down -v` before the source machine goes offline**
|
||||
if you want a clean state.
|
||||
- **`PICLOUD_SECRET_KEY`** is required for v1.1.7+ to start. Pick one
|
||||
with `openssl rand -base64 32` for production; use
|
||||
`PICLOUD_DEV_MODE=true` (no master key needed) for local
|
||||
development. The dev key is deterministic so secrets persist across
|
||||
restarts in dev.
|
||||
- **CI workflow** lives at [`.github/workflows/ci.yml`](.github/workflows/ci.yml)
|
||||
(added in v1.1.5). Runs fmt + clippy + `cargo test --workspace`
|
||||
against a `postgres:15` service, plus dashboard `npm run check`.
|
||||
When you push to `main` or open a PR, CI will run. **First push
|
||||
after this handoff will exercise it for the first time on real
|
||||
workload — watch the run.**
|
||||
|
||||
---
|
||||
|
||||
## §8 — Key documents for orientation
|
||||
|
||||
- **[`CLAUDE.md`](CLAUDE.md)** — project conventions. Read first.
|
||||
- **[`serverless_cloud_blueprint.md`](serverless_cloud_blueprint.md)** —
|
||||
the authoritative architecture document.
|
||||
- **[`docs/sdk-shape.md`](docs/sdk-shape.md)** — SDK conventions every
|
||||
v1.1.x service follows.
|
||||
- **[`docs/v1.1.x-design-notes.md`](docs/v1.1.x-design-notes.md)** —
|
||||
the in-flight-decisions document. Sections §1–§6 contain the
|
||||
"Decided 2026-06-01" annotations from the design conversation that
|
||||
preceded this session; §7 holds the v1.1.x roadmap; §1–4 are
|
||||
candidates for pruning (their decisions shipped in v1.1.1).
|
||||
- **[`docs/versioning.md`](docs/versioning.md)** — patch-bump policy
|
||||
under the post-1.0 expansion-phase carve-out.
|
||||
- **[`docs/git-workflow.md`](docs/git-workflow.md)** — trunk-based
|
||||
workflow conventions.
|
||||
- **[`CHANGELOG.md`](CHANGELOG.md)** — release notes for v1.1.1 onward.
|
||||
v1.1.7's entry includes the retroactive dead_letter security note.
|
||||
|
||||
Per-release artifacts on `main`:
|
||||
- `HANDBACK.md` at repo root — currently holds the v1.1.7 agent's
|
||||
handback. Overwritten each release.
|
||||
- `REVIEW.md` at repo root — currently holds the v1.1.7 reviewer's
|
||||
audit. Overwritten each release.
|
||||
|
||||
If you want the full per-release HANDBACK + REVIEW history, the seven
|
||||
`feat/v1.1.x-*` branches preserve them (each branch's `HEAD~1`
|
||||
contains the HANDBACK and `HEAD` contains the REVIEW for that release).
|
||||
|
||||
---
|
||||
|
||||
## §9 — Quick smoke after resuming
|
||||
|
||||
After cloning + setting up the new machine:
|
||||
|
||||
```sh
|
||||
# Basic gates
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
cargo test --workspace
|
||||
|
||||
# DB-gated (needs Postgres)
|
||||
docker compose up -d
|
||||
export DATABASE_URL='postgres://picloud:picloud@127.0.0.1:5432/picloud'
|
||||
export PICLOUD_DEV_MODE=true
|
||||
cargo test --workspace --test-threads=2
|
||||
|
||||
# Dashboard
|
||||
cd dashboard && npm install && npm run check
|
||||
|
||||
# Client library
|
||||
cd clients/typescript && npm install && npm run lint && npm run test && npm run build
|
||||
```
|
||||
|
||||
If all green: machine is ready. Resume v1.1.8 work by asking me for
|
||||
the full dispatch prompt.
|
||||
|
||||
---
|
||||
|
||||
**Handoff written 2026-06-05.** Main HEAD: `5cbb6ca` (v1.1.7 reviewer
|
||||
APPROVE).
|
||||
183
REVIEW.md
Normal file
183
REVIEW.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# v1.1.7 Audit & Review
|
||||
|
||||
**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.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.7.**
|
||||
|
||||
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.
|
||||
|
||||
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 (HEAD `3cfb795`)
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
Sum via the v1.1.7 discipline awk pattern:
|
||||
|
||||
```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)
|
||||
|
||||
| Decision / requirement | Where it lives | Verdict |
|
||||
|---|---|---|
|
||||
| **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. The three flagged items
|
||||
|
||||
### 3.1 Brief-internal contradiction: `TriggerEvent::DeadLetter` field names (HANDBACK §7 #2)
|
||||
|
||||
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}`.
|
||||
|
||||
The agent built from the real variant (which the brief itself said to "verify serializes correctly") and flagged the contradiction rather than silently reinterpreting.
|
||||
|
||||
**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.
|
||||
|
||||
### 3.2 `inbound_secret` stored encrypted (HANDBACK §7 #1)
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
### 3.3 Latent finding: clippy `--all-targets` regression (HANDBACK §10 #1)
|
||||
|
||||
This is the most important finding in this review.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
- **`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. Versioning audit
|
||||
|
||||
| File | Before | After | Status |
|
||||
|---|---|---|---|
|
||||
| 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 |
|
||||
|
||||
## 8. Recommended next steps (post-merge)
|
||||
|
||||
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 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']
|
||||
}
|
||||
});
|
||||
@@ -14,7 +14,34 @@ picloud-shared.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
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
|
||||
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
|
||||
|
||||
@@ -3,30 +3,71 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use chrono::Utc;
|
||||
use picloud_shared::{ScriptValidator, ValidationError, SDK_VERSION};
|
||||
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module, Scope};
|
||||
use picloud_shared::{
|
||||
ScriptValidator, SdkCallCx, Services, TriggerEvent, ValidatedScript, ValidationError,
|
||||
SDK_VERSION,
|
||||
};
|
||||
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};
|
||||
use crate::types::{
|
||||
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
|
||||
};
|
||||
|
||||
/// Preconfigured Rhai engine with sandbox limits applied.
|
||||
/// 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.
|
||||
///
|
||||
/// One `Engine` is constructed at process startup and reused across
|
||||
/// invocations. `execute` is **synchronous** — it owns the per-call
|
||||
/// scope and log buffer. Wall-clock timeouts and offloading off the
|
||||
/// async runtime belong to the caller (orchestrator-core's
|
||||
/// `LocalExecutorClient` wraps this with `spawn_blocking` + `timeout`).
|
||||
///
|
||||
/// The `Services` bundle is empty in v1.1.0; subsequent v1.1.x PRs add
|
||||
/// service handles (KV, docs, …) and `sdk::register_all` wires them
|
||||
/// into each per-call Rhai engine.
|
||||
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) -> Self {
|
||||
Self { limits }
|
||||
pub fn new(limits: Limits, services: Services) -> Self {
|
||||
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]
|
||||
@@ -34,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()))
|
||||
}
|
||||
|
||||
@@ -54,19 +121,57 @@ impl Engine {
|
||||
/// 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);
|
||||
let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let engine = build_engine(effective_limits, Some(logs.clone()));
|
||||
|
||||
let ast = engine
|
||||
// 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()));
|
||||
|
||||
// Per-call context handed to every stateful SDK service via the
|
||||
// `sdk::register_all` hook. The Arc lets future service closures
|
||||
// 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,
|
||||
trigger_depth: req.trigger_depth,
|
||||
root_execution_id: req.root_execution_id,
|
||||
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 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();
|
||||
|
||||
@@ -91,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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +237,11 @@ fn build_engine(limits: Limits, logs: Option<Arc<Mutex<Vec<LogEntry>>>>) -> Rhai
|
||||
engine.register_static_module("log", build_log_module(logs).into());
|
||||
}
|
||||
|
||||
// Stateless utility modules — regex::/random::/time::/json::/base64::/
|
||||
// hex::/url::. Always registered, including in the parse-only validate
|
||||
// path, so script authors get consistent surface in both phases.
|
||||
sdk::stdlib::register_stdlib(&mut engine);
|
||||
|
||||
engine
|
||||
}
|
||||
|
||||
@@ -213,9 +333,196 @@ fn build_ctx_map(req: &ExecRequest) -> Map {
|
||||
request.insert("rest".into(), req.rest.clone().into());
|
||||
|
||||
ctx.insert("request".into(), request.into());
|
||||
|
||||
// Triggered invocations: surface the originating event as
|
||||
// `ctx.event`. Direct ingress (HTTP request, manual run) leaves
|
||||
// the key absent so scripts can test `if "event" in ctx`.
|
||||
if let Some(event) = req.event.as_ref() {
|
||||
ctx.insert("event".into(), trigger_event_to_dynamic(event));
|
||||
}
|
||||
|
||||
ctx
|
||||
}
|
||||
|
||||
/// Convert a `TriggerEvent` into the `ctx.event` Rhai shape defined in
|
||||
/// `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());
|
||||
match event {
|
||||
TriggerEvent::Kv {
|
||||
op,
|
||||
collection,
|
||||
key,
|
||||
value,
|
||||
} => {
|
||||
m.insert("op".into(), op.as_str().into());
|
||||
let mut kv_map = Map::new();
|
||||
kv_map.insert("collection".into(), collection.clone().into());
|
||||
kv_map.insert("key".into(), key.clone().into());
|
||||
kv_map.insert(
|
||||
"value".into(),
|
||||
value.clone().map_or(Dynamic::UNIT, json_to_dynamic),
|
||||
);
|
||||
m.insert("kv".into(), kv_map.into());
|
||||
}
|
||||
TriggerEvent::Docs {
|
||||
op,
|
||||
collection,
|
||||
id,
|
||||
data,
|
||||
prev_data,
|
||||
} => {
|
||||
m.insert("op".into(), op.as_str().into());
|
||||
let mut docs_map = Map::new();
|
||||
docs_map.insert("collection".into(), collection.clone().into());
|
||||
docs_map.insert("id".into(), id.clone().into());
|
||||
docs_map.insert(
|
||||
"data".into(),
|
||||
data.clone().map_or(Dynamic::UNIT, json_to_dynamic),
|
||||
);
|
||||
docs_map.insert(
|
||||
"prev_data".into(),
|
||||
prev_data.clone().map_or(Dynamic::UNIT, json_to_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,
|
||||
attempts,
|
||||
last_error,
|
||||
trigger_id,
|
||||
script_id,
|
||||
first_attempt_at,
|
||||
last_attempt_at,
|
||||
} => {
|
||||
let mut dl = Map::new();
|
||||
dl.insert("id".into(), dead_letter_id.to_string().into());
|
||||
dl.insert("original".into(), trigger_event_to_dynamic(original));
|
||||
dl.insert("attempts".into(), i64::from(*attempts).into());
|
||||
dl.insert("last_error".into(), last_error.clone().into());
|
||||
dl.insert(
|
||||
"trigger_id".into(),
|
||||
trigger_id
|
||||
.map(|id| Dynamic::from(id.to_string()))
|
||||
.unwrap_or(Dynamic::UNIT),
|
||||
);
|
||||
dl.insert(
|
||||
"script_id".into(),
|
||||
script_id
|
||||
.map(|id| Dynamic::from(id.to_string()))
|
||||
.unwrap_or(Dynamic::UNIT),
|
||||
);
|
||||
dl.insert(
|
||||
"first_attempt_at".into(),
|
||||
first_attempt_at.to_rfc3339().into(),
|
||||
);
|
||||
dl.insert(
|
||||
"last_attempt_at".into(),
|
||||
last_attempt_at.to_rfc3339().into(),
|
||||
);
|
||||
m.insert("dead_letter".into(), dl.into());
|
||||
}
|
||||
}
|
||||
m.into()
|
||||
}
|
||||
|
||||
fn invocation_type_str(it: InvocationType) -> &'static str {
|
||||
match it {
|
||||
InvocationType::Http => "http",
|
||||
@@ -265,69 +572,6 @@ fn parse_structured_response(map: Map) -> Result<(u16, BTreeMap<String, String>,
|
||||
Ok((status_code, headers, body))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rhai ↔ serde_json bridges
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn json_to_dynamic(value: Json) -> Dynamic {
|
||||
match value {
|
||||
Json::Null => Dynamic::UNIT,
|
||||
Json::Bool(b) => b.into(),
|
||||
Json::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
i.into()
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
f.into()
|
||||
} else {
|
||||
n.to_string().into()
|
||||
}
|
||||
}
|
||||
Json::String(s) => s.into(),
|
||||
Json::Array(arr) => arr
|
||||
.into_iter()
|
||||
.map(json_to_dynamic)
|
||||
.collect::<Vec<Dynamic>>()
|
||||
.into(),
|
||||
Json::Object(obj) => {
|
||||
let mut m = Map::new();
|
||||
for (k, v) in obj {
|
||||
m.insert(k.into(), json_to_dynamic(v));
|
||||
}
|
||||
Dynamic::from(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dynamic_to_json(value: &Dynamic) -> Json {
|
||||
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::<rhai::Array>() {
|
||||
return Json::Array(arr.iter().map(dynamic_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(), dynamic_to_json(&v));
|
||||
}
|
||||
return Json::Object(out);
|
||||
}
|
||||
// Anything else (timestamps, custom types) — best-effort string form.
|
||||
Json::String(value.to_string())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Error mapping
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -7,10 +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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
77
crates/executor-core/src/sdk/bridge.rs
Normal file
77
crates/executor-core/src/sdk/bridge.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
//! JSON ↔ Rhai `Dynamic` value bridge.
|
||||
//!
|
||||
//! Originally inline in `engine.rs`; moved here for v1.1.0 so future
|
||||
//! service modules (KV in v1.1.1, docs in v1.1.2, …) can convert
|
||||
//! values without `engine.rs` being the only owner of the conversions.
|
||||
//! Behaviour is unchanged from the pre-extraction implementation —
|
||||
//! `sdk_contract.rs::json_round_trip_preserves_nested_shapes` pins the
|
||||
//! observable round-trip.
|
||||
|
||||
use rhai::{Dynamic, Map};
|
||||
use serde_json::Value as Json;
|
||||
|
||||
/// Convert a `serde_json::Value` into a Rhai `Dynamic` suitable for
|
||||
/// pushing into a script's scope. Numbers prefer the narrowest type
|
||||
/// (`i64` over `f64`); anything that can't round-trip falls back to a
|
||||
/// string so the script always sees a defined value.
|
||||
pub fn json_to_dynamic(value: Json) -> Dynamic {
|
||||
match value {
|
||||
Json::Null => Dynamic::UNIT,
|
||||
Json::Bool(b) => b.into(),
|
||||
Json::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
i.into()
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
f.into()
|
||||
} else {
|
||||
n.to_string().into()
|
||||
}
|
||||
}
|
||||
Json::String(s) => s.into(),
|
||||
Json::Array(arr) => arr
|
||||
.into_iter()
|
||||
.map(json_to_dynamic)
|
||||
.collect::<Vec<Dynamic>>()
|
||||
.into(),
|
||||
Json::Object(obj) => {
|
||||
let mut m = Map::new();
|
||||
for (k, v) in obj {
|
||||
m.insert(k.into(), json_to_dynamic(v));
|
||||
}
|
||||
Dynamic::from(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a Rhai `Dynamic` back to a `serde_json::Value`. Custom Rhai
|
||||
/// types (timestamps, user-registered modules) fall back to their
|
||||
/// `Display` form so they appear as strings in JSON output rather than
|
||||
/// failing the response build.
|
||||
pub fn dynamic_to_json(value: &Dynamic) -> Json {
|
||||
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::<rhai::Array>() {
|
||||
return Json::Array(arr.iter().map(dynamic_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(), dynamic_to_json(&v));
|
||||
}
|
||||
return Json::Object(out);
|
||||
}
|
||||
Json::String(value.to_string())
|
||||
}
|
||||
10
crates/executor-core/src/sdk/cx.rs
Normal file
10
crates/executor-core/src/sdk/cx.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
//! Re-export of `picloud_shared::SdkCallCx`.
|
||||
//!
|
||||
//! The type itself lives in `picloud-shared` because future stateful
|
||||
//! service impls live in `manager-core` (which `executor-core` must
|
||||
//! not depend on) and need to reference the same cx shape. This
|
||||
//! re-export lets executor-side code write
|
||||
//! `use picloud_executor_core::sdk::SdkCallCx;` instead of reaching
|
||||
//! into `picloud_shared` for one type.
|
||||
|
||||
pub use picloud_shared::SdkCallCx;
|
||||
84
crates/executor-core/src/sdk/dead_letters.rs
Normal file
84
crates/executor-core/src/sdk/dead_letters.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
//! `dead_letters::` Rhai bridge.
|
||||
//!
|
||||
//! ```rhai
|
||||
//! dead_letters::replay("01234567-..."); // re-enqueue + mark replayed
|
||||
//! dead_letters::resolve("01234567-...", "ignored"); // close out the row
|
||||
//! ```
|
||||
//!
|
||||
//! Sync↔async via `Handle::current().block_on(...)` — same pattern as
|
||||
//! the `kv::` bridge (works because `LocalExecutorClient` runs the
|
||||
//! script under `spawn_blocking`).
|
||||
//!
|
||||
//! `dead_letters::list(filter)` is intentionally NOT shipped — design
|
||||
//! notes §4 defers it to v1.2 to align with the `docs::find()` query
|
||||
//! DSL.
|
||||
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{DeadLetterError, DeadLetterId, SdkCallCx, Services};
|
||||
use rhai::{Engine as RhaiEngine, EvalAltResult, Module};
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let svc = services.dead_letters.clone();
|
||||
let mut module = Module::new();
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"replay",
|
||||
move |id: &str| -> Result<(), Box<EvalAltResult>> {
|
||||
let dl_id = parse_dl_id(id)?;
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.replay(&cx, dl_id).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"resolve",
|
||||
move |id: &str, reason: &str| -> Result<(), Box<EvalAltResult>> {
|
||||
let dl_id = parse_dl_id(id)?;
|
||||
let reason = reason.to_string();
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.resolve(&cx, dl_id, &reason).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
engine.register_static_module("dead_letters", module.into());
|
||||
}
|
||||
|
||||
fn parse_dl_id(s: &str) -> Result<DeadLetterId, Box<EvalAltResult>> {
|
||||
Uuid::from_str(s)
|
||||
.map(DeadLetterId::from)
|
||||
.map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("dead_letters: invalid id {s:?}: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn block_on<F>(fut: F) -> Result<(), Box<EvalAltResult>>
|
||||
where
|
||||
F: std::future::Future<Output = Result<(), DeadLetterError>> + Send,
|
||||
{
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("dead_letters: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("dead_letters: {err}").into(), rhai::Position::NONE)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
255
crates/executor-core/src/sdk/docs.rs
Normal file
255
crates/executor-core/src/sdk/docs.rs
Normal file
@@ -0,0 +1,255 @@
|
||||
//! `docs::` Rhai bridge — collection-scoped handle pattern, v1.1.2.
|
||||
//!
|
||||
//! ```rhai
|
||||
//! let users = docs::collection("users");
|
||||
//! let id = users.create(#{ name: "Alice", tier: "gold" });
|
||||
//! let doc = users.get(id); // envelope or () if missing
|
||||
//! let golds = users.find(#{ tier: "gold" });
|
||||
//! let one = users.find_one(#{ tier: "gold" });
|
||||
//! users.update(id, #{ name: "Alice", tier: "platinum" });
|
||||
//! let removed = users.delete(id); // bool was-present
|
||||
//! let page = users.list(#{ cursor: (), limit: 100 });
|
||||
//! ```
|
||||
//!
|
||||
//! Mirrors `kv.rs`: `DocsHandle` captures the collection + service +
|
||||
//! per-call cx; methods bind via `engine.register_fn` so scripts call
|
||||
//! them with dot-notation. **The service derives `app_id` from
|
||||
//! `cx.app_id` — never from any closure argument.** Cross-app
|
||||
//! isolation boundary; same as KV.
|
||||
//!
|
||||
//! Doc shape returned by `get`/`find`/`find_one`/`list`: an envelope
|
||||
//! `#{ id, data: #{...}, created_at, updated_at }`. Decision D in the
|
||||
//! v1.1.2 plan — explicit metadata vs user-data separation.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{DocId, DocRow, DocsError, DocsService, SdkCallCx, Services};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::bridge::{dynamic_to_json, json_to_dynamic};
|
||||
|
||||
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
||||
/// plus an owned string).
|
||||
#[derive(Clone)]
|
||||
pub struct DocsHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn DocsService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let docs_service = services.docs.clone();
|
||||
|
||||
let mut module = Module::new();
|
||||
{
|
||||
let docs_service = docs_service.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"collection",
|
||||
move |name: &str| -> Result<DocsHandle, Box<EvalAltResult>> {
|
||||
if name.is_empty() {
|
||||
return Err("docs::collection name must not be empty".into());
|
||||
}
|
||||
Ok(DocsHandle {
|
||||
collection: name.to_string(),
|
||||
service: docs_service.clone(),
|
||||
cx: cx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
engine.register_static_module("docs", module.into());
|
||||
|
||||
engine.register_type_with_name::<DocsHandle>("DocsHandle");
|
||||
|
||||
register_create(engine);
|
||||
register_get(engine);
|
||||
register_find(engine);
|
||||
register_find_one(engine);
|
||||
register_update(engine);
|
||||
register_delete(engine);
|
||||
register_list(engine);
|
||||
}
|
||||
|
||||
fn register_create(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"create",
|
||||
|handle: &mut DocsHandle, data: Map| -> Result<String, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let json = dynamic_to_json(&Dynamic::from(data));
|
||||
let id = block_on(async move { h.service.create(&h.cx, &h.collection, json).await })?;
|
||||
Ok(id.to_string())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_get(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"get",
|
||||
|handle: &mut DocsHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let parsed_id = parse_doc_id(id)?;
|
||||
let row =
|
||||
block_on(async move { h.service.get(&h.cx, &h.collection, parsed_id).await })?;
|
||||
Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d))))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_find(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"find",
|
||||
|handle: &mut DocsHandle, filter: Map| -> Result<Array, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let json = dynamic_to_json(&Dynamic::from(filter));
|
||||
let rows = block_on(async move { h.service.find(&h.cx, &h.collection, json).await })?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|d| Dynamic::from(doc_to_map(d)))
|
||||
.collect::<Vec<Dynamic>>())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_find_one(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"find_one",
|
||||
|handle: &mut DocsHandle, filter: Map| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let json = dynamic_to_json(&Dynamic::from(filter));
|
||||
let row =
|
||||
block_on(async move { h.service.find_one(&h.cx, &h.collection, json).await })?;
|
||||
Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d))))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_update(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"update",
|
||||
|handle: &mut DocsHandle, id: &str, data: Map| -> Result<(), Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let parsed_id = parse_doc_id(id)?;
|
||||
let json = dynamic_to_json(&Dynamic::from(data));
|
||||
block_on(async move {
|
||||
h.service
|
||||
.update(&h.cx, &h.collection, parsed_id, json)
|
||||
.await
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_delete(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"delete",
|
||||
|handle: &mut DocsHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let parsed_id = parse_doc_id(id)?;
|
||||
block_on(async move { h.service.delete(&h.cx, &h.collection, parsed_id).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_list(engine: &mut RhaiEngine) {
|
||||
// Zero-arg form: full page from the start.
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut DocsHandle| -> Result<Map, Box<EvalAltResult>> { list_call(handle, None, 0) },
|
||||
);
|
||||
// One-arg form: pass `#{ cursor, limit }` map. Either field is
|
||||
// optional; missing/unit → defaults.
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut DocsHandle, args: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||
let cursor = match args.get("cursor") {
|
||||
Some(d) if !d.is_unit() => {
|
||||
Some(d.clone().into_string().map_err(|_| -> Box<EvalAltResult> {
|
||||
"docs::list: 'cursor' must be a string or ()".into()
|
||||
})?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let limit = match args.get("limit") {
|
||||
Some(d) if !d.is_unit() => {
|
||||
let n = d.as_int().map_err(|_| -> Box<EvalAltResult> {
|
||||
"docs::list: 'limit' must be an integer".into()
|
||||
})?;
|
||||
u32::try_from(n.max(0)).unwrap_or(0)
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
list_call(handle, cursor, limit)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn list_call(
|
||||
handle: &DocsHandle,
|
||||
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 docs: Array = page
|
||||
.docs
|
||||
.iter()
|
||||
.map(|d| Dynamic::from(doc_to_map(d)))
|
||||
.collect();
|
||||
m.insert("docs".into(), docs.into());
|
||||
m.insert(
|
||||
"next_cursor".into(),
|
||||
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Build the `{ id, data, created_at, updated_at }` envelope per
|
||||
/// Decision D. Scripts read user fields via `doc.data.<field>`; `id`
|
||||
/// and timestamps are direct children of the envelope.
|
||||
fn doc_to_map(doc: &DocRow) -> Map {
|
||||
let mut m = Map::new();
|
||||
m.insert("id".into(), doc.id.to_string().into());
|
||||
m.insert("data".into(), json_to_dynamic(doc.data.clone()));
|
||||
m.insert("created_at".into(), doc.created_at.to_rfc3339().into());
|
||||
m.insert("updated_at".into(), doc.updated_at.to_rfc3339().into());
|
||||
m
|
||||
}
|
||||
|
||||
fn parse_doc_id(id: &str) -> Result<DocId, Box<EvalAltResult>> {
|
||||
Uuid::parse_str(id).map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("docs: invalid id '{id}': {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
/// Mirrors `kv.rs::block_on` — Tokio runtime is reachable from inside
|
||||
/// the `spawn_blocking` wrapper that owns Rhai execution. Errors
|
||||
/// prefix with `"docs: "` so scripts see `docs: forbidden`,
|
||||
/// `docs: document not found`, `docs: unsupported operator: …`, etc.
|
||||
fn block_on<F, T>(fut: F) -> Result<T, Box<EvalAltResult>>
|
||||
where
|
||||
F: std::future::Future<Output = Result<T, DocsError>> + Send,
|
||||
T: Send,
|
||||
{
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("docs: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("docs: {err}").into(), rhai::Position::NONE).into()
|
||||
})
|
||||
}
|
||||
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()
|
||||
}
|
||||
193
crates/executor-core/src/sdk/kv.rs
Normal file
193
crates/executor-core/src/sdk/kv.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
//! `kv::` Rhai bridge — collection-scoped handle pattern.
|
||||
//!
|
||||
//! ```rhai
|
||||
//! let widgets = kv::collection("widgets");
|
||||
//! widgets.set("k", #{ n: 1 });
|
||||
//! let v = widgets.get("k"); // value or () if absent
|
||||
//! if widgets.has("k") { ... }
|
||||
//! widgets.delete("k"); // bool (was-present)
|
||||
//! let page = widgets.list(); // returns #{ keys: [...], next_cursor: () }
|
||||
//! ```
|
||||
//!
|
||||
//! The `KvHandle` custom Rhai type captures the collection name once
|
||||
//! and routes each call through the injected `Arc<dyn KvService>` with
|
||||
//! the per-call `Arc<SdkCallCx>`. **The service derives `app_id` from
|
||||
//! `cx.app_id` — `app_id` never appears in any function signature
|
||||
//! script-side, preserving cross-app isolation.**
|
||||
//!
|
||||
//! Sync↔async bridge: Rhai is synchronous; the underlying service is
|
||||
//! async. Closures wrap each call in `Handle::current().block_on(...)`
|
||||
//! — safe because `LocalExecutorClient` runs the script under
|
||||
//! `spawn_blocking`, so a runtime handle is reachable and blocking on
|
||||
//! it doesn't park an async worker.
|
||||
//!
|
||||
//! Error convention (per `docs/sdk-shape.md`):
|
||||
//! - throw on failure (Rhai runtime error string)
|
||||
//! - `()` for absent values (`get` on a missing key)
|
||||
//! - `bool` for predicates (`has`; also `delete` returns was-present)
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{KvError, KvService, SdkCallCx, 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};
|
||||
|
||||
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
||||
/// plus an owned string).
|
||||
#[derive(Clone)]
|
||||
pub struct KvHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn KvService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let kv_service = services.kv.clone();
|
||||
|
||||
// `kv::collection(name)` — handle constructor lives in the `kv`
|
||||
// static module so the script-visible call is `kv::collection(...)`.
|
||||
let mut module = Module::new();
|
||||
{
|
||||
let kv_service = kv_service.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"collection",
|
||||
move |name: &str| -> Result<KvHandle, Box<EvalAltResult>> {
|
||||
if name.is_empty() {
|
||||
return Err("kv::collection name must not be empty".into());
|
||||
}
|
||||
Ok(KvHandle {
|
||||
collection: name.to_string(),
|
||||
service: kv_service.clone(),
|
||||
cx: cx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
engine.register_static_module("kv", module.into());
|
||||
|
||||
// Methods on KvHandle — `register_fn` with `&mut KvHandle` first
|
||||
// argument lets Rhai dispatch them as `handle.get(k)` /
|
||||
// `handle.set(k, v)` / etc. through the dot-notation.
|
||||
engine.register_type_with_name::<KvHandle>("KvHandle");
|
||||
|
||||
register_get(engine);
|
||||
register_set(engine);
|
||||
register_has(engine);
|
||||
register_delete(engine);
|
||||
register_list(engine);
|
||||
}
|
||||
|
||||
fn register_get(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"get",
|
||||
|handle: &mut KvHandle, key: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
block_on(async move { h.service.get(&h.cx, &h.collection, key).await })
|
||||
.map(|opt| opt.map_or(Dynamic::UNIT, json_to_dynamic))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_set(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"set",
|
||||
|handle: &mut KvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let json = dynamic_to_json(&value);
|
||||
block_on(async move { h.service.set(&h.cx, &h.collection, key, json).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_has(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"has",
|
||||
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
block_on(async move { h.service.has(&h.cx, &h.collection, key).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_delete(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"delete",
|
||||
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
block_on(async move { h.service.delete(&h.cx, &h.collection, key).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_list(engine: &mut RhaiEngine) {
|
||||
// Zero-arg form — full page, no cursor.
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut KvHandle| -> Result<Map, Box<EvalAltResult>> { list_call(handle, None, 0) },
|
||||
);
|
||||
|
||||
// One-arg form — cursor only.
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut KvHandle, cursor: &str| -> Result<Map, Box<EvalAltResult>> {
|
||||
list_call(handle, Some(cursor.to_string()), 0)
|
||||
},
|
||||
);
|
||||
|
||||
// Two-arg form — cursor + limit.
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut KvHandle, 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)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn list_call(
|
||||
handle: &KvHandle,
|
||||
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 keys: Array = page.keys.into_iter().map(Dynamic::from).collect();
|
||||
m.insert("keys".into(), keys.into());
|
||||
m.insert(
|
||||
"next_cursor".into(),
|
||||
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Run an async future inside the synchronous Rhai context.
|
||||
///
|
||||
/// `LocalExecutorClient` wraps script execution in `spawn_blocking`, so
|
||||
/// the current Tokio runtime is reachable via `Handle::current()`. We
|
||||
/// block on it directly; we are NOT calling this from an async task,
|
||||
/// so blocking is the correct primitive (`block_in_place` would also
|
||||
/// work, but we're already on a blocking worker).
|
||||
fn block_on<F, T>(fut: F) -> Result<T, Box<EvalAltResult>>
|
||||
where
|
||||
F: std::future::Future<Output = Result<T, KvError>> + Send,
|
||||
T: Send,
|
||||
{
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("kv: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("kv: {err}").into(), rhai::Position::NONE).into()
|
||||
})
|
||||
}
|
||||
49
crates/executor-core/src/sdk/mod.rs
Normal file
49
crates/executor-core/src/sdk/mod.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
//! SDK plumbing — types and the per-call registration entry point.
|
||||
//!
|
||||
//! `executor-core` is responsible for building the per-invocation Rhai
|
||||
//! engine and wiring stateful services into it. v1.1.0 ships the
|
||||
//! shapes (`Services` bundle, `SdkCallCx`, `register_all` entry point)
|
||||
//! but no actual services — subsequent v1.1.x PRs (KV in v1.1.1,
|
||||
//! docs in v1.1.2, …) extend `register_all` rather than re-threading
|
||||
//! plumbing through `engine.rs`.
|
||||
//!
|
||||
//! Bridge functions (`json_to_dynamic` / `dynamic_to_json`) also live
|
||||
//! here so service modules can convert values without `engine.rs`
|
||||
//! being the only home for the conversion logic.
|
||||
|
||||
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};
|
||||
pub use cx::SdkCallCx;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::Services;
|
||||
use rhai::Engine as RhaiEngine;
|
||||
|
||||
/// Single hook every v1.1.x stateful service registers into. Called
|
||||
/// once per invocation, just after `build_engine` constructs the
|
||||
/// sandboxed Rhai engine and just before script compilation.
|
||||
///
|
||||
/// v1.1.1 wires the first stateful service (KV). Subsequent PRs add a
|
||||
/// single `<service>::register(...)` line per service.
|
||||
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.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()
|
||||
})
|
||||
}
|
||||
48
crates/executor-core/src/sdk/stdlib/base64.rs
Normal file
48
crates/executor-core/src/sdk/stdlib/base64.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! `base64::` — standard and URL-safe Base64.
|
||||
//!
|
||||
//! Two encoders are exposed: standard alphabet with padding (`encode`/
|
||||
//! `decode`) and URL-safe alphabet without padding (`encode_url`/
|
||||
//! `decode_url`). Each encoder accepts both `String` and `Blob` inputs
|
||||
//! as separate Rhai overloads; decoders always return `Blob` — the
|
||||
//! caller knows whether the original bytes were textual.
|
||||
|
||||
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
|
||||
use base64::Engine as _;
|
||||
use rhai::{Blob, Engine as RhaiEngine, EvalAltResult, Module};
|
||||
|
||||
pub fn register(engine: &mut RhaiEngine) {
|
||||
let mut module = Module::new();
|
||||
|
||||
module.set_native_fn("encode", |s: &str| -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(STANDARD.encode(s.as_bytes()))
|
||||
});
|
||||
module.set_native_fn("encode", |b: Blob| -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(STANDARD.encode(&b))
|
||||
});
|
||||
module.set_native_fn("decode", |s: &str| -> Result<Blob, Box<EvalAltResult>> {
|
||||
STANDARD
|
||||
.decode(s)
|
||||
.map_err(|e| format!("base64::decode: {e}").into())
|
||||
});
|
||||
|
||||
module.set_native_fn(
|
||||
"encode_url",
|
||||
|s: &str| -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(URL_SAFE_NO_PAD.encode(s.as_bytes()))
|
||||
},
|
||||
);
|
||||
module.set_native_fn(
|
||||
"encode_url",
|
||||
|b: Blob| -> Result<String, Box<EvalAltResult>> { Ok(URL_SAFE_NO_PAD.encode(&b)) },
|
||||
);
|
||||
module.set_native_fn(
|
||||
"decode_url",
|
||||
|s: &str| -> Result<Blob, Box<EvalAltResult>> {
|
||||
URL_SAFE_NO_PAD
|
||||
.decode(s)
|
||||
.map_err(|e| format!("base64::decode_url: {e}").into())
|
||||
},
|
||||
);
|
||||
|
||||
engine.register_static_module("base64", module.into());
|
||||
}
|
||||
21
crates/executor-core/src/sdk/stdlib/hex.rs
Normal file
21
crates/executor-core/src/sdk/stdlib/hex.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! `hex::` — hexadecimal encode/decode (lowercase output, case-
|
||||
//! insensitive input). String and Blob inputs are both accepted on
|
||||
//! encode; decode always returns `Blob`.
|
||||
|
||||
use rhai::{Blob, Engine as RhaiEngine, EvalAltResult, Module};
|
||||
|
||||
pub fn register(engine: &mut RhaiEngine) {
|
||||
let mut module = Module::new();
|
||||
|
||||
module.set_native_fn("encode", |s: &str| -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(hex::encode(s.as_bytes()))
|
||||
});
|
||||
module.set_native_fn("encode", |b: Blob| -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(hex::encode(&b))
|
||||
});
|
||||
module.set_native_fn("decode", |s: &str| -> Result<Blob, Box<EvalAltResult>> {
|
||||
hex::decode(s).map_err(|e| format!("hex::decode: {e}").into())
|
||||
});
|
||||
|
||||
engine.register_static_module("hex", module.into());
|
||||
}
|
||||
43
crates/executor-core/src/sdk/stdlib/json.rs
Normal file
43
crates/executor-core/src/sdk/stdlib/json.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
//! `json::` — JSON parse and stringify. Reuses the bridge functions in
|
||||
//! `crate::sdk::bridge` so script-visible JSON has the same shape
|
||||
//! (numbers, maps, arrays, nulls) as `ctx.request.body` already does.
|
||||
|
||||
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Module};
|
||||
|
||||
use crate::sdk::bridge::{dynamic_to_json, json_to_dynamic};
|
||||
|
||||
pub fn register(engine: &mut RhaiEngine) {
|
||||
let mut module = Module::new();
|
||||
register_parse(&mut module);
|
||||
register_stringify(&mut module);
|
||||
register_stringify_pretty(&mut module);
|
||||
engine.register_static_module("json", module.into());
|
||||
}
|
||||
|
||||
fn register_parse(module: &mut Module) {
|
||||
module.set_native_fn("parse", |s: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(s).map_err(|e| format!("json::parse: {e}"))?;
|
||||
Ok(json_to_dynamic(value))
|
||||
});
|
||||
}
|
||||
|
||||
fn register_stringify(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"stringify",
|
||||
|v: Dynamic| -> Result<String, Box<EvalAltResult>> {
|
||||
serde_json::to_string(&dynamic_to_json(&v))
|
||||
.map_err(|e| format!("json::stringify: {e}").into())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_stringify_pretty(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"stringify_pretty",
|
||||
|v: Dynamic| -> Result<String, Box<EvalAltResult>> {
|
||||
serde_json::to_string_pretty(&dynamic_to_json(&v))
|
||||
.map_err(|e| format!("json::stringify_pretty: {e}").into())
|
||||
},
|
||||
);
|
||||
}
|
||||
25
crates/executor-core/src/sdk/stdlib/mod.rs
Normal file
25
crates/executor-core/src/sdk/stdlib/mod.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
//! Stateless utility modules registered once at engine build via
|
||||
//! `Engine::register_static_module`. They have no per-call state, no
|
||||
//! cross-app sensitivity, and no `SdkCallCx` — distinguishing them
|
||||
//! from stateful service modules (KV, docs, …) which hook into
|
||||
//! `sdk::register_all` instead. See [docs/sdk-shape.md](../../../../../docs/sdk-shape.md).
|
||||
|
||||
use rhai::Engine as RhaiEngine;
|
||||
|
||||
pub mod base64;
|
||||
pub mod hex;
|
||||
pub mod json;
|
||||
pub mod random;
|
||||
pub mod regex;
|
||||
pub mod time;
|
||||
pub mod url;
|
||||
|
||||
pub fn register_stdlib(engine: &mut RhaiEngine) {
|
||||
regex::register(engine);
|
||||
random::register(engine);
|
||||
time::register(engine);
|
||||
json::register(engine);
|
||||
base64::register(engine);
|
||||
hex::register(engine);
|
||||
url::register(engine);
|
||||
}
|
||||
70
crates/executor-core/src/sdk/stdlib/random.rs
Normal file
70
crates/executor-core/src/sdk/stdlib/random.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
//! `random::` — CSPRNG primitives (`rand::rngs::OsRng`).
|
||||
//!
|
||||
//! Only the OS RNG is exposed. No "fast non-crypto" variant — scripts
|
||||
//! should not pick between secure and insecure entropy. Output sizes
|
||||
//! are capped to keep a single script call from blowing host memory.
|
||||
|
||||
use rand::distributions::{Alphanumeric, DistString};
|
||||
use rand::{rngs::OsRng, Rng, RngCore};
|
||||
use rhai::{Blob, Engine as RhaiEngine, EvalAltResult, Module};
|
||||
use uuid::Uuid;
|
||||
|
||||
const MAX_BYTES: i64 = 65_536;
|
||||
const MAX_STRING: i64 = 4_096;
|
||||
|
||||
pub fn register(engine: &mut RhaiEngine) {
|
||||
let mut module = Module::new();
|
||||
register_int(&mut module);
|
||||
register_float(&mut module);
|
||||
register_bytes(&mut module);
|
||||
register_string(&mut module);
|
||||
register_uuid(&mut module);
|
||||
engine.register_static_module("random", module.into());
|
||||
}
|
||||
|
||||
fn register_int(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"int",
|
||||
|min: i64, max: i64| -> Result<i64, Box<EvalAltResult>> {
|
||||
if min > max {
|
||||
return Err(format!("random::int: min ({min}) > max ({max})").into());
|
||||
}
|
||||
Ok(OsRng.gen_range(min..=max))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_float(module: &mut Module) {
|
||||
module.set_native_fn("float", || -> Result<f64, Box<EvalAltResult>> {
|
||||
Ok(OsRng.gen::<f64>())
|
||||
});
|
||||
}
|
||||
|
||||
fn register_bytes(module: &mut Module) {
|
||||
module.set_native_fn("bytes", |n: i64| -> Result<Blob, Box<EvalAltResult>> {
|
||||
if !(0..=MAX_BYTES).contains(&n) {
|
||||
return Err(format!("random::bytes: n must be in 0..={MAX_BYTES}, got {n}").into());
|
||||
}
|
||||
// Safe: n is non-negative and bounded by MAX_BYTES, which fits in usize.
|
||||
let len = usize::try_from(n).expect("n bounded above by MAX_BYTES");
|
||||
let mut buf = vec![0u8; len];
|
||||
OsRng.fill_bytes(&mut buf);
|
||||
Ok(buf)
|
||||
});
|
||||
}
|
||||
|
||||
fn register_string(module: &mut Module) {
|
||||
module.set_native_fn("string", |n: i64| -> Result<String, Box<EvalAltResult>> {
|
||||
if !(0..=MAX_STRING).contains(&n) {
|
||||
return Err(format!("random::string: n must be in 0..={MAX_STRING}, got {n}").into());
|
||||
}
|
||||
let len = usize::try_from(n).expect("n bounded above by MAX_STRING");
|
||||
Ok(Alphanumeric.sample_string(&mut OsRng, len))
|
||||
});
|
||||
}
|
||||
|
||||
fn register_uuid(module: &mut Module) {
|
||||
module.set_native_fn("uuid", || -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(Uuid::new_v4().to_string())
|
||||
});
|
||||
}
|
||||
105
crates/executor-core/src/sdk/stdlib/regex.rs
Normal file
105
crates/executor-core/src/sdk/stdlib/regex.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
//! `regex::` — non-backtracking regular expressions (Rust `regex` crate).
|
||||
//!
|
||||
//! Patterns compile per call. No cache: premature for v1.1.0, and the
|
||||
//! `regex` crate's linear-time guarantees keep per-call cost bounded.
|
||||
//! Catastrophic patterns are rejected at compile time by the crate
|
||||
//! itself; no extra defense needed.
|
||||
|
||||
use regex::Regex;
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Module};
|
||||
|
||||
pub fn register(engine: &mut RhaiEngine) {
|
||||
let mut module = Module::new();
|
||||
register_is_match(&mut module);
|
||||
register_find(&mut module);
|
||||
register_find_all(&mut module);
|
||||
register_replace(&mut module);
|
||||
register_replace_all(&mut module);
|
||||
register_split(&mut module);
|
||||
register_captures(&mut module);
|
||||
engine.register_static_module("regex", module.into());
|
||||
}
|
||||
|
||||
fn compile(pattern: &str) -> Result<Regex, Box<EvalAltResult>> {
|
||||
Regex::new(pattern).map_err(|e| format!("invalid regex: {e}").into())
|
||||
}
|
||||
|
||||
fn register_is_match(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"is_match",
|
||||
|pattern: &str, text: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
Ok(compile(pattern)?.is_match(text))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_find(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"find",
|
||||
|pattern: &str, text: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
Ok(compile(pattern)?
|
||||
.find(text)
|
||||
.map_or(Dynamic::UNIT, |m| Dynamic::from(m.as_str().to_string())))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_find_all(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"find_all",
|
||||
|pattern: &str, text: &str| -> Result<Array, Box<EvalAltResult>> {
|
||||
Ok(compile(pattern)?
|
||||
.find_iter(text)
|
||||
.map(|m| Dynamic::from(m.as_str().to_string()))
|
||||
.collect())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_replace(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"replace",
|
||||
|pattern: &str, text: &str, replacement: &str| -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(compile(pattern)?.replace(text, replacement).into_owned())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_replace_all(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"replace_all",
|
||||
|pattern: &str, text: &str, replacement: &str| -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(compile(pattern)?
|
||||
.replace_all(text, replacement)
|
||||
.into_owned())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_split(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"split",
|
||||
|pattern: &str, text: &str| -> Result<Array, Box<EvalAltResult>> {
|
||||
Ok(compile(pattern)?
|
||||
.split(text)
|
||||
.map(|s| Dynamic::from(s.to_string()))
|
||||
.collect())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_captures(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"captures",
|
||||
|pattern: &str, text: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let re = compile(pattern)?;
|
||||
Ok(re.captures(text).map_or(Dynamic::UNIT, |caps| {
|
||||
let arr: Array = caps
|
||||
.iter()
|
||||
.map(|m| m.map_or(Dynamic::UNIT, |m| Dynamic::from(m.as_str().to_string())))
|
||||
.collect();
|
||||
Dynamic::from(arr)
|
||||
}))
|
||||
},
|
||||
);
|
||||
}
|
||||
68
crates/executor-core/src/sdk/stdlib/time.rs
Normal file
68
crates/executor-core/src/sdk/stdlib/time.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! `time::` — UTC time. The canonical "time value" is milliseconds
|
||||
//! since the Unix epoch as `i64`. ISO 8601 strings are for parsing and
|
||||
//! display only. UTC only — no timezone support in v1.1.0 (would pull
|
||||
//! in chrono-tz, deferred until a real use case demands it).
|
||||
|
||||
use chrono::{DateTime, SecondsFormat, Utc};
|
||||
use rhai::{Engine as RhaiEngine, EvalAltResult, Module};
|
||||
|
||||
pub fn register(engine: &mut RhaiEngine) {
|
||||
let mut module = Module::new();
|
||||
register_now(&mut module);
|
||||
register_now_ms(&mut module);
|
||||
register_parse(&mut module);
|
||||
register_format(&mut module);
|
||||
register_add_seconds(&mut module);
|
||||
register_diff_seconds(&mut module);
|
||||
engine.register_static_module("time", module.into());
|
||||
}
|
||||
|
||||
fn register_now(module: &mut Module) {
|
||||
module.set_native_fn("now", || -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true))
|
||||
});
|
||||
}
|
||||
|
||||
fn register_now_ms(module: &mut Module) {
|
||||
module.set_native_fn("now_ms", || -> Result<i64, Box<EvalAltResult>> {
|
||||
Ok(Utc::now().timestamp_millis())
|
||||
});
|
||||
}
|
||||
|
||||
fn register_parse(module: &mut Module) {
|
||||
module.set_native_fn("parse", |iso: &str| -> Result<i64, Box<EvalAltResult>> {
|
||||
DateTime::parse_from_rfc3339(iso)
|
||||
.map(|dt| dt.timestamp_millis())
|
||||
.map_err(|e| format!("time::parse: invalid ISO 8601 / RFC 3339: {e}").into())
|
||||
});
|
||||
}
|
||||
|
||||
fn register_format(module: &mut Module) {
|
||||
module.set_native_fn("format", |ms: i64| -> Result<String, Box<EvalAltResult>> {
|
||||
DateTime::<Utc>::from_timestamp_millis(ms)
|
||||
.map(|dt| dt.to_rfc3339_opts(SecondsFormat::Millis, true))
|
||||
.ok_or_else(|| format!("time::format: ms ({ms}) out of representable range").into())
|
||||
});
|
||||
}
|
||||
|
||||
fn register_add_seconds(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"add_seconds",
|
||||
|ms: i64, secs: i64| -> Result<i64, Box<EvalAltResult>> {
|
||||
secs.checked_mul(1000)
|
||||
.and_then(|delta| ms.checked_add(delta))
|
||||
.ok_or_else(|| format!("time::add_seconds: overflow (ms={ms}, secs={secs})").into())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_diff_seconds(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"diff_seconds",
|
||||
|a_ms: i64, b_ms: i64| -> Result<i64, Box<EvalAltResult>> {
|
||||
b_ms.checked_sub(a_ms)
|
||||
.map(|d| d / 1000)
|
||||
.ok_or_else(|| format!("time::diff_seconds: overflow (a={a_ms}, b={b_ms})").into())
|
||||
},
|
||||
);
|
||||
}
|
||||
64
crates/executor-core/src/sdk/stdlib/url.rs
Normal file
64
crates/executor-core/src/sdk/stdlib/url.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
//! `url::` — RFC 3986 percent-encoding.
|
||||
//!
|
||||
//! `encode`/`decode` operate on opaque component values; `encode_query`
|
||||
//! builds an `application/x-www-form-urlencoded`-style query string
|
||||
//! from a Rhai `Map`. Key ordering is the map's natural order (Rhai's
|
||||
//! `Map` is a `BTreeMap`, so keys come out alphabetically — fine for
|
||||
//! query strings, which RFC 3986 leaves unordered).
|
||||
|
||||
use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
|
||||
use rhai::{Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
|
||||
/// RFC 3986 unreserved set: `A-Z / a-z / 0-9 / - / _ / . / ~`.
|
||||
/// Everything outside this set gets percent-encoded.
|
||||
const UNRESERVED: &AsciiSet = &NON_ALPHANUMERIC
|
||||
.remove(b'-')
|
||||
.remove(b'_')
|
||||
.remove(b'.')
|
||||
.remove(b'~');
|
||||
|
||||
pub fn register(engine: &mut RhaiEngine) {
|
||||
let mut module = Module::new();
|
||||
register_encode(&mut module);
|
||||
register_decode(&mut module);
|
||||
register_encode_query(&mut module);
|
||||
engine.register_static_module("url", module.into());
|
||||
}
|
||||
|
||||
fn register_encode(module: &mut Module) {
|
||||
module.set_native_fn("encode", |s: &str| -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(utf8_percent_encode(s, UNRESERVED).to_string())
|
||||
});
|
||||
}
|
||||
|
||||
fn register_decode(module: &mut Module) {
|
||||
module.set_native_fn("decode", |s: &str| -> Result<String, Box<EvalAltResult>> {
|
||||
percent_decode_str(s)
|
||||
.decode_utf8()
|
||||
.map(std::borrow::Cow::into_owned)
|
||||
.map_err(|e| format!("url::decode: invalid UTF-8: {e}").into())
|
||||
});
|
||||
}
|
||||
|
||||
fn register_encode_query(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"encode_query",
|
||||
|m: Map| -> Result<String, Box<EvalAltResult>> {
|
||||
let mut out = String::new();
|
||||
for (k, v) in m {
|
||||
if !out.is_empty() {
|
||||
out.push('&');
|
||||
}
|
||||
out.push_str(&utf8_percent_encode(&k, UNRESERVED).to_string());
|
||||
out.push('=');
|
||||
// Coerce values via `to_string` rather than throwing on
|
||||
// non-strings — scripts commonly pass numbers/bools here
|
||||
// and a forced cast at the call site is friction with
|
||||
// no upside.
|
||||
let value = v.to_string();
|
||||
out.push_str(&utf8_percent_encode(&value, UNRESERVED).to_string());
|
||||
}
|
||||
Ok(out)
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{ExecutionId, RequestId, ScriptId, ScriptSandbox};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, Principal, RequestId, ScriptId, ScriptSandbox, TriggerEvent,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -50,6 +52,49 @@ pub struct ExecRequest {
|
||||
/// override) before the Rhai engine is built.
|
||||
#[serde(default)]
|
||||
pub sandbox_overrides: ScriptSandbox,
|
||||
|
||||
/// Owning application. Source of truth for every `(app_id, …)`
|
||||
/// storage lookup the script makes via stateful SDK services.
|
||||
/// Internal-only; not surfaced via `ctx` (which the script sees).
|
||||
pub app_id: AppId,
|
||||
|
||||
/// Caller identity, when authenticated. `None` for unauthenticated
|
||||
/// data-plane HTTP requests (the common case for public scripts);
|
||||
/// `Some` when a bearer token or session cookie was resolved.
|
||||
/// Internal-only — exposed via `SdkCallCx` to service trait impls.
|
||||
///
|
||||
/// `#[serde(skip)]`: `ExecRequest` is serializable so cluster mode
|
||||
/// (v1.3+) can ship invocations to remote executors over HTTP, but
|
||||
/// `Principal` has no wire derivation today. Skipping here keeps
|
||||
/// v1.1.0 compiling; the cluster-mode PR will introduce a wire-safe
|
||||
/// snapshot then.
|
||||
#[serde(skip)]
|
||||
pub principal: Option<Principal>,
|
||||
|
||||
/// Triggers-framework depth. `0` for direct invocations. The
|
||||
/// dispatcher (v1.1.1) increments on each indirection to bound
|
||||
/// runaway feedback loops.
|
||||
#[serde(default)]
|
||||
pub trigger_depth: u32,
|
||||
|
||||
/// Originating execution id of a trigger chain. Equal to
|
||||
/// `execution_id` for direct invocations; preserves the root
|
||||
/// across fan-out for audit log grouping.
|
||||
pub root_execution_id: ExecutionId,
|
||||
|
||||
/// `true` only when the dispatcher resolved this invocation
|
||||
/// against a `dead_letter` trigger. The retry / dead-letter
|
||||
/// machinery short-circuits when this is set so handler failures
|
||||
/// cannot themselves be dead-lettered (design notes §4
|
||||
/// recursion-stop rule).
|
||||
#[serde(default)]
|
||||
pub is_dead_letter_handler: bool,
|
||||
|
||||
/// The originating event for a triggered invocation. `None` for
|
||||
/// direct ingress (sync HTTP, manual admin run). Flattened into
|
||||
/// `ctx.event` by the executor's per-call ctx builder.
|
||||
#[serde(default)]
|
||||
pub event: Option<TriggerEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -100,4 +145,11 @@ pub enum ExecError {
|
||||
|
||||
#[error("script runtime error: {0}")]
|
||||
Runtime(String),
|
||||
|
||||
/// Concurrency gate (orchestrator-core::ExecutionGate) refused
|
||||
/// admission. Surfaced as HTTP 503 with a `Retry-After` header.
|
||||
/// The gate enforces a global cap so a script storm can't park
|
||||
/// every blocking thread.
|
||||
#[error("execution declined: server at capacity (retry after {retry_after_secs}s)")]
|
||||
Overloaded { retry_after_secs: u32 },
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use picloud_executor_core::{Engine, ExecError, ExecRequest, InvocationType, Limits, LogLevel};
|
||||
use picloud_shared::{ExecutionId, RequestId, ScriptId, ScriptSandbox};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, KvEventOp, RequestId, ScriptId, ScriptSandbox, Services, TriggerEvent,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn req(body: serde_json::Value) -> ExecRequest {
|
||||
let execution_id = ExecutionId::new();
|
||||
ExecRequest {
|
||||
execution_id: ExecutionId::new(),
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
script_name: "test".into(),
|
||||
@@ -18,11 +21,17 @@ fn req(body: serde_json::Value) -> ExecRequest {
|
||||
query: BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id: AppId::new(),
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn engine() -> Engine {
|
||||
Engine::new(Limits::default())
|
||||
Engine::new(Limits::default(), Services::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -121,7 +130,7 @@ fn enforces_operation_budget() {
|
||||
max_operations: 1_000,
|
||||
..Limits::default()
|
||||
};
|
||||
let engine = Engine::new(limits);
|
||||
let engine = Engine::new(limits, Services::default());
|
||||
// 10_000 iterations vastly exceeds 1_000 ops.
|
||||
let src = r"let n = 0; for i in 0..10000 { n += 1; } n";
|
||||
let err = engine
|
||||
@@ -230,3 +239,67 @@ fn body_passes_through_nested_json_round_trip() {
|
||||
let resp = engine().execute(src, req(body.clone())).unwrap();
|
||||
assert_eq!(resp.body, body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctx_event_absent_for_direct_invocations() {
|
||||
// Scripts not fired through the triggers framework see no
|
||||
// `ctx.event` key — they can use `"event" in ctx` to detect.
|
||||
let src = r#"
|
||||
if "event" in ctx { #{ statusCode: 500, body: "should be absent" } }
|
||||
else { "absent" }
|
||||
"#;
|
||||
let resp = engine().execute(src, req(json!(null))).unwrap();
|
||||
assert_eq!(resp.body, json!("absent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctx_event_kv_shape_matches_design_notes() {
|
||||
// Build an ExecRequest mimicking what the dispatcher hands a
|
||||
// KV-triggered handler — `event = Some(TriggerEvent::Kv { … })`.
|
||||
let mut r = req(json!(null));
|
||||
r.event = Some(TriggerEvent::Kv {
|
||||
op: KvEventOp::Insert,
|
||||
collection: "widgets".into(),
|
||||
key: "k1".into(),
|
||||
value: Some(json!({ "n": 1 })),
|
||||
});
|
||||
let src = r"
|
||||
#{
|
||||
source: ctx.event.source,
|
||||
op: ctx.event.op,
|
||||
collection: ctx.event.kv.collection,
|
||||
key: ctx.event.kv.key,
|
||||
value: ctx.event.kv.value
|
||||
}
|
||||
";
|
||||
let resp = engine().execute(src, r).unwrap();
|
||||
assert_eq!(
|
||||
resp.body,
|
||||
json!({
|
||||
"source": "kv",
|
||||
"op": "insert",
|
||||
"collection": "widgets",
|
||||
"key": "k1",
|
||||
"value": { "n": 1 }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctx_event_kv_delete_has_unit_value() {
|
||||
let mut r = req(json!(null));
|
||||
r.event = Some(TriggerEvent::Kv {
|
||||
op: KvEventOp::Delete,
|
||||
collection: "widgets".into(),
|
||||
key: "k1".into(),
|
||||
value: None,
|
||||
});
|
||||
let src = r"
|
||||
#{
|
||||
op: ctx.event.op,
|
||||
value_is_unit: ctx.event.kv.value == ()
|
||||
}
|
||||
";
|
||||
let resp = engine().execute(src, r).unwrap();
|
||||
assert_eq!(resp.body, json!({ "op": "delete", "value_is_unit": true }));
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits, LogLevel};
|
||||
use picloud_shared::{ExecutionId, RequestId, ScriptId, ScriptSandbox};
|
||||
use picloud_shared::{AppId, ExecutionId, RequestId, ScriptId, ScriptSandbox, Services};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -31,12 +31,13 @@ use serde_json::{json, Value};
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn engine() -> Engine {
|
||||
Engine::new(Limits::default())
|
||||
Engine::new(Limits::default(), Services::default())
|
||||
}
|
||||
|
||||
fn baseline_request() -> ExecRequest {
|
||||
let execution_id = ExecutionId::new();
|
||||
ExecRequest {
|
||||
execution_id: ExecutionId::new(),
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
script_name: "contract".into(),
|
||||
@@ -48,6 +49,12 @@ fn baseline_request() -> ExecRequest {
|
||||
query: BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id: AppId::new(),
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
526
crates/executor-core/tests/sdk_docs.rs
Normal file
526
crates/executor-core/tests/sdk_docs.rs
Normal file
@@ -0,0 +1,526 @@
|
||||
//! `docs::` SDK bridge integration tests — runs a real Rhai engine
|
||||
//! against an in-memory `DocsService` impl. Mirrors `tests/sdk_kv.rs`:
|
||||
//! `tokio::task::spawn_blocking` so the bridge's `block_on` has a
|
||||
//! reachable runtime.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{
|
||||
AppId, DocId, DocRow, DocsError, DocsListPage, DocsService, ExecutionId, NoopDeadLetterService,
|
||||
NoopEventEmitter, NoopHttpService, NoopKvService, NoopModuleSource, RequestId, ScriptId,
|
||||
ScriptSandbox, SdkCallCx, Services,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Default)]
|
||||
struct InMemoryDocs {
|
||||
data: Mutex<HashMap<(AppId, String, DocId), DocRow>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DocsService for InMemoryDocs {
|
||||
async fn create(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
data: Value,
|
||||
) -> Result<DocId, DocsError> {
|
||||
if !data.is_object() {
|
||||
return Err(DocsError::InvalidData);
|
||||
}
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
let row = DocRow {
|
||||
id,
|
||||
data,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
self.data
|
||||
.lock()
|
||||
.await
|
||||
.insert((cx.app_id, collection.to_string(), id), row);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<DocRow>, DocsError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(cx.app_id, collection.to_string(), id))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn find(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
filter: Value,
|
||||
) -> Result<Vec<DocRow>, DocsError> {
|
||||
// Tiny eval: extract top-level equalities + $in arrays + $gt
|
||||
// (text lex) so the bridge tests can run end-to-end against a
|
||||
// fake. This fake mirrors the real service's reject-unsupported
|
||||
// contract so the v1.2-pointer-error test goes through the
|
||||
// bridge's error-propagation path.
|
||||
let map = self.data.lock().await;
|
||||
let obj = filter
|
||||
.as_object()
|
||||
.ok_or_else(|| DocsError::InvalidFilter("filter must be a map/object".into()))?;
|
||||
reject_unsupported_operators(obj)?;
|
||||
let mut out: Vec<DocRow> = map
|
||||
.iter()
|
||||
.filter(|((a, c, _), _)| *a == cx.app_id && c == collection)
|
||||
.map(|(_, v)| v.clone())
|
||||
.filter(|row| matches_simple(&row.data, obj))
|
||||
.collect();
|
||||
if let Some(limit) = obj.get("$limit").and_then(Value::as_u64) {
|
||||
out.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn find_one(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
filter: Value,
|
||||
) -> Result<Option<DocRow>, DocsError> {
|
||||
Ok(self.find(cx, collection, filter).await?.into_iter().next())
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
data: Value,
|
||||
) -> Result<(), DocsError> {
|
||||
if !data.is_object() {
|
||||
return Err(DocsError::InvalidData);
|
||||
}
|
||||
let mut map = self.data.lock().await;
|
||||
let key = (cx.app_id, collection.to_string(), id);
|
||||
let Some(row) = map.get_mut(&key) else {
|
||||
return Err(DocsError::NotFound);
|
||||
};
|
||||
row.data = data;
|
||||
row.updated_at = Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, cx: &SdkCallCx, collection: &str, id: DocId) -> Result<bool, DocsError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.remove(&(cx.app_id, collection.to_string(), id))
|
||||
.is_some())
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
_cursor: Option<&str>,
|
||||
_limit: u32,
|
||||
) -> Result<DocsListPage, DocsError> {
|
||||
let mut docs: Vec<DocRow> = self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|((a, c, _), _)| *a == cx.app_id && c == collection)
|
||||
.map(|(_, v)| v.clone())
|
||||
.collect();
|
||||
docs.sort_by_key(|d| d.id);
|
||||
Ok(DocsListPage {
|
||||
docs,
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan an operator object for any `$xxx` key not in the v1.1.2
|
||||
/// allowlist and return the same shape of error the real parser
|
||||
/// emits. Top-level `$limit` is the only allowed modifier the fake
|
||||
/// engages with; the unsupported test passes `$regex`.
|
||||
fn reject_unsupported_operators(obj: &serde_json::Map<String, Value>) -> Result<(), DocsError> {
|
||||
const SUPPORTED_TOP_LEVEL: &[&str] = &["$limit", "$sort"];
|
||||
const SUPPORTED_NESTED: &[&str] = &["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in"];
|
||||
for (key, value) in obj {
|
||||
if let Some(stripped) = key.strip_prefix('$') {
|
||||
if !SUPPORTED_TOP_LEVEL.contains(&key.as_str()) {
|
||||
return Err(DocsError::UnsupportedOperator(format!(
|
||||
"docs::find: top-level modifier '${stripped}' is not supported in v1.1.2; planned for v1.2 advanced query"
|
||||
)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(inner) = value.as_object() {
|
||||
for op_key in inner.keys() {
|
||||
if op_key.starts_with('$') && !SUPPORTED_NESTED.contains(&op_key.as_str()) {
|
||||
return Err(DocsError::UnsupportedOperator(format!(
|
||||
"docs::find: operator '{op_key}' is not supported in v1.1.2; planned for v1.2 advanced query"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn matches_simple(data: &Value, filter: &serde_json::Map<String, Value>) -> bool {
|
||||
for (key, want) in filter {
|
||||
if key.starts_with('$') {
|
||||
// $limit handled in the find body.
|
||||
continue;
|
||||
}
|
||||
let actual = data.get(key);
|
||||
if let Some(obj) = want.as_object() {
|
||||
// operator object — handle $in and $gt only (enough for
|
||||
// the bridge tests to exercise the round-trip).
|
||||
if let Some(arr) = obj.get("$in").and_then(Value::as_array) {
|
||||
let Some(actual) = actual else {
|
||||
return false;
|
||||
};
|
||||
if !arr.iter().any(|v| v == actual) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(gt) = obj.get("$gt") {
|
||||
let Some(actual) = actual else {
|
||||
return false;
|
||||
};
|
||||
let a = actual.as_str().unwrap_or("");
|
||||
let b = gt.as_str().unwrap_or("");
|
||||
if a <= b {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if Some(want) != actual {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn make_engine() -> Arc<Engine> {
|
||||
let services = Services::new(
|
||||
Arc::new(NoopKvService),
|
||||
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))
|
||||
}
|
||||
|
||||
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: "docs-test".into(),
|
||||
invocation_type: InvocationType::Http,
|
||||
path: "/docs-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 docs_create_then_get_round_trip() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let users = docs::collection("users");
|
||||
let id = users.create(#{ name: "Alice", tier: "gold" });
|
||||
let doc = users.get(id);
|
||||
#{ id_matches: doc.id == id, data_name: doc.data.name }
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
let obj = body.as_object().unwrap();
|
||||
assert_eq!(obj["id_matches"], json!(true));
|
||||
assert_eq!(obj["data_name"], json!("Alice"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn docs_get_missing_returns_unit() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = docs::collection("users");
|
||||
let v = c.get("00000000-0000-0000-0000-000000000000");
|
||||
v == ()
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn docs_get_with_invalid_uuid_throws() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"docs::collection("users").get("not-a-uuid")"#;
|
||||
let req = baseline_request(app);
|
||||
let err = tokio::task::spawn_blocking(move || engine.execute(src, req))
|
||||
.await
|
||||
.unwrap()
|
||||
.expect_err("invalid uuid should throw");
|
||||
assert!(format!("{err:?}").contains("invalid id"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn docs_find_equality_returns_matches() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = docs::collection("users");
|
||||
c.create(#{ tier: "gold" });
|
||||
c.create(#{ tier: "silver" });
|
||||
c.create(#{ tier: "gold" });
|
||||
let golds = c.find(#{ tier: "gold" });
|
||||
golds.len()
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(body, json!(2));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn docs_find_with_in_operator() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = docs::collection("users");
|
||||
c.create(#{ tier: "gold" });
|
||||
c.create(#{ tier: "silver" });
|
||||
c.create(#{ tier: "platinum" });
|
||||
let hits = c.find(#{ tier: #{ "$in": ["gold", "platinum"] } });
|
||||
hits.len()
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(body, json!(2));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn docs_find_with_gt_comparison() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = docs::collection("events");
|
||||
c.create(#{ when: "2026-01-15" });
|
||||
c.create(#{ when: "2026-03-15" });
|
||||
c.create(#{ when: "2026-05-15" });
|
||||
let recent = c.find(#{ when: #{ "$gt": "2026-02-01" } });
|
||||
recent.len()
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(body, json!(2));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn docs_find_one_returns_envelope_or_unit() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = docs::collection("users");
|
||||
c.create(#{ tier: "gold" });
|
||||
let hit = c.find_one(#{ tier: "gold" });
|
||||
let miss = c.find_one(#{ tier: "platinum" });
|
||||
#{ hit_has_data: hit.data.tier == "gold", miss_is_unit: miss == () }
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
let obj = body.as_object().unwrap();
|
||||
assert_eq!(obj["hit_has_data"], json!(true));
|
||||
assert_eq!(obj["miss_is_unit"], json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn docs_update_then_get_reflects_change() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = docs::collection("users");
|
||||
let id = c.create(#{ name: "Alice", tier: "gold" });
|
||||
c.update(id, #{ name: "Alice", tier: "platinum" });
|
||||
c.get(id).data.tier
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(body, json!("platinum"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn docs_update_missing_throws() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = docs::collection("users");
|
||||
c.update("00000000-0000-0000-0000-000000000000", #{ x: 1 })
|
||||
"#;
|
||||
let req = baseline_request(app);
|
||||
let err = tokio::task::spawn_blocking(move || engine.execute(src, req))
|
||||
.await
|
||||
.unwrap()
|
||||
.expect_err("update missing should throw");
|
||||
assert!(format!("{err:?}").contains("not found"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn docs_delete_returns_was_present() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = docs::collection("users");
|
||||
let nope = c.delete("00000000-0000-0000-0000-000000000000");
|
||||
let id = c.create(#{ x: 1 });
|
||||
let yep = c.delete(id);
|
||||
#{ nope: nope, yep: yep }
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(body, json!({ "nope": false, "yep": true }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn docs_unsupported_operator_throws_with_v1_2_pointer() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = docs::collection("users");
|
||||
c.find(#{ name: #{ "$regex": "^A" } })
|
||||
"#;
|
||||
let req = baseline_request(app);
|
||||
let err = tokio::task::spawn_blocking(move || engine.execute(src, req))
|
||||
.await
|
||||
.unwrap()
|
||||
.expect_err("unsupported operator should throw");
|
||||
let msg = format!("{err:?}");
|
||||
assert!(msg.contains("$regex"), "msg: {msg}");
|
||||
assert!(msg.contains("v1.2"), "msg: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn docs_empty_collection_name_throws() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"docs::collection("")"#;
|
||||
let req = baseline_request(app);
|
||||
let err = tokio::task::spawn_blocking(move || engine.execute(src, req))
|
||||
.await
|
||||
.unwrap()
|
||||
.expect_err("empty collection should throw");
|
||||
assert!(format!("{err:?}").contains("docs::collection"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn docs_list_returns_docs_array() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = docs::collection("users");
|
||||
c.create(#{ a: 1 });
|
||||
c.create(#{ a: 2 });
|
||||
let page = c.list();
|
||||
page.docs.len()
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(body, json!(2));
|
||||
}
|
||||
|
||||
/// Cross-app isolation through the bridge — script with `app_id = A`
|
||||
/// must NOT see documents written from `app_id = B` even when the
|
||||
/// (collection, id) tuple is shared. The bridge captures `cx.app_id`
|
||||
/// via `Arc<SdkCallCx>` and the service derives storage `app_id` from
|
||||
/// it (never from a script arg).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn docs_bridge_preserves_cross_app_isolation() {
|
||||
let engine = make_engine();
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
|
||||
let writer = r#"
|
||||
let c = docs::collection("shared");
|
||||
let id = c.create(#{ from: "a" });
|
||||
id
|
||||
"#;
|
||||
let id_a = run_script(engine.clone(), writer, baseline_request(app_a)).await;
|
||||
let id_a_str = id_a.as_str().unwrap().to_string();
|
||||
|
||||
// App B looks up the same id under the same collection — should
|
||||
// see nothing because the service keyed it by app_id = A.
|
||||
let reader_src = format!(
|
||||
r#"
|
||||
let c = docs::collection("shared");
|
||||
let v = c.get("{id_a_str}");
|
||||
v == ()
|
||||
"#
|
||||
);
|
||||
let body = run_script(engine, &reader_src, baseline_request(app_b)).await;
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn docs_envelope_has_id_data_created_at_updated_at() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = docs::collection("users");
|
||||
let id = c.create(#{ name: "Alice" });
|
||||
let doc = c.get(id);
|
||||
// Probe each envelope field is present + correctly typed.
|
||||
#{
|
||||
has_id: type_of(doc.id) == "string",
|
||||
has_data: type_of(doc.data) == "map",
|
||||
has_created_at: type_of(doc.created_at) == "string",
|
||||
has_updated_at: type_of(doc.updated_at) == "string",
|
||||
user_field: doc.data.name
|
||||
}
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
let obj = body.as_object().unwrap();
|
||||
assert_eq!(obj["has_id"], json!(true));
|
||||
assert_eq!(obj["has_data"], json!(true));
|
||||
assert_eq!(obj["has_created_at"], json!(true));
|
||||
assert_eq!(obj["has_updated_at"], json!(true));
|
||||
assert_eq!(obj["user_field"], json!("Alice"));
|
||||
}
|
||||
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));
|
||||
}
|
||||
268
crates/executor-core/tests/sdk_kv.rs
Normal file
268
crates/executor-core/tests/sdk_kv.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
//! `kv::` SDK bridge integration tests — runs a real Rhai engine
|
||||
//! against an in-memory `KvService` impl. Mirrors how
|
||||
//! `orchestrator-core::LocalExecutorClient` invokes the engine: under
|
||||
//! `tokio::task::spawn_blocking` so the bridge's `block_on` has a
|
||||
//! reachable runtime.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, KvError, KvListPage, KvService, NoopDeadLetterService, NoopDocsService,
|
||||
NoopEventEmitter, NoopHttpService, NoopModuleSource, RequestId, ScriptId, ScriptSandbox,
|
||||
SdkCallCx, Services,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Default)]
|
||||
struct InMemoryKv {
|
||||
data: Mutex<HashMap<(AppId, String, String), Value>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl KvService for InMemoryKv {
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<Value>, KvError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(cx.app_id, collection.to_string(), key.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
value: Value,
|
||||
) -> Result<(), KvError> {
|
||||
self.data
|
||||
.lock()
|
||||
.await
|
||||
.insert((cx.app_id, collection.to_string(), key.to_string()), value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, KvError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.remove(&(cx.app_id, collection.to_string(), key.to_string()))
|
||||
.is_some())
|
||||
}
|
||||
|
||||
async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, KvError> {
|
||||
Ok(self.data.lock().await.contains_key(&(
|
||||
cx.app_id,
|
||||
collection.to_string(),
|
||||
key.to_string(),
|
||||
)))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<KvListPage, KvError> {
|
||||
let data = self.data.lock().await;
|
||||
let mut keys: Vec<String> = data
|
||||
.iter()
|
||||
.filter(|((a, c, _), _)| *a == cx.app_id && c == collection)
|
||||
.map(|((_, _, k), _)| k.clone())
|
||||
.filter(|k| cursor.is_none_or(|c| k.as_str() > c))
|
||||
.collect();
|
||||
keys.sort();
|
||||
let take = if limit == 0 {
|
||||
usize::MAX
|
||||
} else {
|
||||
limit as usize
|
||||
};
|
||||
let next_cursor = if keys.len() > take {
|
||||
keys.truncate(take);
|
||||
keys.last().cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(KvListPage { keys, next_cursor })
|
||||
}
|
||||
}
|
||||
|
||||
fn make_engine() -> Arc<Engine> {
|
||||
let services = Services::new(
|
||||
Arc::new(InMemoryKv::default()),
|
||||
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))
|
||||
}
|
||||
|
||||
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: "kv-test".into(),
|
||||
invocation_type: InvocationType::Http,
|
||||
path: "/kv-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 kv_set_then_get_round_trip() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let widgets = kv::collection("widgets");
|
||||
widgets.set("k1", #{ n: 1 });
|
||||
widgets.get("k1")
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(body, json!({ "n": 1 }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn kv_get_missing_returns_unit() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = kv::collection("widgets");
|
||||
let v = c.get("nope");
|
||||
v == ()
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn kv_has_returns_bool() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = kv::collection("widgets");
|
||||
let before = c.has("k");
|
||||
c.set("k", "v");
|
||||
let after = c.has("k");
|
||||
#{ before: before, after: after }
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(body, json!({ "before": false, "after": true }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn kv_delete_returns_was_present() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = kv::collection("widgets");
|
||||
let nope = c.delete("missing");
|
||||
c.set("k", 1);
|
||||
let yep = c.delete("k");
|
||||
#{ nope: nope, yep: yep }
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(body, json!({ "nope": false, "yep": true }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn kv_empty_collection_name_throws() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"kv::collection("")"#;
|
||||
let req = baseline_request(app);
|
||||
let err = tokio::task::spawn_blocking(move || engine.execute(src, req))
|
||||
.await
|
||||
.unwrap()
|
||||
.expect_err("empty collection should throw");
|
||||
assert!(format!("{err:?}").contains("kv::collection"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn kv_list_pages_via_cursor() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = kv::collection("widgets");
|
||||
for i in 0..5 { c.set(`k${i}`, i); }
|
||||
let p1 = c.list("", 2);
|
||||
let p2 = c.list(p1.next_cursor, 2);
|
||||
#{
|
||||
p1_keys: p1.keys,
|
||||
p1_cursor: p1.next_cursor,
|
||||
p2_keys: p2.keys,
|
||||
}
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
let obj = body.as_object().unwrap();
|
||||
let p1_keys = obj["p1_keys"].as_array().unwrap();
|
||||
let p2_keys = obj["p2_keys"].as_array().unwrap();
|
||||
assert_eq!(p1_keys.len(), 2);
|
||||
assert_eq!(p2_keys.len(), 2);
|
||||
assert!(obj["p1_cursor"].is_string());
|
||||
}
|
||||
|
||||
/// Cross-app isolation via `cx.app_id` — script with `app_id = A`
|
||||
/// cannot see entries from `app_id = B`. The kv:: bridge never
|
||||
/// surfaces `app_id` to the script, so this is enforced purely by the
|
||||
/// service deriving it from the captured `Arc<SdkCallCx>`.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn kv_bridge_preserves_cross_app_isolation() {
|
||||
let engine = make_engine();
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
|
||||
let writer = r#"
|
||||
let c = kv::collection("shared");
|
||||
c.set("k", "from-a");
|
||||
"ok"
|
||||
"#;
|
||||
let _ = run_script(engine.clone(), writer, baseline_request(app_a)).await;
|
||||
|
||||
// App B sees nothing under the same collection/key.
|
||||
let reader = r#"
|
||||
let c = kv::collection("shared");
|
||||
c.get("k")
|
||||
"#;
|
||||
let body = run_script(engine, reader, baseline_request(app_b)).await;
|
||||
assert_eq!(body, Value::Null);
|
||||
}
|
||||
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;
|
||||
}
|
||||
384
crates/executor-core/tests/stdlib.rs
Normal file
384
crates/executor-core/tests/stdlib.rs
Normal file
@@ -0,0 +1,384 @@
|
||||
//! Integration tests for the v1.1.0 stdlib utility modules.
|
||||
//!
|
||||
//! These exist alongside `sdk_contract.rs` rather than inside it
|
||||
//! because the stateless utilities aren't part of the same versioned
|
||||
//! SDK contract surface — `sdk_contract.rs` covers things that bump
|
||||
//! `SDK_VERSION` when they change; stdlib additions don't.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use picloud_executor_core::{Engine, ExecError, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{AppId, ExecutionId, RequestId, ScriptId, ScriptSandbox, Services};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Test harness — duplicated from sdk_contract.rs (each integration test
|
||||
// crate has its own; there is no tests/common/).
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn engine() -> Engine {
|
||||
Engine::new(Limits::default(), Services::default())
|
||||
}
|
||||
|
||||
fn baseline_request() -> ExecRequest {
|
||||
let execution_id = ExecutionId::new();
|
||||
ExecRequest {
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
script_name: "stdlib".into(),
|
||||
invocation_type: InvocationType::Http,
|
||||
path: "/stdlib-test".into(),
|
||||
headers: BTreeMap::new(),
|
||||
body: Value::Null,
|
||||
params: BTreeMap::new(),
|
||||
query: BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id: AppId::new(),
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run(source: &str) -> Value {
|
||||
engine()
|
||||
.execute(source, baseline_request())
|
||||
.expect("stdlib test should execute cleanly")
|
||||
.body
|
||||
}
|
||||
|
||||
fn run_err(source: &str) -> ExecError {
|
||||
engine()
|
||||
.execute(source, baseline_request())
|
||||
.expect_err("stdlib test expected to throw")
|
||||
}
|
||||
|
||||
fn assert_runtime_err(err: ExecError, needle: &str) {
|
||||
match err {
|
||||
ExecError::Runtime(msg) => assert!(
|
||||
msg.contains(needle),
|
||||
"runtime error did not contain `{needle}`: {msg}"
|
||||
),
|
||||
other => panic!("expected Runtime error containing `{needle}`, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// regex
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn regex_is_match_true_and_false() {
|
||||
assert_eq!(run(r#"regex::is_match("^h", "hello")"#), json!(true));
|
||||
assert_eq!(run(r#"regex::is_match("^x", "hello")"#), json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_find_returns_first_match() {
|
||||
assert_eq!(run(r#"regex::find("\\d+", "abc 42 def 99")"#), json!("42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_find_returns_unit_when_no_match() {
|
||||
// () serializes to JSON null via dynamic_to_json.
|
||||
assert_eq!(run(r#"regex::find("\\d+", "abc")"#), Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_find_all_returns_array() {
|
||||
assert_eq!(
|
||||
run(r#"regex::find_all("\\d+", "a1 b22 c333")"#),
|
||||
json!(["1", "22", "333"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_first_only() {
|
||||
assert_eq!(
|
||||
run(r#"regex::replace("a", "banana", "X")"#),
|
||||
json!("bXnana")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_all() {
|
||||
assert_eq!(
|
||||
run(r#"regex::replace_all("a", "banana", "X")"#),
|
||||
json!("bXnXnX")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_split() {
|
||||
assert_eq!(
|
||||
run(r#"regex::split(",\\s*", "a, b,c, d")"#),
|
||||
json!(["a", "b", "c", "d"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_captures_extracts_groups() {
|
||||
assert_eq!(
|
||||
run(r#"regex::captures("(\\d+)-(\\w+)", "42-abc")"#),
|
||||
json!(["42-abc", "42", "abc"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_captures_returns_unit_when_no_match() {
|
||||
assert_eq!(run(r#"regex::captures("(\\d+)", "abc")"#), Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_invalid_pattern_throws() {
|
||||
assert_runtime_err(run_err(r#"regex::is_match("(", "x")"#), "invalid regex");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// random
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn random_int_within_range() {
|
||||
// Run a few times to exercise the bounds — each call is independent.
|
||||
let body = run(r"
|
||||
let n = random::int(10, 20);
|
||||
n >= 10 && n <= 20
|
||||
");
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_int_throws_when_min_greater_than_max() {
|
||||
assert_runtime_err(run_err("random::int(20, 10)"), "min");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_float_in_unit_interval() {
|
||||
let body = run(r"
|
||||
let f = random::float();
|
||||
f >= 0.0 && f < 1.0
|
||||
");
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bytes_returns_blob_of_correct_length() {
|
||||
assert_eq!(run("random::bytes(16).len()"), json!(16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bytes_rejects_negative() {
|
||||
assert_runtime_err(run_err("random::bytes(-1)"), "random::bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bytes_rejects_oversize() {
|
||||
assert_runtime_err(run_err("random::bytes(70000)"), "random::bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_string_produces_alphanumeric_of_correct_length() {
|
||||
let body = run(r#"
|
||||
let s = random::string(32);
|
||||
s.len == 32 && regex::is_match("^[A-Za-z0-9]+$", s)
|
||||
"#);
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_uuid_has_canonical_format() {
|
||||
let body = run(
|
||||
r#"regex::is_match("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", random::uuid())"#,
|
||||
);
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// time
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn time_now_ms_is_positive() {
|
||||
let body = run("time::now_ms() > 0");
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_now_string_looks_like_iso() {
|
||||
let body = run(r#"regex::is_match("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}", time::now())"#);
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_parse_format_round_trip() {
|
||||
let body = run(r"
|
||||
let ms = 1700000000000;
|
||||
time::parse(time::format(ms)) == ms
|
||||
");
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_add_seconds() {
|
||||
assert_eq!(run("time::add_seconds(0, 60)"), json!(60_000));
|
||||
assert_eq!(run("time::add_seconds(1000, -1)"), json!(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_diff_seconds_truncates() {
|
||||
assert_eq!(run("time::diff_seconds(0, 65_500)"), json!(65));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_parse_rejects_garbage() {
|
||||
assert_runtime_err(run_err(r#"time::parse("nonsense")"#), "time::parse");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// json
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn json_parse_then_stringify_round_trip() {
|
||||
let body = run(r#"
|
||||
let src = `{"a":1,"b":"x"}`;
|
||||
json::stringify(json::parse(src)) == src
|
||||
"#);
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_stringify_compact() {
|
||||
assert_eq!(run(r"json::stringify(#{ a: 1 })"), json!(r#"{"a":1}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_stringify_pretty_has_newlines() {
|
||||
let body = run(r#"json::stringify_pretty(#{ a: 1 }).contains("\n")"#);
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_parse_invalid_throws() {
|
||||
assert_runtime_err(run_err(r#"json::parse("not json")"#), "json::parse");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// base64
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn base64_encode_string() {
|
||||
assert_eq!(run(r#"base64::encode("hi")"#), json!("aGk="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_decode_then_re_encode_round_trip() {
|
||||
assert_eq!(
|
||||
run(r#"base64::encode(base64::decode("aGVsbG8="))"#),
|
||||
json!("aGVsbG8=")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_encode_url_has_no_padding() {
|
||||
let body = run(r#"
|
||||
let s = base64::encode_url("hello world!?");
|
||||
!s.contains("=") && !s.contains("+") && !s.contains("/")
|
||||
"#);
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_decode_url_round_trip() {
|
||||
assert_eq!(
|
||||
run(r#"base64::encode_url(base64::decode_url("aGVsbG8"))"#),
|
||||
json!("aGVsbG8")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_decode_invalid_throws() {
|
||||
assert_runtime_err(run_err(r#"base64::decode("!!!")"#), "base64::decode");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// hex
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn hex_encode_produces_lowercase() {
|
||||
assert_eq!(run(r#"hex::encode("Z")"#), json!("5a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_decode_then_re_encode_round_trip() {
|
||||
// mixed-case input → lowercase output proves both case-insensitive
|
||||
// decode and lowercase encode.
|
||||
assert_eq!(
|
||||
run(r#"hex::encode(hex::decode("DeAdBeEf"))"#),
|
||||
json!("deadbeef")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_decode_returns_correct_length() {
|
||||
assert_eq!(run(r#"hex::decode("deadbeef").len()"#), json!(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_decode_invalid_throws() {
|
||||
assert_runtime_err(run_err(r#"hex::decode("xyz")"#), "hex::decode");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// url
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn url_encode_basic() {
|
||||
assert_eq!(run(r#"url::encode("hello world")"#), json!("hello%20world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_encode_preserves_unreserved() {
|
||||
assert_eq!(
|
||||
run(r#"url::encode("abcXYZ123-_.~")"#),
|
||||
json!("abcXYZ123-_.~")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_decode_round_trip() {
|
||||
assert_eq!(
|
||||
run(r#"url::decode(url::encode("hello world!?"))"#),
|
||||
json!("hello world!?")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_encode_query_basic() {
|
||||
// Map keys come out alphabetically (Rhai's Map is a BTreeMap).
|
||||
assert_eq!(
|
||||
run(r#"url::encode_query(#{ a: "1", b: "x y" })"#),
|
||||
json!("a=1&b=x%20y")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_encode_query_coerces_non_strings() {
|
||||
// Numbers and bools shouldn't throw; they coerce via to_string().
|
||||
let body = run(r"url::encode_query(#{ n: 42, b: true })");
|
||||
// Order is alphabetical: b before n.
|
||||
assert_eq!(body, json!("b=true&n=42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_decode_rejects_invalid_utf8() {
|
||||
assert_runtime_err(run_err(r#"url::decode("%FF%FE%80")"#), "url::decode");
|
||||
}
|
||||
@@ -10,24 +10,34 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
picloud-shared.workspace = true
|
||||
picloud-executor-core.workspace = true
|
||||
picloud-orchestrator-core.workspace = true
|
||||
|
||||
async-trait.workspace = true
|
||||
axum.workspace = true
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
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
|
||||
rand.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
|
||||
|
||||
28
crates/manager-core/migrations/0007_kv.sql
Normal file
28
crates/manager-core/migrations/0007_kv.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- v1.1.1: Key-value store — see blueprint §8.1 + docs/sdk-shape.md.
|
||||
--
|
||||
-- Identity tuple `(app_id, collection, key)`. `app_id` is first in the
|
||||
-- primary key so the implicit index is always per-app; cross-app reads
|
||||
-- cannot happen even with a buggy query. Collections are a required
|
||||
-- namespace inside an app — the same key can live in different
|
||||
-- collections without collision.
|
||||
--
|
||||
-- `value` is JSONB so scripts can store nested structures without
|
||||
-- a separate serialization step. No TTL column in v1.1.1; deferred
|
||||
-- until a concrete need surfaces (the blueprint reserved one but the
|
||||
-- v1.1.1 SDK surface — get/set/has/delete/list — doesn't expose TTL).
|
||||
|
||||
CREATE TABLE kv_entries (
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
collection TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (app_id, collection, key)
|
||||
);
|
||||
|
||||
-- Supports list-by-collection (keyset pagination) and per-collection
|
||||
-- triggers' fan-out scans. The PK already covers (app_id, collection)
|
||||
-- as a prefix but spelling out the explicit index makes intent clear
|
||||
-- for the planner.
|
||||
CREATE INDEX idx_kv_entries_app_collection ON kv_entries (app_id, collection);
|
||||
72
crates/manager-core/migrations/0008_triggers.sql
Normal file
72
crates/manager-core/migrations/0008_triggers.sql
Normal file
@@ -0,0 +1,72 @@
|
||||
-- v1.1.1: Trigger framework — Layout E (design notes §2 + §7).
|
||||
--
|
||||
-- A parent `triggers` table holds the common columns (script_id, retry
|
||||
-- config, dispatch_mode, registered-by principal); per-kind detail
|
||||
-- tables hold the kind-specific filter columns. v1.1.1 ships two
|
||||
-- kinds: KV (collection_glob + ops) and dead_letter (source / trigger
|
||||
-- / script filters). Future kinds (cron, pubsub, queue, email) extend
|
||||
-- the parent and add their own detail table.
|
||||
--
|
||||
-- `registered_by_principal` captures the admin user that registered
|
||||
-- the trigger. The dispatcher resolves this back to a `Principal` at
|
||||
-- execution time so the trigger runs as the user that set it up
|
||||
-- (design notes §4: "a trigger execution runs as the principal that
|
||||
-- registered the trigger").
|
||||
--
|
||||
-- HTTP routes stay in their own `routes` table for now (Phase 3
|
||||
-- production schema with its own trie-index columns); the dispatcher
|
||||
-- discriminates HTTP outbox rows by `source_kind = 'http'` and
|
||||
-- `trigger_id` referencing `routes.id`. Folding routes into triggers
|
||||
-- is a v1.2 cleanup, not a v1.1.1 requirement.
|
||||
|
||||
CREATE TABLE triggers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
script_id UUID NOT NULL REFERENCES scripts(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('kv', 'dead_letter')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
-- Async by default — sync would mean the trigger fires inline with
|
||||
-- the originating mutation, which v1.1.1 doesn't support.
|
||||
dispatch_mode TEXT NOT NULL DEFAULT 'async'
|
||||
CHECK (dispatch_mode IN ('sync', 'async')),
|
||||
-- Defaults applied at write time so the row is auditable on its
|
||||
-- own. Per-trigger overrides set on create; the env-defined
|
||||
-- defaults provide the fallback values.
|
||||
retry_max_attempts INT NOT NULL,
|
||||
retry_backoff TEXT NOT NULL
|
||||
CHECK (retry_backoff IN ('exponential', 'linear', 'constant')),
|
||||
retry_base_ms INT NOT NULL,
|
||||
registered_by_principal UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- The dispatcher's hot lookup: "all enabled triggers for app X of
|
||||
-- kind Y". Indexed only when enabled = TRUE so disabled rows don't
|
||||
-- pollute the index.
|
||||
CREATE INDEX idx_triggers_app_kind_enabled
|
||||
ON triggers (app_id, kind)
|
||||
WHERE enabled = TRUE;
|
||||
|
||||
-- One row per KV trigger. `collection_glob` accepts:
|
||||
-- "*" — any collection in the app
|
||||
-- "widgets" — exact match
|
||||
-- "users:*" — prefix wildcard (matched in Rust, not SQL)
|
||||
-- `ops` is the subset of {insert, update, delete} this trigger
|
||||
-- subscribes to. Empty array means "any op" (the trigger fires on
|
||||
-- every mutation; admin endpoint validates this).
|
||||
CREATE TABLE kv_trigger_details (
|
||||
trigger_id UUID PRIMARY KEY REFERENCES triggers(id) ON DELETE CASCADE,
|
||||
collection_glob TEXT NOT NULL,
|
||||
ops TEXT[] NOT NULL
|
||||
);
|
||||
|
||||
-- One row per dead-letter trigger. All three filter columns are
|
||||
-- nullable — NULL means "no filter on this dimension". A trigger
|
||||
-- with all three nullable filters fires on every dead-letter row.
|
||||
CREATE TABLE dead_letter_trigger_details (
|
||||
trigger_id UUID PRIMARY KEY REFERENCES triggers(id) ON DELETE CASCADE,
|
||||
source_filter TEXT,
|
||||
trigger_id_filter UUID,
|
||||
script_id_filter UUID
|
||||
);
|
||||
64
crates/manager-core/migrations/0009_outbox.sql
Normal file
64
crates/manager-core/migrations/0009_outbox.sql
Normal file
@@ -0,0 +1,64 @@
|
||||
-- v1.1.1: Universal trigger outbox — design notes §2.
|
||||
--
|
||||
-- One table for every async dispatch in the system. KV/cron/pubsub/
|
||||
-- queue/email/dead-letter all write rows in this shape; the dispatcher
|
||||
-- claims due rows with `FOR UPDATE SKIP LOCKED` and routes them to
|
||||
-- the executor.
|
||||
--
|
||||
-- Sync HTTP also writes here (NATS-style inbox, design notes §3) —
|
||||
-- `reply_to` carries an `inbox_id` that the orchestrator awaits on a
|
||||
-- oneshot channel. `reply_to.is_some()` is the "don't retry" signal:
|
||||
-- one attempt, surface the result via the inbox.
|
||||
--
|
||||
-- `trigger_id` is a polymorphic reference discriminated by
|
||||
-- `source_kind`: for `source_kind='http'` it references `routes.id`;
|
||||
-- otherwise it references `triggers.id`. Polymorphism handled in
|
||||
-- Rust (the dispatcher); no DB-level FK because Postgres doesn't
|
||||
-- support polymorphic FKs cleanly. NULL is allowed because direct
|
||||
-- admin-replay paths may not have a triggering row at all.
|
||||
--
|
||||
-- `script_id` denormalized so the dispatcher resolves the target
|
||||
-- script without an extra round-trip per row.
|
||||
|
||||
CREATE TABLE outbox (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
source_kind TEXT NOT NULL
|
||||
CHECK (source_kind IN ('http', 'kv', 'dead_letter')),
|
||||
-- Polymorphic — see comment above. No FK constraint.
|
||||
trigger_id UUID,
|
||||
-- Pre-resolved at write time so the dispatcher doesn't re-look it up.
|
||||
script_id UUID,
|
||||
-- NULL = async (retry per policy). Some(inbox_id) = sync HTTP
|
||||
-- (never retry; resolve the inbox with the result).
|
||||
reply_to UUID,
|
||||
-- ServiceEvent + ExecRequest scaffold serialized as JSONB.
|
||||
payload JSONB NOT NULL,
|
||||
-- Forensic field — the principal that triggered the originating
|
||||
-- event. NOT the execution principal for trigger fan-out (that
|
||||
-- comes from `triggers.registered_by_principal`).
|
||||
origin_principal UUID,
|
||||
-- Trigger-depth as the dispatcher will hand it to the executor.
|
||||
-- Read out into ExecRequest.trigger_depth at dispatch time.
|
||||
trigger_depth INT NOT NULL DEFAULT 0,
|
||||
-- Originating execution id (for audit log grouping). Equals the
|
||||
-- root for direct invocations; preserved across fan-out chains.
|
||||
root_execution_id UUID,
|
||||
attempt_count INT NOT NULL DEFAULT 0,
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
-- Set inside the SELECT FOR UPDATE SKIP LOCKED transaction so
|
||||
-- the dispatcher can't double-pick a row across concurrent loop
|
||||
-- iterations.
|
||||
claimed_at TIMESTAMPTZ,
|
||||
claimed_by TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Hot index: the dispatcher's `WHERE next_attempt_at <= NOW() AND
|
||||
-- claimed_at IS NULL` claim query. Partial index keeps the hot set
|
||||
-- small even if the table grows large.
|
||||
CREATE INDEX idx_outbox_due
|
||||
ON outbox (next_attempt_at)
|
||||
WHERE claimed_at IS NULL;
|
||||
|
||||
CREATE INDEX idx_outbox_app ON outbox (app_id);
|
||||
50
crates/manager-core/migrations/0010_dead_letters.sql
Normal file
50
crates/manager-core/migrations/0010_dead_letters.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
-- v1.1.1: dead_letters — design notes §4.
|
||||
--
|
||||
-- Async invocations that exhaust their retry policy land here. Each
|
||||
-- row carries the original event payload verbatim plus the attempt
|
||||
-- history so handlers (registered via `dead_letter` triggers) and the
|
||||
-- dashboard can decide what to do.
|
||||
--
|
||||
-- Schema mirrors design notes §4. The CHECK constraint on
|
||||
-- `resolution` enforces the closed vocabulary used by both the SDK
|
||||
-- (`dead_letters::resolve(id, reason)`) and the recursion-stop rule
|
||||
-- (`handler_failed`). Sync HTTP failures (`reply_to.is_some()`) never
|
||||
-- land here — they're served via the inbox channel.
|
||||
--
|
||||
-- Indexes:
|
||||
-- - partial index on unresolved rows: the dashboard's
|
||||
-- unresolved-count badge query (`COUNT(*) WHERE app_id = $1 AND
|
||||
-- resolved_at IS NULL`).
|
||||
-- - GC index on `created_at`: the weekly retention sweep.
|
||||
|
||||
CREATE TABLE dead_letters (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
-- The outbox.id row that exhausted retries. The outbox row itself
|
||||
-- has been deleted at this point.
|
||||
original_event_id UUID NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
op TEXT NOT NULL,
|
||||
-- Nullable because direct admin replays may have no trigger row.
|
||||
trigger_id UUID,
|
||||
script_id UUID,
|
||||
payload JSONB NOT NULL,
|
||||
attempt_count INT NOT NULL,
|
||||
first_attempt_at TIMESTAMPTZ NOT NULL,
|
||||
last_attempt_at TIMESTAMPTZ NOT NULL,
|
||||
last_error TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolution TEXT
|
||||
CHECK (resolution IN
|
||||
('replayed', 'ignored', 'handled_by_script', 'handler_failed'))
|
||||
);
|
||||
|
||||
-- Dashboard unresolved-count badge — partial index on the predicate
|
||||
-- the query uses.
|
||||
CREATE INDEX idx_dead_letters_app_unresolved
|
||||
ON dead_letters (app_id)
|
||||
WHERE resolved_at IS NULL;
|
||||
|
||||
-- GC sweep scans by creation time.
|
||||
CREATE INDEX idx_dead_letters_gc ON dead_letters (created_at);
|
||||
31
crates/manager-core/migrations/0011_abandoned_executions.sql
Normal file
31
crates/manager-core/migrations/0011_abandoned_executions.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- v1.1.1: abandoned_executions — design notes §3 #9.
|
||||
--
|
||||
-- Forensic table for the "dispatcher tried to resolve a oneshot inbox
|
||||
-- but the receiver was already dropped" edge case. The orchestrator
|
||||
-- timed out (returned 504 to the caller) and gave up on the channel,
|
||||
-- but then the dispatcher's execution succeeded later. The caller
|
||||
-- never sees the result; the row exists so the operator can
|
||||
-- correlate when the abandoned-counter metric spikes.
|
||||
--
|
||||
-- Only the dispatcher-after-orchestrator-timeout edge case writes
|
||||
-- here; ordinary "script timed out, caller got 504" stays uneventful.
|
||||
--
|
||||
-- 7-day retention, GC by `created_at`, sweep alongside dead_letters.
|
||||
|
||||
CREATE TABLE abandoned_executions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
-- Original outbox row id (the row itself has been deleted).
|
||||
outbox_id UUID NOT NULL,
|
||||
script_id UUID,
|
||||
-- The inbox channel id the dispatcher tried to resolve.
|
||||
inbox_id UUID NOT NULL,
|
||||
-- The HTTP status code the dispatcher attempted to send back.
|
||||
status_code INT NOT NULL,
|
||||
-- Truncated body / error description (capped at write time —
|
||||
-- the dispatcher doesn't need to ship megabytes here).
|
||||
result_summary TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_abandoned_executions_gc ON abandoned_executions (created_at);
|
||||
16
crates/manager-core/migrations/0012_routes_dispatch_mode.sql
Normal file
16
crates/manager-core/migrations/0012_routes_dispatch_mode.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- v1.1.1: per-route dispatch mode (design notes §2 + §3).
|
||||
--
|
||||
-- `sync` (default): orchestrator awaits the executor inline and
|
||||
-- returns the response in the same HTTP request — current MVP
|
||||
-- behaviour.
|
||||
-- `async`: orchestrator writes the request to the trigger outbox,
|
||||
-- returns `202 Accepted` immediately. The dispatcher runs the
|
||||
-- script in the background and surfaces failures via the
|
||||
-- retry / dead-letter machinery — same shape as any other async
|
||||
-- event.
|
||||
--
|
||||
-- Existing routes default to `sync` so the migration is non-breaking.
|
||||
|
||||
ALTER TABLE routes
|
||||
ADD COLUMN dispatch_mode TEXT NOT NULL DEFAULT 'sync'
|
||||
CHECK (dispatch_mode IN ('sync', 'async'));
|
||||
39
crates/manager-core/migrations/0013_docs.sql
Normal file
39
crates/manager-core/migrations/0013_docs.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- v1.1.2: Documents — schemaless JSONB store with basic query semantics.
|
||||
--
|
||||
-- Identity tuple `(app_id, collection, id)`. `id` is a server-generated
|
||||
-- UUID; scripts never supply it on create. `app_id` is first in the
|
||||
-- primary key so the implicit index is always per-app — cross-app reads
|
||||
-- are impossible even under a buggy query.
|
||||
--
|
||||
-- `data` is JSONB so scripts can store nested structures without a
|
||||
-- separate serialization step. The GIN-on-`jsonb_path_ops` index
|
||||
-- accelerates the v1.1.2 query DSL's equality and containment operators
|
||||
-- (`docs::find` with `$eq` / `$in`); range/comparison operators rely on
|
||||
-- the per-collection seq scan within the small `app_id` partition.
|
||||
--
|
||||
-- `created_at` / `updated_at` are server-managed: created on insert,
|
||||
-- bumped on every successful update. The returned doc envelope surfaces
|
||||
-- both fields to scripts for read-only access (no script-side override).
|
||||
|
||||
CREATE TABLE docs (
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
collection TEXT NOT NULL,
|
||||
id UUID NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (app_id, collection, id)
|
||||
);
|
||||
|
||||
-- The dispatcher/find hot path: "all docs in app X / collection Y."
|
||||
-- The PK already covers (app_id, collection) as a prefix but spelling
|
||||
-- out the explicit index makes intent clear for the planner. Mirrors
|
||||
-- 0007_kv.sql's idx_kv_entries_app_collection.
|
||||
CREATE INDEX idx_docs_app_collection ON docs (app_id, collection);
|
||||
|
||||
-- GIN on JSONB with the `jsonb_path_ops` opclass: smaller index than
|
||||
-- the default `jsonb_ops`, supports `@>` (containment) which is what
|
||||
-- equality filters compile to under the GIN-friendly path. Range
|
||||
-- operators ($gt/$gte/$lt/$lte/$ne) fall back to per-collection scans;
|
||||
-- those are still bounded by the (app_id, collection) selectivity.
|
||||
CREATE INDEX idx_docs_data_gin ON docs USING GIN (data jsonb_path_ops);
|
||||
36
crates/manager-core/migrations/0014_docs_triggers.sql
Normal file
36
crates/manager-core/migrations/0014_docs_triggers.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- v1.1.2: Extend the triggers framework to recognise `docs` as the
|
||||
-- second concrete kind (after `kv` in v1.1.1).
|
||||
--
|
||||
-- Two CHECK constraints widen (no narrowing — both lists strictly
|
||||
-- gain `'docs'`); one new detail table mirrors `kv_trigger_details`'s
|
||||
-- shape with `DocsEventOp` ops instead of `KvEventOp`. Dispatcher
|
||||
-- routing is generic across kinds — the same code path that handles
|
||||
-- `Kv | DeadLetter` outbox rows now also handles `Docs` (single match
|
||||
-- arm extension on the Rust side; no migration needed).
|
||||
|
||||
-- Extend triggers.kind to include 'docs'. Constraint is in-line on the
|
||||
-- column so Postgres auto-named it `triggers_kind_check`. Dropping the
|
||||
-- old and adding the widened constraint is safe — no existing rows
|
||||
-- carry a value outside the new set.
|
||||
ALTER TABLE triggers DROP CONSTRAINT triggers_kind_check;
|
||||
ALTER TABLE triggers ADD CONSTRAINT triggers_kind_check
|
||||
CHECK (kind IN ('kv', 'dead_letter', 'docs'));
|
||||
|
||||
-- Extend outbox.source_kind to include 'docs'. Same shape as above;
|
||||
-- v1.1.1's existing source_kinds ('http', 'kv', 'dead_letter') 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'));
|
||||
|
||||
-- One row per docs trigger. Same shape as `kv_trigger_details`:
|
||||
-- collection_glob — "*" matches all, "foo*" prefix-matches, "foo"
|
||||
-- exact-matches (Rust-side via collection_matches).
|
||||
-- ops — subset of {create, update, delete}. Empty array
|
||||
-- means "any op" (matches every docs mutation in
|
||||
-- the collection). The admin endpoint rejects
|
||||
-- empty collection_glob; ops can be empty.
|
||||
CREATE TABLE docs_trigger_details (
|
||||
trigger_id UUID PRIMARY KEY REFERENCES triggers(id) ON DELETE CASCADE,
|
||||
collection_glob TEXT NOT NULL,
|
||||
ops TEXT[] NOT NULL
|
||||
);
|
||||
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;
|
||||
128
crates/manager-core/src/abandoned_repo.rs
Normal file
128
crates/manager-core/src/abandoned_repo.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
//! `AbandonedExecutionsRepo` — forensic table written by the
|
||||
//! dispatcher when it tries to resolve a sync-HTTP inbox channel
|
||||
//! that's already been dropped (orchestrator timed out and gave up).
|
||||
//!
|
||||
//! Schema: see `migrations/0011_abandoned_executions.sql`.
|
||||
//!
|
||||
//! Tiny surface: insert + GC. Reading happens via direct SQL when
|
||||
//! correlating the metric counter spike.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AppId, ScriptId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AbandonedRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewAbandonedExecution {
|
||||
pub app_id: AppId,
|
||||
pub outbox_id: Uuid,
|
||||
pub script_id: Option<ScriptId>,
|
||||
pub inbox_id: Uuid,
|
||||
pub status_code: u16,
|
||||
pub result_summary: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AbandonedRepo: Send + Sync {
|
||||
async fn insert(&self, row: NewAbandonedExecution) -> Result<Uuid, AbandonedRepoError>;
|
||||
|
||||
/// Retention sweep — deletes rows older than `older_than` up to
|
||||
/// `limit` at a time.
|
||||
async fn gc(&self, older_than: DateTime<Utc>, limit: i64) -> Result<u64, AbandonedRepoError>;
|
||||
}
|
||||
|
||||
pub struct PostgresAbandonedRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresAbandonedRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
const SUMMARY_CAP_BYTES: usize = 4096;
|
||||
|
||||
#[async_trait]
|
||||
impl AbandonedRepo for PostgresAbandonedRepo {
|
||||
async fn insert(&self, row: NewAbandonedExecution) -> Result<Uuid, AbandonedRepoError> {
|
||||
// Truncate the summary at write-time. The forensic table
|
||||
// doesn't need megabytes; the original outbox row may have
|
||||
// been arbitrary size but we lose nothing useful by clipping.
|
||||
let summary = row.result_summary.map(|s| truncate(s, SUMMARY_CAP_BYTES));
|
||||
let (id,): (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO abandoned_executions ( \
|
||||
app_id, outbox_id, script_id, inbox_id, status_code, result_summary \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6) \
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(row.app_id.into_inner())
|
||||
.bind(row.outbox_id)
|
||||
.bind(row.script_id.map(ScriptId::into_inner))
|
||||
.bind(row.inbox_id)
|
||||
.bind(i32::from(row.status_code))
|
||||
.bind(summary)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn gc(&self, older_than: DateTime<Utc>, limit: i64) -> Result<u64, AbandonedRepoError> {
|
||||
let res = sqlx::query(
|
||||
"DELETE FROM abandoned_executions \
|
||||
WHERE id IN ( \
|
||||
SELECT id FROM abandoned_executions \
|
||||
WHERE created_at < $1 \
|
||||
FOR UPDATE SKIP LOCKED \
|
||||
LIMIT $2 \
|
||||
)",
|
||||
)
|
||||
.bind(older_than)
|
||||
.bind(limit)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(mut s: String, max_bytes: usize) -> String {
|
||||
if s.len() <= max_bytes {
|
||||
return s;
|
||||
}
|
||||
// Walk back from `max_bytes` to a UTF-8 char boundary so we never
|
||||
// panic on `truncate` mid-codepoint.
|
||||
let mut cut = max_bytes;
|
||||
while cut > 0 && !s.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
s.truncate(cut);
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn truncate_respects_char_boundaries() {
|
||||
// 3-byte UTF-8 chars; cap inside the middle char should walk
|
||||
// back to the start.
|
||||
let s = "héllo".to_string();
|
||||
let t = truncate(s, 2);
|
||||
assert!(t.is_char_boundary(t.len()));
|
||||
assert_eq!(t, "h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_passthrough_for_short_strings() {
|
||||
assert_eq!(truncate("ok".into(), 100), "ok");
|
||||
}
|
||||
}
|
||||
@@ -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?;
|
||||
@@ -270,10 +358,13 @@ async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
Path(id): Path<ScriptId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||
// Delete is gated tighter than Save: editors can edit scripts but
|
||||
// only app_admin / instance admin / owner can remove them. See
|
||||
// blueprint §11.6.
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteScript(script.app_id),
|
||||
Capability::AppAdmin(script.app_id),
|
||||
)
|
||||
.await?;
|
||||
state.repo.delete(id).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?;
|
||||
|
||||
@@ -82,6 +84,7 @@ async fn seed_into(
|
||||
// Accept any method so both `curl /hello` and
|
||||
// `curl -d '{"name":"X"}' /hello` work out of the box.
|
||||
method: None,
|
||||
dispatch_mode: picloud_shared::DispatchMode::Sync,
|
||||
})
|
||||
.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));
|
||||
}
|
||||
}
|
||||
@@ -143,8 +143,8 @@ pub struct AppLookupResponse {
|
||||
pub redirect_to: Option<String>,
|
||||
/// The caller's role on this app, used by the dashboard to decide
|
||||
/// whether to render admin-only surfaces (Members tab, settings).
|
||||
/// `Owner` maps to `app_admin`, `Admin` to `editor` (both implicit
|
||||
/// per blueprint §11.6); `Member` carries its explicit
|
||||
/// `Owner` and `Admin` both map to `app_admin` (implicit per
|
||||
/// blueprint §11.6); `Member` carries its explicit
|
||||
/// `app_members.role`.
|
||||
pub my_role: Option<AppRole>,
|
||||
}
|
||||
@@ -226,16 +226,15 @@ async fn get_app(
|
||||
/// Compute the caller's effective `AppRole` on a specific app. Mirrors
|
||||
/// the implicit-grant logic in `authz::role_grants` but returns the
|
||||
/// role itself (for UI gating) rather than a yes/no decision. `Owner`
|
||||
/// is implicit `AppAdmin` everywhere; `Admin` is implicit `Editor`
|
||||
/// everywhere; `Member` consults `app_members`.
|
||||
/// and `Admin` are both implicit `AppAdmin` everywhere; `Member`
|
||||
/// consults `app_members`.
|
||||
async fn compute_my_role(
|
||||
authz: &dyn AuthzRepo,
|
||||
principal: &Principal,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AppsApiError> {
|
||||
match principal.instance_role {
|
||||
InstanceRole::Owner => Ok(Some(AppRole::AppAdmin)),
|
||||
InstanceRole::Admin => Ok(Some(AppRole::Editor)),
|
||||
InstanceRole::Owner | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)),
|
||||
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,35 @@ pub async fn require_admin(state: State<AuthState>, req: Request<Body>, next: Ne
|
||||
require_authenticated(state, req, next).await
|
||||
}
|
||||
|
||||
/// Opportunistic data-plane variant: always inserts an
|
||||
/// `Extension<Option<Principal>>` and forwards the request. Used on
|
||||
/// `/execute/{id}` and the user-route fallback, where most invocations
|
||||
/// are anonymous public HTTP and the few authed ones (dashboard
|
||||
/// test-runs, API keys) should still let scripts see the caller via
|
||||
/// `cx.principal` once services consume it.
|
||||
///
|
||||
/// Failure modes — all degrade to `None` rather than rejecting:
|
||||
/// * No bearer / cookie → `None`.
|
||||
/// * Malformed or unknown token → `None`.
|
||||
/// * DB blip while resolving → `None` (fail-open; the data plane
|
||||
/// should not 500 on transient infra failures for an *optional*
|
||||
/// identity check).
|
||||
///
|
||||
/// Admin-side routes that REQUIRE an identity keep using
|
||||
/// `require_authenticated`.
|
||||
pub async fn attach_principal_if_present(
|
||||
State(state): State<AuthState>,
|
||||
mut req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let principal: Option<Principal> = match extract_token(&req) {
|
||||
Some(token) => resolve_principal(&state, &token).await.unwrap_or(None),
|
||||
None => None,
|
||||
};
|
||||
req.extensions_mut().insert(principal);
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// Decide whether the token is an API key (pic_ prefix) or a session
|
||||
/// token, then resolve the corresponding `Principal`. `Ok(None)`
|
||||
/// means the token was structurally valid but didn't match any active
|
||||
|
||||
@@ -57,6 +57,64 @@ pub enum Capability {
|
||||
AppAdmin(AppId),
|
||||
/// Read execution logs for scripts in this app.
|
||||
AppLogRead(AppId),
|
||||
/// Read entries from this app's KV store (v1.1.1). Granted to
|
||||
/// `viewer`+ in the per-app role table. Maps to `script:read` on
|
||||
/// API keys — the seven-scope vocabulary stays locked.
|
||||
AppKvRead(AppId),
|
||||
/// Write entries to this app's KV store (v1.1.1). Granted to
|
||||
/// `editor`+. Maps to `script:write` on API keys.
|
||||
AppKvWrite(AppId),
|
||||
/// Read documents from this app's docs store (v1.1.2). Same trust
|
||||
/// shape as KV read — granted to `viewer`+, maps to `script:read`
|
||||
/// on API keys. Honors the seven-scope commitment.
|
||||
AppDocsRead(AppId),
|
||||
/// Write documents to this app's docs store (v1.1.2). Same trust
|
||||
/// 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`+.
|
||||
AppManageTriggers(AppId),
|
||||
/// Replay / resolve dead-letter rows for this app (v1.1.1). Maps
|
||||
/// 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 {
|
||||
@@ -73,7 +131,21 @@ impl Capability {
|
||||
| Self::AppWriteRoute(id)
|
||||
| Self::AppManageDomains(id)
|
||||
| Self::AppAdmin(id)
|
||||
| Self::AppLogRead(id) => Some(id),
|
||||
| Self::AppLogRead(id)
|
||||
| Self::AppKvRead(id)
|
||||
| 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)
|
||||
| Self::AppTopicManage(id) => Some(id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,11 +160,25 @@ impl Capability {
|
||||
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||
Scope::InstanceAdmin
|
||||
}
|
||||
Self::AppRead(_) => Scope::ScriptRead,
|
||||
Self::AppWriteScript(_) => 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(_) => Scope::AppAdmin,
|
||||
Self::AppAdmin(_)
|
||||
| Self::AppManageTriggers(_)
|
||||
| Self::AppDeadLetterManage(_)
|
||||
| Self::AppTopicManage(_) => Scope::AppAdmin,
|
||||
Self::AppLogRead(_) => Scope::LogRead,
|
||||
}
|
||||
}
|
||||
@@ -199,21 +285,14 @@ async fn role_grants(
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin is implicit `editor` on every app (per blueprint §11.6). They
|
||||
/// can create apps and manage users, but NOT touch instance-wide
|
||||
/// settings or take app-admin-only actions on apps they're not
|
||||
/// explicitly app_admin of. Everything not in this set falls through
|
||||
/// to deny (`InstanceManageSettings`, `AppManageDomains`, `AppAdmin`).
|
||||
/// Admin is implicit `app_admin` on every app (per blueprint §11.6).
|
||||
/// They can create apps, manage users, and take any app-scoped action
|
||||
/// on any app without an explicit `app_members` row — single-human
|
||||
/// installs would otherwise need to add themselves to every new app.
|
||||
/// Only `InstanceManageSettings` (sandbox ceiling, etc.) stays
|
||||
/// owner-only.
|
||||
const fn admin_grants(cap: Capability) -> bool {
|
||||
matches!(
|
||||
cap,
|
||||
Capability::InstanceCreateApp
|
||||
| Capability::InstanceManageUsers
|
||||
| Capability::AppRead(_)
|
||||
| Capability::AppWriteScript(_)
|
||||
| Capability::AppWriteRoute(_)
|
||||
| Capability::AppLogRead(_)
|
||||
)
|
||||
!matches!(cap, Capability::InstanceManageSettings)
|
||||
}
|
||||
|
||||
/// Member has zero instance authority. App authority requires an
|
||||
@@ -237,16 +316,36 @@ async fn member_grants(
|
||||
/// domain claims, and delete. Roles form a strict subset chain, so
|
||||
/// the check is "is this capability in the role's set?".
|
||||
const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||
let in_viewer = matches!(cap, Capability::AppRead(_) | Capability::AppLogRead(_));
|
||||
let in_viewer = matches!(
|
||||
cap,
|
||||
Capability::AppRead(_)
|
||||
| Capability::AppLogRead(_)
|
||||
| Capability::AppKvRead(_)
|
||||
| Capability::AppDocsRead(_)
|
||||
| Capability::AppFilesRead(_)
|
||||
| Capability::AppSecretsRead(_)
|
||||
);
|
||||
let in_editor = in_viewer
|
||||
|| matches!(
|
||||
cap,
|
||||
Capability::AppWriteScript(_) | Capability::AppWriteRoute(_)
|
||||
Capability::AppWriteScript(_)
|
||||
| Capability::AppWriteRoute(_)
|
||||
| Capability::AppKvWrite(_)
|
||||
| Capability::AppDocsWrite(_)
|
||||
| Capability::AppHttpRequest(_)
|
||||
| Capability::AppFilesWrite(_)
|
||||
| Capability::AppPubsubPublish(_)
|
||||
| Capability::AppSecretsWrite(_)
|
||||
| Capability::AppEmailSend(_)
|
||||
);
|
||||
let in_app_admin = in_editor
|
||||
|| matches!(
|
||||
cap,
|
||||
Capability::AppManageDomains(_) | Capability::AppAdmin(_)
|
||||
Capability::AppManageDomains(_)
|
||||
| Capability::AppAdmin(_)
|
||||
| Capability::AppManageTriggers(_)
|
||||
| Capability::AppDeadLetterManage(_)
|
||||
| Capability::AppTopicManage(_)
|
||||
);
|
||||
match role {
|
||||
AppRole::Viewer => in_viewer,
|
||||
@@ -357,10 +456,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_cannot_manage_instance_settings_or_app_admin_actions() {
|
||||
async fn admin_cannot_manage_instance_settings() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let p = principal(InstanceRole::Admin);
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::InstanceManageSettings)
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_is_implicit_app_admin_on_every_app() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let p = principal(InstanceRole::Admin);
|
||||
let app = AppId::new();
|
||||
// Instance-scoped allowances.
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
|
||||
Decision::Allow,
|
||||
@@ -371,36 +483,22 @@ mod tests {
|
||||
.unwrap(),
|
||||
Decision::Allow,
|
||||
);
|
||||
// Editor-like + app-admin grants both succeed without any
|
||||
// app_members row.
|
||||
for cap in [
|
||||
Capability::AppRead(app),
|
||||
Capability::AppWriteScript(app),
|
||||
Capability::AppWriteRoute(app),
|
||||
Capability::AppLogRead(app),
|
||||
Capability::AppManageDomains(app),
|
||||
Capability::AppAdmin(app),
|
||||
] {
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::InstanceManageSettings)
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny,
|
||||
);
|
||||
// Editor-like grants succeed
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppWriteScript(app))
|
||||
.await
|
||||
.unwrap(),
|
||||
can(&repo, &p, cap).await.unwrap(),
|
||||
Decision::Allow,
|
||||
"admin denied app-scoped capability {cap:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppWriteRoute(app))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Allow,
|
||||
);
|
||||
// App-admin grants do not
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppManageDomains(app))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny,
|
||||
);
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||
Decision::Deny,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -474,6 +572,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Editors hold `AppWriteScript` (Save) but **not** `AppAdmin`
|
||||
/// (Delete). The script-delete handler gates on the latter so the
|
||||
/// API can't be tricked into letting an editor remove the script
|
||||
/// they were only allowed to edit.
|
||||
#[tokio::test]
|
||||
async fn editor_can_write_scripts_but_not_delete_them() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let p = principal(InstanceRole::Member);
|
||||
let app = AppId::new();
|
||||
repo.grant(p.user_id, app, AppRole::Editor).await;
|
||||
|
||||
assert!(can(&repo, &p, Capability::AppWriteScript(app))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
// Delete is gated on AppAdmin in the handler — editors must be
|
||||
// denied here for that gate to bite.
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||
Decision::Deny,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn member_with_app_admin_role_can_do_app_admin_actions() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
@@ -568,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"));
|
||||
}
|
||||
}
|
||||
261
crates/manager-core/src/dead_letter_repo.rs
Normal file
261
crates/manager-core/src/dead_letter_repo.rs
Normal file
@@ -0,0 +1,261 @@
|
||||
//! `DeadLetterRepo` — CRUD over the `dead_letters` table.
|
||||
//!
|
||||
//! The dispatcher writes new rows when an async trigger exhausts its
|
||||
//! retry policy. Admin endpoints (commit 8) read for the dashboard
|
||||
//! list view and write to mark rows resolved or replay them. The GC
|
||||
//! sweeper (commit 10) deletes expired rows by `created_at`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AppId, DeadLetterId, ScriptId, TriggerId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DeadLetterRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
#[error("dead-letter row not found: {0}")]
|
||||
NotFound(DeadLetterId),
|
||||
|
||||
#[error("invalid resolution {0:?}")]
|
||||
InvalidResolution(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewDeadLetter {
|
||||
pub app_id: AppId,
|
||||
/// `outbox.id` that exhausted retries. Outbox row deleted at the
|
||||
/// same time.
|
||||
pub original_event_id: Uuid,
|
||||
pub source: String,
|
||||
pub op: String,
|
||||
pub trigger_id: Option<TriggerId>,
|
||||
pub script_id: Option<ScriptId>,
|
||||
pub payload: serde_json::Value,
|
||||
pub attempt_count: u32,
|
||||
pub first_attempt_at: DateTime<Utc>,
|
||||
pub last_attempt_at: DateTime<Utc>,
|
||||
pub last_error: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeadLetterRow {
|
||||
pub id: DeadLetterId,
|
||||
pub app_id: AppId,
|
||||
pub original_event_id: Uuid,
|
||||
pub source: String,
|
||||
pub op: String,
|
||||
pub trigger_id: Option<TriggerId>,
|
||||
pub script_id: Option<ScriptId>,
|
||||
pub payload: serde_json::Value,
|
||||
pub attempt_count: u32,
|
||||
pub first_attempt_at: DateTime<Utc>,
|
||||
pub last_attempt_at: DateTime<Utc>,
|
||||
pub last_error: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub resolved_at: Option<DateTime<Utc>>,
|
||||
pub resolution: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait DeadLetterRepo: Send + Sync {
|
||||
/// Insert a new dead-letter row. Returns the assigned id.
|
||||
async fn insert(&self, row: NewDeadLetter) -> Result<DeadLetterId, DeadLetterRepoError>;
|
||||
|
||||
async fn get(&self, id: DeadLetterId) -> Result<Option<DeadLetterRow>, DeadLetterRepoError>;
|
||||
|
||||
/// Lookup for the dashboard list view. `unresolved_only=true`
|
||||
/// filters to `resolved_at IS NULL`.
|
||||
async fn list_for_app(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
unresolved_only: bool,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<DeadLetterRow>, DeadLetterRepoError>;
|
||||
|
||||
/// Hot path for the dashboard's per-app unresolved-count badge.
|
||||
async fn unresolved_count(&self, app_id: AppId) -> Result<i64, DeadLetterRepoError>;
|
||||
|
||||
/// Mark the row resolved with the given reason. The reason MUST
|
||||
/// be one of the four CHECK-constraint values
|
||||
/// (`replayed`, `ignored`, `handled_by_script`, `handler_failed`).
|
||||
async fn resolve(&self, id: DeadLetterId, reason: &str) -> Result<(), DeadLetterRepoError>;
|
||||
|
||||
/// Retention sweep. Deletes rows with `created_at < older_than`
|
||||
/// up to `limit` at a time, using FOR UPDATE SKIP LOCKED to play
|
||||
/// nicely with concurrent dispatchers. Returns the count deleted.
|
||||
async fn gc(&self, older_than: DateTime<Utc>, limit: i64) -> Result<u64, DeadLetterRepoError>;
|
||||
}
|
||||
|
||||
pub struct PostgresDeadLetterRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresDeadLetterRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_RESOLUTIONS: &[&str] =
|
||||
&["replayed", "ignored", "handled_by_script", "handler_failed"];
|
||||
|
||||
#[async_trait]
|
||||
impl DeadLetterRepo for PostgresDeadLetterRepo {
|
||||
async fn insert(&self, row: NewDeadLetter) -> Result<DeadLetterId, DeadLetterRepoError> {
|
||||
let (id,): (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO dead_letters ( \
|
||||
app_id, original_event_id, source, op, trigger_id, script_id, \
|
||||
payload, attempt_count, first_attempt_at, last_attempt_at, last_error \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(row.app_id.into_inner())
|
||||
.bind(row.original_event_id)
|
||||
.bind(row.source)
|
||||
.bind(row.op)
|
||||
.bind(row.trigger_id.map(TriggerId::into_inner))
|
||||
.bind(row.script_id.map(ScriptId::into_inner))
|
||||
.bind(row.payload)
|
||||
.bind(i32::try_from(row.attempt_count).unwrap_or(0))
|
||||
.bind(row.first_attempt_at)
|
||||
.bind(row.last_attempt_at)
|
||||
.bind(row.last_error)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(id.into())
|
||||
}
|
||||
|
||||
async fn get(&self, id: DeadLetterId) -> Result<Option<DeadLetterRow>, DeadLetterRepoError> {
|
||||
let row: Option<DeadLetterRowRaw> = sqlx::query_as(
|
||||
"SELECT id, app_id, original_event_id, source, op, trigger_id, script_id, \
|
||||
payload, attempt_count, first_attempt_at, last_attempt_at, \
|
||||
last_error, created_at, resolved_at, resolution \
|
||||
FROM dead_letters WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(DeadLetterRowRaw::into_row))
|
||||
}
|
||||
|
||||
async fn list_for_app(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
unresolved_only: bool,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<DeadLetterRow>, DeadLetterRepoError> {
|
||||
let rows: Vec<DeadLetterRowRaw> = sqlx::query_as(
|
||||
"SELECT id, app_id, original_event_id, source, op, trigger_id, script_id, \
|
||||
payload, attempt_count, first_attempt_at, last_attempt_at, \
|
||||
last_error, created_at, resolved_at, resolution \
|
||||
FROM dead_letters \
|
||||
WHERE app_id = $1 \
|
||||
AND ($2::bool = FALSE OR resolved_at IS NULL) \
|
||||
ORDER BY created_at DESC \
|
||||
LIMIT $3 OFFSET $4",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(unresolved_only)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(DeadLetterRowRaw::into_row).collect())
|
||||
}
|
||||
|
||||
async fn unresolved_count(&self, app_id: AppId) -> Result<i64, DeadLetterRepoError> {
|
||||
let (count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM dead_letters \
|
||||
WHERE app_id = $1 AND resolved_at IS NULL",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn resolve(&self, id: DeadLetterId, reason: &str) -> Result<(), DeadLetterRepoError> {
|
||||
if !ALLOWED_RESOLUTIONS.contains(&reason) {
|
||||
return Err(DeadLetterRepoError::InvalidResolution(reason.to_string()));
|
||||
}
|
||||
let res = sqlx::query(
|
||||
"UPDATE dead_letters \
|
||||
SET resolution = $2, resolved_at = NOW() \
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(reason)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(DeadLetterRepoError::NotFound(id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn gc(&self, older_than: DateTime<Utc>, limit: i64) -> Result<u64, DeadLetterRepoError> {
|
||||
// Tombstones picked under FOR UPDATE SKIP LOCKED so concurrent
|
||||
// sweepers (cluster mode) don't fight each other.
|
||||
let res = sqlx::query(
|
||||
"DELETE FROM dead_letters \
|
||||
WHERE id IN ( \
|
||||
SELECT id FROM dead_letters \
|
||||
WHERE created_at < $1 \
|
||||
FOR UPDATE SKIP LOCKED \
|
||||
LIMIT $2 \
|
||||
)",
|
||||
)
|
||||
.bind(older_than)
|
||||
.bind(limit)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DeadLetterRowRaw {
|
||||
id: Uuid,
|
||||
app_id: Uuid,
|
||||
original_event_id: Uuid,
|
||||
source: String,
|
||||
op: String,
|
||||
trigger_id: Option<Uuid>,
|
||||
script_id: Option<Uuid>,
|
||||
payload: serde_json::Value,
|
||||
attempt_count: i32,
|
||||
first_attempt_at: DateTime<Utc>,
|
||||
last_attempt_at: DateTime<Utc>,
|
||||
last_error: String,
|
||||
created_at: DateTime<Utc>,
|
||||
resolved_at: Option<DateTime<Utc>>,
|
||||
resolution: Option<String>,
|
||||
}
|
||||
|
||||
impl DeadLetterRowRaw {
|
||||
fn into_row(self) -> DeadLetterRow {
|
||||
DeadLetterRow {
|
||||
id: self.id.into(),
|
||||
app_id: self.app_id.into(),
|
||||
original_event_id: self.original_event_id,
|
||||
source: self.source,
|
||||
op: self.op,
|
||||
trigger_id: self.trigger_id.map(Into::into),
|
||||
script_id: self.script_id.map(Into::into),
|
||||
payload: self.payload,
|
||||
attempt_count: u32::try_from(self.attempt_count).unwrap_or(0),
|
||||
first_attempt_at: self.first_attempt_at,
|
||||
last_attempt_at: self.last_attempt_at,
|
||||
last_error: self.last_error,
|
||||
created_at: self.created_at,
|
||||
resolved_at: self.resolved_at,
|
||||
resolution: self.resolution,
|
||||
}
|
||||
}
|
||||
}
|
||||
118
crates/manager-core/src/dead_letter_service.rs
Normal file
118
crates/manager-core/src/dead_letter_service.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
//! `PostgresDeadLetterService` — replaces `NoopDeadLetterService` in
|
||||
//! v1.1.1's `Services` bundle. Implements `replay` (re-enqueue the
|
||||
//! original event into the outbox + mark the DL row replayed) and
|
||||
//! `resolve` (close the row out with a reason).
|
||||
//!
|
||||
//! Both methods are gated by `Capability::AppDeadLetterManage(AppId)`
|
||||
//! evaluated against `cx.principal`. Public-HTTP scripts with
|
||||
//! `principal: None` fail the check — design notes §4: managing
|
||||
//! dead letters is an admin act.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{DeadLetterError, DeadLetterId, DeadLetterService, SdkCallCx};
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::dead_letter_repo::{DeadLetterRepo, DeadLetterRepoError, DeadLetterRow};
|
||||
use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxSourceKind};
|
||||
|
||||
pub struct PostgresDeadLetterService {
|
||||
repo: Arc<dyn DeadLetterRepo>,
|
||||
outbox: Arc<dyn OutboxRepo>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
impl PostgresDeadLetterService {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repo: Arc<dyn DeadLetterRepo>,
|
||||
outbox: Arc<dyn OutboxRepo>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
outbox,
|
||||
authz,
|
||||
}
|
||||
}
|
||||
|
||||
async fn require_dl_capability(&self, cx: &SdkCallCx) -> Result<(), DeadLetterError> {
|
||||
let Some(ref principal) = cx.principal else {
|
||||
return Err(DeadLetterError::Forbidden);
|
||||
};
|
||||
authz::require(
|
||||
&*self.authz,
|
||||
principal,
|
||||
Capability::AppDeadLetterManage(cx.app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| DeadLetterError::Forbidden)
|
||||
}
|
||||
|
||||
async fn load_row(&self, id: DeadLetterId) -> Result<DeadLetterRow, DeadLetterError> {
|
||||
self.repo
|
||||
.get(id)
|
||||
.await
|
||||
.map_err(map_repo_err)?
|
||||
.ok_or(DeadLetterError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeadLetterService for PostgresDeadLetterService {
|
||||
async fn replay(&self, cx: &SdkCallCx, id: DeadLetterId) -> Result<(), DeadLetterError> {
|
||||
self.require_dl_capability(cx).await?;
|
||||
let row = self.load_row(id).await?;
|
||||
if row.app_id != cx.app_id {
|
||||
// Cross-app — treat as not-found to avoid leaking
|
||||
// information about other apps' dead letters.
|
||||
return Err(DeadLetterError::NotFound);
|
||||
}
|
||||
|
||||
let source_kind = OutboxSourceKind::from_wire(&row.source).unwrap_or(OutboxSourceKind::Kv);
|
||||
self.outbox
|
||||
.insert(NewOutboxRow {
|
||||
app_id: row.app_id,
|
||||
source_kind,
|
||||
trigger_id: row.trigger_id,
|
||||
script_id: row.script_id,
|
||||
reply_to: None,
|
||||
payload: row.payload.clone(),
|
||||
origin_principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DeadLetterError::Backend(e.to_string()))?;
|
||||
|
||||
self.repo
|
||||
.resolve(id, "replayed")
|
||||
.await
|
||||
.map_err(map_repo_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
id: DeadLetterId,
|
||||
reason: &str,
|
||||
) -> Result<(), DeadLetterError> {
|
||||
self.require_dl_capability(cx).await?;
|
||||
let row = self.load_row(id).await?;
|
||||
if row.app_id != cx.app_id {
|
||||
return Err(DeadLetterError::NotFound);
|
||||
}
|
||||
self.repo.resolve(id, reason).await.map_err(map_repo_err)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn map_repo_err(e: DeadLetterRepoError) -> DeadLetterError {
|
||||
match e {
|
||||
DeadLetterRepoError::NotFound(_) => DeadLetterError::NotFound,
|
||||
DeadLetterRepoError::InvalidResolution(s) => DeadLetterError::InvalidResolution(s),
|
||||
DeadLetterRepoError::Db(e) => DeadLetterError::Backend(e.to_string()),
|
||||
}
|
||||
}
|
||||
319
crates/manager-core/src/dead_letters_api.rs
Normal file
319
crates/manager-core/src/dead_letters_api.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
//! `/api/v1/admin/apps/{id}/dead_letters/*` — dashboard surface for
|
||||
//! the no-default-handler model (design notes §4).
|
||||
//!
|
||||
//! Endpoints:
|
||||
//! - `GET /apps/{id}/dead_letters?unresolved=true` — list view
|
||||
//! - `GET /apps/{id}/dead_letters/count` — badge count
|
||||
//! - `GET /apps/{id}/dead_letters/{dl_id}` — row detail
|
||||
//! - `POST /apps/{id}/dead_letters/{dl_id}/replay` — re-enqueue
|
||||
//! - `POST /apps/{id}/dead_letters/{dl_id}/resolve` — mark resolved
|
||||
//!
|
||||
//! All gated on `Capability::AppDeadLetterManage(app_id)`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{AppId, DeadLetterId, DeadLetterService, Principal, SdkCallCx};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::dead_letter_repo::{DeadLetterRepo, DeadLetterRepoError, DeadLetterRow};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DeadLettersState {
|
||||
pub repo: Arc<dyn DeadLetterRepo>,
|
||||
pub service: Arc<dyn DeadLetterService>,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn dead_letters_router(state: DeadLettersState) -> Router {
|
||||
Router::new()
|
||||
.route("/apps/{app_id}/dead_letters", get(list))
|
||||
.route("/apps/{app_id}/dead_letters/count", get(count))
|
||||
.route("/apps/{app_id}/dead_letters/{dl_id}", get(detail))
|
||||
.route("/apps/{app_id}/dead_letters/{dl_id}/replay", post(replay))
|
||||
.route("/apps/{app_id}/dead_letters/{dl_id}/resolve", post(resolve))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListQuery {
|
||||
#[serde(default)]
|
||||
pub unresolved: bool,
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: i64,
|
||||
#[serde(default)]
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
const fn default_limit() -> i64 {
|
||||
50
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ListResponse {
|
||||
pub dead_letters: Vec<DeadLetterDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CountResponse {
|
||||
pub unresolved: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ResolveBody {
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeadLetterDto {
|
||||
pub id: DeadLetterId,
|
||||
pub app_id: AppId,
|
||||
pub source: String,
|
||||
pub op: String,
|
||||
pub trigger_id: Option<picloud_shared::TriggerId>,
|
||||
pub script_id: Option<picloud_shared::ScriptId>,
|
||||
pub payload: serde_json::Value,
|
||||
pub attempt_count: u32,
|
||||
pub first_attempt_at: chrono::DateTime<chrono::Utc>,
|
||||
pub last_attempt_at: chrono::DateTime<chrono::Utc>,
|
||||
pub last_error: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub resolved_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub resolution: Option<String>,
|
||||
}
|
||||
|
||||
impl From<DeadLetterRow> for DeadLetterDto {
|
||||
fn from(r: DeadLetterRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
app_id: r.app_id,
|
||||
source: r.source,
|
||||
op: r.op,
|
||||
trigger_id: r.trigger_id,
|
||||
script_id: r.script_id,
|
||||
payload: r.payload,
|
||||
attempt_count: r.attempt_count,
|
||||
first_attempt_at: r.first_attempt_at,
|
||||
last_attempt_at: r.last_attempt_at,
|
||||
last_error: r.last_error,
|
||||
created_at: r.created_at,
|
||||
resolved_at: r.resolved_at,
|
||||
resolution: r.resolution,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(
|
||||
State(s): State<DeadLettersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(app_id): Path<AppId>,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> Result<Json<ListResponse>, DeadLettersApiError> {
|
||||
ensure_app(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppDeadLetterManage(app_id),
|
||||
)
|
||||
.await?;
|
||||
let rows = s
|
||||
.repo
|
||||
.list_for_app(app_id, q.unresolved, q.limit.clamp(1, 200), q.offset.max(0))
|
||||
.await?;
|
||||
Ok(Json(ListResponse {
|
||||
dead_letters: rows.into_iter().map(Into::into).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn count(
|
||||
State(s): State<DeadLettersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(app_id): Path<AppId>,
|
||||
) -> Result<Json<CountResponse>, DeadLettersApiError> {
|
||||
ensure_app(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppDeadLetterManage(app_id),
|
||||
)
|
||||
.await?;
|
||||
let n = s.repo.unresolved_count(app_id).await?;
|
||||
Ok(Json(CountResponse { unresolved: n }))
|
||||
}
|
||||
|
||||
async fn detail(
|
||||
State(s): State<DeadLettersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((app_id, dl_id)): Path<(AppId, DeadLetterId)>,
|
||||
) -> Result<Json<DeadLetterDto>, DeadLettersApiError> {
|
||||
ensure_app(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppDeadLetterManage(app_id),
|
||||
)
|
||||
.await?;
|
||||
let row = s
|
||||
.repo
|
||||
.get(dl_id)
|
||||
.await?
|
||||
.ok_or(DeadLettersApiError::NotFound(dl_id))?;
|
||||
if row.app_id != app_id {
|
||||
return Err(DeadLettersApiError::NotFound(dl_id));
|
||||
}
|
||||
Ok(Json(row.into()))
|
||||
}
|
||||
|
||||
async fn replay(
|
||||
State(s): State<DeadLettersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((app_id, dl_id)): Path<(AppId, DeadLetterId)>,
|
||||
) -> Result<StatusCode, DeadLettersApiError> {
|
||||
ensure_app(&*s.apps, app_id).await?;
|
||||
// Authz handled inside the service via SdkCallCx.
|
||||
let cx = admin_cx(app_id, &principal);
|
||||
s.service
|
||||
.replay(&cx, dl_id)
|
||||
.await
|
||||
.map_err(map_service_err)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn resolve(
|
||||
State(s): State<DeadLettersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((app_id, dl_id)): Path<(AppId, DeadLetterId)>,
|
||||
Json(body): Json<ResolveBody>,
|
||||
) -> Result<StatusCode, DeadLettersApiError> {
|
||||
ensure_app(&*s.apps, app_id).await?;
|
||||
let cx = admin_cx(app_id, &principal);
|
||||
s.service
|
||||
.resolve(&cx, dl_id, &body.reason)
|
||||
.await
|
||||
.map_err(map_service_err)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Synthesize an `SdkCallCx` for the admin path. The service layer
|
||||
/// reads `cx.app_id` + `cx.principal` and ignores the trigger /
|
||||
/// execution fields, so the per-call ids are arbitrary.
|
||||
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(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: picloud_shared::ExecutionId::new(),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_app(apps: &dyn AppRepository, app_id: AppId) -> Result<(), DeadLettersApiError> {
|
||||
apps.get_by_id(app_id)
|
||||
.await
|
||||
.map_err(|e| DeadLettersApiError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| DeadLettersApiError::AppNotFound(app_id.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_service_err(e: picloud_shared::DeadLetterError) -> DeadLettersApiError {
|
||||
match e {
|
||||
picloud_shared::DeadLetterError::NotFound => {
|
||||
DeadLettersApiError::NotFound(DeadLetterId::new())
|
||||
}
|
||||
picloud_shared::DeadLetterError::Forbidden => DeadLettersApiError::Forbidden,
|
||||
picloud_shared::DeadLetterError::InvalidResolution(s) => {
|
||||
DeadLettersApiError::Invalid(format!("invalid resolution: {s}"))
|
||||
}
|
||||
picloud_shared::DeadLetterError::Backend(s) => DeadLettersApiError::Backend(s),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DeadLettersApiError {
|
||||
#[error("app not found: {0}")]
|
||||
AppNotFound(String),
|
||||
|
||||
#[error("dead-letter not found: {0}")]
|
||||
NotFound(DeadLetterId),
|
||||
|
||||
#[error("invalid: {0}")]
|
||||
Invalid(String),
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
|
||||
#[error("dead-letter backend: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for DeadLettersApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthzError> for DeadLettersApiError {
|
||||
fn from(e: AuthzError) -> Self {
|
||||
Self::AuthzRepo(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DeadLetterRepoError> for DeadLettersApiError {
|
||||
fn from(e: DeadLetterRepoError) -> Self {
|
||||
match e {
|
||||
DeadLetterRepoError::NotFound(id) => Self::NotFound(id),
|
||||
DeadLetterRepoError::InvalidResolution(s) => Self::Invalid(s),
|
||||
DeadLetterRepoError::Db(e) => Self::Backend(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for DeadLettersApiError {
|
||||
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, "dead_letters authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "dead_letters api backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
796
crates/manager-core/src/dispatcher.rs
Normal file
796
crates/manager-core/src/dispatcher.rs
Normal file
@@ -0,0 +1,796 @@
|
||||
//! The triggers-framework dispatcher.
|
||||
//!
|
||||
//! Single tokio task that polls the outbox, claims due rows
|
||||
//! (`FOR UPDATE SKIP LOCKED`), and routes each to the executor.
|
||||
//! Shares the `ExecutionGate` with sync HTTP — they compete for the
|
||||
//! same permit budget, matching design notes §2.
|
||||
//!
|
||||
//! Outcome handling per design notes §3 and §4:
|
||||
//! - reply_to.is_some() (sync HTTP): never retry. Deliver to inbox
|
||||
//! (or write `abandoned_executions` if the receiver dropped).
|
||||
//! - is_dead_letter_handler == true: never retry, never DL. Failure
|
||||
//! just annotates the original DL row with `resolution =
|
||||
//! 'handler_failed'` and bumps a metric.
|
||||
//! - Otherwise on failure: if `attempt_count + 1 < max_attempts`,
|
||||
//! reschedule with backoff + jitter. Else, write a `dead_letters`
|
||||
//! row and delete from outbox.
|
||||
//!
|
||||
//! Depth-limit: `trigger_depth > max_trigger_depth` skips execution
|
||||
//! entirely (log + metric) and deletes the row — does NOT dead-letter
|
||||
//! (design notes §4: depth-exceeded means "you built a loop", and
|
||||
//! dead-lettering would just re-fire the same loop).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
|
||||
use picloud_orchestrator_core::{ExecutionGate, ExecutorClient};
|
||||
use picloud_shared::{
|
||||
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::{NewOutboxRow, OutboxRepo, OutboxRow, OutboxSourceKind};
|
||||
use crate::principal_resolver::PrincipalResolver;
|
||||
use crate::repo::ScriptRepository;
|
||||
use crate::trigger_config::{BackoffShape, TriggerConfig};
|
||||
use crate::trigger_repo::{TriggerKind, TriggerRepo};
|
||||
|
||||
/// Bundle the dispatcher reads from. Each handle is `Arc<dyn …>` so
|
||||
/// tests can substitute in-memory backings.
|
||||
pub struct Dispatcher {
|
||||
pub outbox: Arc<dyn OutboxRepo>,
|
||||
pub triggers: Arc<dyn TriggerRepo>,
|
||||
pub scripts: Arc<dyn ScriptRepository>,
|
||||
pub dead_letters: Arc<dyn DeadLetterRepo>,
|
||||
pub abandoned: Arc<dyn AbandonedRepo>,
|
||||
pub principals: Arc<dyn PrincipalResolver>,
|
||||
pub executor: Arc<dyn ExecutorClient>,
|
||||
pub gate: Arc<ExecutionGate>,
|
||||
pub inbox: Arc<dyn InboxResolver>,
|
||||
pub config: TriggerConfig,
|
||||
/// Stable id for this dispatcher instance — written into
|
||||
/// `outbox.claimed_by` for forensics. In MVP this is the host's
|
||||
/// pid; cluster mode (v1.3+) uses node identity.
|
||||
pub instance_id: String,
|
||||
}
|
||||
|
||||
/// How many outbox rows the dispatcher tries to claim per tick.
|
||||
/// Bounded to keep the working set small even if there's a flood.
|
||||
const CLAIM_BATCH: i64 = 8;
|
||||
|
||||
/// Polling cadence. Short enough that fan-out feels instant; long
|
||||
/// enough that an idle dispatcher doesn't burn cycles.
|
||||
const TICK_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Hard cap on the wall-clock budget passed to the executor for an
|
||||
/// async-dispatched script. Sync HTTP gets a per-script timeout via
|
||||
/// the orchestrator path; async rows don't have one, so we apply a
|
||||
/// platform-wide ceiling here. Matches `LocalExecutorClient`'s own
|
||||
/// 5-minute cap.
|
||||
const ASYNC_EXEC_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
impl Dispatcher {
|
||||
/// Spawn the dispatcher loop as a detached `tokio::task`. The
|
||||
/// returned `JoinHandle` is dropped — the loop runs for the
|
||||
/// process lifetime.
|
||||
pub fn spawn(self) {
|
||||
tokio::spawn(async move {
|
||||
self.run().await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn run(self) {
|
||||
let mut ticker = tokio::time::interval(TICK_INTERVAL);
|
||||
// Skip the immediate first fire so we don't race startup.
|
||||
ticker.tick().await;
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
if let Err(err) = self.tick().await {
|
||||
tracing::warn!(?err, "dispatcher tick errored");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn tick(&self) -> Result<(), DispatcherError> {
|
||||
// Cheap gate sample so we don't claim rows we can't dispatch.
|
||||
// The exact permit budget is reapplied per-row below.
|
||||
let rows = self
|
||||
.outbox
|
||||
.claim_due(&self.instance_id, CLAIM_BATCH)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
if rows.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
for row in rows {
|
||||
// Process serially within a tick — the outer ticker is the
|
||||
// pacing mechanism. Concurrent dispatchers are a cluster-
|
||||
// mode concern; v1.1.1 MVP has one.
|
||||
if let Err(err) = self.dispatch_one(row).await {
|
||||
tracing::warn!(?err, "dispatch one errored");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn dispatch_one(&self, row: OutboxRow) -> Result<(), DispatcherError> {
|
||||
// Depth-limit check — design notes §4: loops aren't DL'd.
|
||||
if row.trigger_depth > self.config.max_trigger_depth {
|
||||
tracing::warn!(
|
||||
outbox_id = %row.id,
|
||||
app_id = %row.app_id,
|
||||
trigger_depth = row.trigger_depth,
|
||||
"trigger depth exceeded; dropping row"
|
||||
);
|
||||
// TODO(metrics): bump `picloud_trigger_depth_exceeded{app_id,trigger_id}`.
|
||||
self.outbox
|
||||
.delete(row.id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Gate admission — non-blocking. If the gate is saturated,
|
||||
// release the claim by rescheduling so another tick can pick
|
||||
// it up. The row stays "due" essentially immediately.
|
||||
let Ok(permit) = self.gate.try_acquire() else {
|
||||
let next = Utc::now() + chrono::Duration::milliseconds(100);
|
||||
self.outbox
|
||||
.reschedule(row.id, row.attempt_count, next)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Resolve the trigger config (KV / DL) or pull the HTTP
|
||||
// payload directly off the outbox row.
|
||||
let (resolved, exec_req) = match row.source_kind {
|
||||
OutboxSourceKind::Http => match self.build_http_request(&row).await {
|
||||
Ok(pair) => pair,
|
||||
Err(err) => {
|
||||
tracing::warn!(outbox_id = %row.id, ?err, "http exec build failed; dropping");
|
||||
self.outbox
|
||||
.delete(row.id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
drop(permit);
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
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,
|
||||
Err(err) => {
|
||||
tracing::warn!(outbox_id = %row.id, ?err, "exec request build failed; dropping row");
|
||||
self.outbox
|
||||
.delete(row.id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
drop(permit);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
(resolved, req)
|
||||
}
|
||||
};
|
||||
|
||||
// The gate permit auto-releases when this scope ends or when
|
||||
// the executor finishes. We hand control to the executor and
|
||||
// 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_with_identity(identity, &source, exec_req, ASYNC_EXEC_TIMEOUT)
|
||||
.await;
|
||||
drop(permit);
|
||||
|
||||
match outcome {
|
||||
Ok(resp) => self.handle_success(&row, &resolved, resp).await,
|
||||
Err(err) => self.handle_failure(&row, &resolved, err).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_trigger(&self, row: &OutboxRow) -> Result<ResolvedTrigger, DispatcherError> {
|
||||
// For KV and DL kinds, the outbox carries `trigger_id`. Use it
|
||||
// to look up the trigger row, then resolve the script.
|
||||
let Some(trigger_id) = row.trigger_id else {
|
||||
return Err(DispatcherError::ResolveTrigger(
|
||||
"outbox row missing trigger_id".into(),
|
||||
));
|
||||
};
|
||||
let trigger = self
|
||||
.triggers
|
||||
.get(trigger_id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::ResolveTrigger(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
DispatcherError::ResolveTrigger(format!("trigger {trigger_id} not found"))
|
||||
})?;
|
||||
|
||||
let script = self
|
||||
.scripts
|
||||
.get(trigger.script_id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::ResolveTrigger(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
DispatcherError::ResolveTrigger(format!("script {} not found", trigger.script_id))
|
||||
})?;
|
||||
|
||||
Ok(ResolvedTrigger {
|
||||
trigger_kind: trigger.kind,
|
||||
is_dead_letter_handler: matches!(trigger.kind, TriggerKind::DeadLetter),
|
||||
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,
|
||||
retry_backoff: trigger.retry_backoff,
|
||||
retry_base_ms: trigger.retry_base_ms,
|
||||
})
|
||||
}
|
||||
|
||||
async fn build_exec_request(
|
||||
&self,
|
||||
row: &OutboxRow,
|
||||
resolved: &ResolvedTrigger,
|
||||
) -> Result<ExecRequest, DispatcherError> {
|
||||
let trigger_event: TriggerEvent = serde_json::from_value(row.payload.clone())
|
||||
.map_err(|e| DispatcherError::ResolveTrigger(format!("decode payload: {e}")))?;
|
||||
|
||||
let principal = self
|
||||
.principals
|
||||
.resolve(resolved.registered_by_principal)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::ResolveTrigger(e.to_string()))?;
|
||||
|
||||
let execution_id = ExecutionId::new();
|
||||
Ok(ExecRequest {
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id: resolved.script_id,
|
||||
script_name: resolved.script_name.clone(),
|
||||
invocation_type: InvocationType::Function,
|
||||
path: format!("/trigger/{}", trigger_event.source()),
|
||||
headers: std::collections::BTreeMap::new(),
|
||||
body: serde_json::Value::Null,
|
||||
params: std::collections::BTreeMap::new(),
|
||||
query: std::collections::BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
sandbox_overrides: resolved.sandbox_overrides,
|
||||
app_id: row.app_id,
|
||||
principal: Some(principal),
|
||||
trigger_depth: row.trigger_depth,
|
||||
root_execution_id: row.root_execution_id.unwrap_or(execution_id),
|
||||
is_dead_letter_handler: resolved.is_dead_letter_handler,
|
||||
event: Some(trigger_event),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build an `(ResolvedTrigger, ExecRequest)` for an HTTP outbox
|
||||
/// row. HTTP rows don't have a backing `triggers` row (the
|
||||
/// `trigger_id` references `routes.id` instead). We pull the
|
||||
/// script id off the outbox row, the request shape off the
|
||||
/// payload, and synthesize a `ResolvedTrigger` with retry
|
||||
/// settings irrelevant for HTTP (sync HTTP is never retried;
|
||||
/// async HTTP uses default policy from `TriggerConfig`).
|
||||
async fn build_http_request(
|
||||
&self,
|
||||
row: &OutboxRow,
|
||||
) -> Result<(ResolvedTrigger, ExecRequest), DispatcherError> {
|
||||
let Some(script_id) = row.script_id else {
|
||||
return Err(DispatcherError::ResolveTrigger(
|
||||
"HTTP outbox row missing script_id".into(),
|
||||
));
|
||||
};
|
||||
let script = self
|
||||
.scripts
|
||||
.get(script_id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::ResolveTrigger(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
DispatcherError::ResolveTrigger(format!("script {script_id} not found"))
|
||||
})?;
|
||||
|
||||
let payload: HttpDispatchPayload = serde_json::from_value(row.payload.clone())
|
||||
.map_err(|e| DispatcherError::ResolveTrigger(format!("decode http payload: {e}")))?;
|
||||
|
||||
let execution_id = ExecutionId::new();
|
||||
let req = ExecRequest {
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id,
|
||||
script_name: payload.script_name.clone(),
|
||||
invocation_type: InvocationType::Http,
|
||||
path: payload.path.clone(),
|
||||
headers: payload.headers,
|
||||
body: payload.body,
|
||||
params: payload.params,
|
||||
query: payload.query,
|
||||
rest: payload.rest,
|
||||
sandbox_overrides: script.sandbox,
|
||||
app_id: row.app_id,
|
||||
// HTTP outbox rows don't run as the trigger registrant —
|
||||
// they run with no principal (public ingress) or the
|
||||
// attached one (origin_principal forensic field is not
|
||||
// promoted to execution principal in this MVP).
|
||||
principal: None,
|
||||
trigger_depth: row.trigger_depth,
|
||||
root_execution_id: row.root_execution_id.unwrap_or(execution_id),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
};
|
||||
|
||||
let resolved = ResolvedTrigger {
|
||||
trigger_kind: TriggerKind::Kv, // placeholder; HTTP doesn't have a kind
|
||||
is_dead_letter_handler: false,
|
||||
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
|
||||
// downstream for HTTP (no retries, no inbox principal).
|
||||
registered_by_principal: picloud_shared::AdminUserId::from(uuid::Uuid::nil()),
|
||||
// Async HTTP uses the platform default retry policy from
|
||||
// TriggerConfig. Sync HTTP (reply_to.is_some) never retries
|
||||
// regardless.
|
||||
retry_max_attempts: self.config.retry_max_attempts,
|
||||
retry_backoff: self.config.retry_backoff,
|
||||
retry_base_ms: self.config.retry_base_ms,
|
||||
};
|
||||
Ok((resolved, req))
|
||||
}
|
||||
|
||||
async fn handle_success(
|
||||
&self,
|
||||
row: &OutboxRow,
|
||||
_resolved: &ResolvedTrigger,
|
||||
resp: ExecResponse,
|
||||
) -> Result<(), DispatcherError> {
|
||||
if let Some(inbox_id) = row.reply_to {
|
||||
self.deliver_inbox(row, inbox_id, InboxResult::Success(summarize(&resp)))
|
||||
.await;
|
||||
}
|
||||
self.outbox
|
||||
.delete(row.id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_failure(
|
||||
&self,
|
||||
row: &OutboxRow,
|
||||
resolved: &ResolvedTrigger,
|
||||
err: ExecError,
|
||||
) -> Result<(), DispatcherError> {
|
||||
// Sync HTTP: always single-attempt. Always deliver outcome
|
||||
// (success-or-failure) to the inbox. Never retry, never DL.
|
||||
if let Some(inbox_id) = row.reply_to {
|
||||
let (kind, message) = classify_exec_error(&err);
|
||||
self.deliver_inbox(
|
||||
row,
|
||||
inbox_id,
|
||||
InboxResult::Failure {
|
||||
kind,
|
||||
message: message.clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
self.outbox
|
||||
.delete(row.id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Dead-letter handler: never retry, never DL. Failure
|
||||
// annotates the original DL row + bumps a metric.
|
||||
if resolved.is_dead_letter_handler {
|
||||
tracing::error!(
|
||||
outbox_id = %row.id,
|
||||
app_id = %row.app_id,
|
||||
?err,
|
||||
"dead-letter handler failed; not retrying"
|
||||
);
|
||||
// TODO(metrics): bump `picloud_dead_letter_handler_failures{app_id}`.
|
||||
// Annotate the original DL row (id is `row.payload.dead_letter.id`
|
||||
// when the payload is a DeadLetter TriggerEvent). Best-effort:
|
||||
// if the payload doesn't decode, just log and move on.
|
||||
if let Ok(TriggerEvent::DeadLetter { dead_letter_id, .. }) =
|
||||
serde_json::from_value::<TriggerEvent>(row.payload.clone())
|
||||
{
|
||||
if let Err(e) = self
|
||||
.dead_letters
|
||||
.resolve(dead_letter_id, "handler_failed")
|
||||
.await
|
||||
{
|
||||
tracing::warn!(?e, "could not annotate DL row as handler_failed");
|
||||
}
|
||||
}
|
||||
self.outbox
|
||||
.delete(row.id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Async event: retry per policy, then dead-letter.
|
||||
let attempt = row.attempt_count + 1;
|
||||
if attempt < resolved.retry_max_attempts {
|
||||
let delay = compute_backoff(
|
||||
attempt,
|
||||
resolved.retry_backoff,
|
||||
resolved.retry_base_ms,
|
||||
self.config.retry_jitter_pct,
|
||||
);
|
||||
let next = Utc::now() + chrono::Duration::milliseconds(i64::from(delay));
|
||||
tracing::info!(
|
||||
outbox_id = %row.id,
|
||||
attempt,
|
||||
max_attempts = resolved.retry_max_attempts,
|
||||
retry_in_ms = delay,
|
||||
"rescheduling outbox row"
|
||||
);
|
||||
self.outbox
|
||||
.reschedule(row.id, attempt, next)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Exhausted retries → dead-letter.
|
||||
let (op, source) = describe_event(&row.payload);
|
||||
let now = Utc::now();
|
||||
let dl_id = match self
|
||||
.dead_letters
|
||||
.insert(NewDeadLetter {
|
||||
app_id: row.app_id,
|
||||
original_event_id: row.id,
|
||||
source: source.clone(),
|
||||
op,
|
||||
trigger_id: row.trigger_id,
|
||||
script_id: Some(resolved.script_id),
|
||||
payload: row.payload.clone(),
|
||||
attempt_count: attempt,
|
||||
first_attempt_at: row.created_at,
|
||||
last_attempt_at: now,
|
||||
last_error: err.to_string(),
|
||||
})
|
||||
.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
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
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 => {}
|
||||
InboxDeliveryOutcome::Abandoned => {
|
||||
// Receiver was dropped — record forensic row + bump
|
||||
// metric.
|
||||
let (status_code, summary) = match &result {
|
||||
InboxResult::Success(s) => (s.status_code, None),
|
||||
InboxResult::Failure { kind, message } => {
|
||||
(failure_kind_to_status(*kind), Some(message.clone()))
|
||||
}
|
||||
};
|
||||
if let Err(e) = self
|
||||
.abandoned
|
||||
.insert(NewAbandonedExecution {
|
||||
app_id: row.app_id,
|
||||
outbox_id: row.id,
|
||||
script_id: row.script_id,
|
||||
inbox_id,
|
||||
status_code,
|
||||
result_summary: summary,
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!(?e, "abandoned_executions insert failed");
|
||||
}
|
||||
// TODO(metrics): bump `picloud_abandoned_executions_total{app_id}`.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ResolvedTrigger {
|
||||
pub trigger_kind: TriggerKind,
|
||||
pub is_dead_letter_handler: bool,
|
||||
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,
|
||||
pub retry_backoff: BackoffShape,
|
||||
pub retry_base_ms: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DispatcherError {
|
||||
#[error("outbox: {0}")]
|
||||
Outbox(String),
|
||||
#[error("resolve trigger: {0}")]
|
||||
ResolveTrigger(String),
|
||||
}
|
||||
|
||||
fn summarize(resp: &ExecResponse) -> ExecResponseSummary {
|
||||
ExecResponseSummary {
|
||||
status_code: resp.status_code,
|
||||
headers: resp.headers.clone(),
|
||||
body: resp.body.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map `ExecError` onto the design-notes §3 status-code table.
|
||||
fn classify_exec_error(err: &ExecError) -> (InboxFailureKind, String) {
|
||||
match err {
|
||||
ExecError::Parse(s) | ExecError::InvalidResponse(s) => {
|
||||
(InboxFailureKind::Validation, s.clone())
|
||||
}
|
||||
ExecError::Timeout(_) => (InboxFailureKind::Timeout, err.to_string()),
|
||||
ExecError::OperationBudgetExceeded => (InboxFailureKind::OperationBudget, err.to_string()),
|
||||
ExecError::Overloaded { .. } => (InboxFailureKind::Overloaded, err.to_string()),
|
||||
ExecError::Runtime(s) => (InboxFailureKind::Runtime, s.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn failure_kind_to_status(k: InboxFailureKind) -> u16 {
|
||||
match k {
|
||||
InboxFailureKind::Validation => 422,
|
||||
InboxFailureKind::Runtime => 502,
|
||||
InboxFailureKind::Overloaded => 503,
|
||||
InboxFailureKind::Timeout => 504,
|
||||
InboxFailureKind::OperationBudget => 507,
|
||||
InboxFailureKind::Platform => 500,
|
||||
}
|
||||
}
|
||||
|
||||
/// `(op, source)` extracted from the outbox payload. Used to seed the
|
||||
/// `dead_letters` row when retries exhaust.
|
||||
fn describe_event(payload: &serde_json::Value) -> (String, String) {
|
||||
let source = payload
|
||||
.get("source")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let op = payload
|
||||
.get("op")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
(op, source)
|
||||
}
|
||||
|
||||
/// Compute backoff (ms) for the given attempt + policy + jitter.
|
||||
/// Attempt is 1-indexed (first retry = attempt 1).
|
||||
#[must_use]
|
||||
pub fn compute_backoff(attempt: u32, backoff: BackoffShape, base_ms: u32, jitter_pct: u32) -> u32 {
|
||||
let base_ms = u64::from(base_ms);
|
||||
let attempt = u64::from(attempt.saturating_sub(1));
|
||||
let raw = match backoff {
|
||||
BackoffShape::Constant => base_ms,
|
||||
BackoffShape::Linear => base_ms * (attempt + 1),
|
||||
// 1x base, 2x base, 4x base, … (saturating).
|
||||
BackoffShape::Exponential => base_ms.saturating_mul(1u64 << attempt.min(20)),
|
||||
};
|
||||
let raw = u32::try_from(raw.min(u64::from(u32::MAX))).unwrap_or(u32::MAX);
|
||||
apply_jitter(raw, jitter_pct)
|
||||
}
|
||||
|
||||
fn apply_jitter(raw: u32, pct: u32) -> u32 {
|
||||
if pct == 0 {
|
||||
return raw;
|
||||
}
|
||||
let pct = pct.min(100);
|
||||
// ±span% — bounded by raw itself so we can't underflow when
|
||||
// raw + offset goes below zero.
|
||||
let span = u64::from(raw) * u64::from(pct) / 100;
|
||||
if span == 0 {
|
||||
return raw;
|
||||
}
|
||||
let span_i64 = i64::try_from(span).unwrap_or(i64::MAX);
|
||||
let mut rng = rand::thread_rng();
|
||||
let offset = rng.gen_range(-span_i64..=span_i64);
|
||||
let signed = i64::from(raw).saturating_add(offset).max(0);
|
||||
u32::try_from(signed.min(i64::from(u32::MAX))).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exponential_backoff_doubles_per_attempt() {
|
||||
// No jitter (pct=0) for a deterministic check.
|
||||
assert_eq!(compute_backoff(1, BackoffShape::Exponential, 1000, 0), 1000);
|
||||
assert_eq!(compute_backoff(2, BackoffShape::Exponential, 1000, 0), 2000);
|
||||
assert_eq!(compute_backoff(3, BackoffShape::Exponential, 1000, 0), 4000);
|
||||
assert_eq!(compute_backoff(4, BackoffShape::Exponential, 1000, 0), 8000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_backoff_scales_with_attempt() {
|
||||
assert_eq!(compute_backoff(1, BackoffShape::Linear, 100, 0), 100);
|
||||
assert_eq!(compute_backoff(2, BackoffShape::Linear, 100, 0), 200);
|
||||
assert_eq!(compute_backoff(5, BackoffShape::Linear, 100, 0), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_backoff_returns_base() {
|
||||
for attempt in 1..=5 {
|
||||
assert_eq!(
|
||||
compute_backoff(attempt, BackoffShape::Constant, 750, 0),
|
||||
750
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_within_pct_of_base() {
|
||||
for _ in 0..100 {
|
||||
let v = compute_backoff(1, BackoffShape::Constant, 1000, 20);
|
||||
// ±20% of 1000 = 800..=1200.
|
||||
assert!((800..=1200).contains(&v), "jitter out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_exec_error_covers_every_variant() {
|
||||
let parse = classify_exec_error(&ExecError::Parse("nope".into()));
|
||||
assert!(matches!(parse.0, InboxFailureKind::Validation));
|
||||
let invalid = classify_exec_error(&ExecError::InvalidResponse("bad".into()));
|
||||
assert!(matches!(invalid.0, InboxFailureKind::Validation));
|
||||
let timeout = classify_exec_error(&ExecError::Timeout(30));
|
||||
assert!(matches!(timeout.0, InboxFailureKind::Timeout));
|
||||
let budget = classify_exec_error(&ExecError::OperationBudgetExceeded);
|
||||
assert!(matches!(budget.0, InboxFailureKind::OperationBudget));
|
||||
let runtime = classify_exec_error(&ExecError::Runtime("threw".into()));
|
||||
assert!(matches!(runtime.0, InboxFailureKind::Runtime));
|
||||
let overload = classify_exec_error(&ExecError::Overloaded {
|
||||
retry_after_secs: 1,
|
||||
});
|
||||
assert!(matches!(overload.0, InboxFailureKind::Overloaded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_kind_status_codes_match_design_notes() {
|
||||
assert_eq!(failure_kind_to_status(InboxFailureKind::Validation), 422);
|
||||
assert_eq!(failure_kind_to_status(InboxFailureKind::Runtime), 502);
|
||||
assert_eq!(failure_kind_to_status(InboxFailureKind::Overloaded), 503);
|
||||
assert_eq!(failure_kind_to_status(InboxFailureKind::Timeout), 504);
|
||||
assert_eq!(
|
||||
failure_kind_to_status(InboxFailureKind::OperationBudget),
|
||||
507
|
||||
);
|
||||
assert_eq!(failure_kind_to_status(InboxFailureKind::Platform), 500);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user