8 Commits

Author SHA1 Message Date
MechaCat02
d064681c49 docs(v1.1.5): reviewer audit report — APPROVE verdict
Audit of feat/v1.1.5-files-pubsub against the v1.1.5 dispatch prompt.
All gates green on HEAD; 491 tests pass (+64 new), 139 ignored.

Atomic write protocol audited line-by-line: single-pass SHA-256,
temp→fsync→rename→fsync-dir→DB sequence as specified, unique pid+
counter temp suffix, path-traversal defense at SDK boundary and repo.
Pub/sub fan-out is correctly transactional (single tx begin+commit;
one outbox row per matching subscriber; trigger_depth saturating-
bumped). Topic pattern matcher rejects every shape the brief called
out (*.created, **, a.*.b, user.*x, *user, empty).

Three flagged open questions resolved: orphan-sweep deferred (matches
planning decision), test count 63 vs 70 (defensible — gap is the
dispatcher e2e test, which is already covered for kv/docs/cron via
the shared dispatcher path), empty-blob = missing-data (defensible
interpretation, relaxable later).

First CI workflow added; schema_snapshot un-ignored with DATABASE_URL-
absent skip path.
2026-06-03 21:52:34 +02:00
MechaCat02
9492c18d0e docs(v1.1.5): handback report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:47:55 +02:00
MechaCat02
4595db7a7a chore(v1.1.5): version bumps, CI workflow, schema-snapshot un-ignore
- Workspace 1.1.4 → 1.1.5; SDK 1.5 → 1.6; dashboard 0.10.0 → 0.11.0.
- CHANGELOG v1.1.5 entry; CLAUDE.md runtime-config table gains
  PICLOUD_FILES_ROOT + PICLOUD_FILES_MAX_FILE_SIZE_BYTES.
- schema_snapshot test: drop #[ignore] + #[sqlx::test]; run against
  DATABASE_URL when set, skip cleanly when absent. Re-blessed golden
  picks up files / files_trigger_details / pubsub_trigger_details, the
  two widened CHECKs, and the pubsub partial index.
- First CI workflow (.github/workflows/ci.yml): postgres:15 service +
  fmt + clippy + cargo test --workspace; separate dashboard check job.
- Add files/pubsub admin-trigger reject-coverage tests (module +
  cross-app + bad-pattern), mirroring the v1.1.3 regression set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:44:12 +02:00
MechaCat02
834c787ee1 feat(v1.1.5): pubsub::publish_durable SDK + pubsub:* triggers
Durable pub/sub through the universal outbox — the sixth trigger kind.

- `pubsub::publish_durable(topic, message)` Rhai SDK (no handle; topics
  ARE the grouping unit). Message JSON-encoded; Blobs base64 at any
  depth.
- `PubsubService` trait in picloud-shared with the topic matcher +
  validator (exact / `<prefix>.*` / `*`; mid-pattern wildcards
  rejected). `PostgresPubsubRepo` + `PubsubServiceImpl` in manager-core.
- Publish-time fan-out: one outbox row per matching enabled pubsub
  trigger, all in ONE transaction (no half-fan-out on crash). No
  matching trigger → publish succeeds silently, zero rows.
- `pubsub:*` trigger kind via Layout-E (0020: widen both CHECKs +
  pubsub_trigger_details + partial index), TriggerEvent::Pubsub +
  ctx.event.pubsub, dispatcher arm, admin endpoint POST /triggers/pubsub
  (validates topic pattern + reuses validate_trigger_target).
- AppPubsubPublish capability → script:write (seven-scope held).
- Dashboard Pub/Sub trigger form on the Triggers tab + list rendering.

publish_ephemeral stays deferred to v1.2. ~18 new tests (service
in-memory incl. transactional-rollback, shared matcher, bridge
encoding). No DB required for the suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:37:06 +02:00
MechaCat02
6e132b6ee0 feat(v1.1.5): files SDK + files:* triggers
Filesystem-backed blob storage as the fifth concrete trigger kind.

- `files::collection(c).{create,head,get,update,delete,list}` Rhai SDK
  (blob in/out; metadata maps; missing-field throws naming the field).
- `FilesService` trait in picloud-shared; `FsFilesRepo` (atomic
  write: temp→fsync→rename→fsync-dir→DB; single-pass SHA-256;
  checksum-verified reads → Corrupted) + `FilesServiceImpl` in
  manager-core. Metadata in Postgres (0018), bytes on disk under
  PICLOUD_FILES_ROOT with 0o700 shard dirs.
- `files:*` trigger kind via the Layout-E pattern (0019: widen both
  CHECKs + files_trigger_details), TriggerEvent::Files (metadata only,
  no bytes), emit_files fan-out, dispatcher arm, admin endpoint
  POST /triggers/files (reuses validate_trigger_target).
- AppFilesRead/AppFilesWrite capabilities → script:read/script:write
  (seven-scope commitment held). AppPubsubPublish reserved for v1.1.6.
- Admin files API (list + delete) + dashboard Files view per app.

Cross-app isolation keyed on cx.app_id at every layer. ~45 new tests
(service in-memory, fs tempdir, bridge integration). No DB required
for the suite. publish_ephemeral and the orphan sweep stay deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:18:17 +02:00
MechaCat02
03d03ea6e7 docs(v1.1.4): reviewer audit report — APPROVE verdict
Audit of feat/v1.1.4-http-cron against the v1.1.4 dispatch prompt.
All gates green on HEAD; 427 tests pass (+69 new), 140 ignored.
SSRF policy audited line-by-line: DNS-rebinding defense via reqwest
dns_resolver, literal-IP gap closed at validate_url on every redirect
hop, IPv4-mapped IPv6 re-check, IP never leaked in error strings.
Cron scheduler's fire-once catch-up policy verified; transactional
outbox-insert + last_fired_at bump.

Two flagged divergences accepted: three-arg verb(url, body, opts)
HTTP shape (resolves a self-contradiction in the brief; body_raw
dropped because raw strings just use positional body), and stale
schema-snapshot golden re-blessed (pre-existing drift from v1.1.1-
v1.1.3 — recommend lifting #[ignore] with CI DB in v1.1.5).

Three v1.1.3 follow-ups landed: module backend error redaction,
rhai = "=1.24" exact pin, retroactive CHANGELOG security note.
2026-06-03 20:32:10 +02:00
MechaCat02
6080fc67f6 docs(v1.1.4): handback report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 20:26:44 +02:00
MechaCat02
10b5f655d5 feat(v1.1.4): outbound HTTP SDK + cron triggers
HTTP (`http::*`):
- `HttpService` trait (picloud-shared) + reqwest-backed `HttpServiceImpl`
  (manager-core), wired into the `Services` bundle.
- SSRF deny-list applied to the resolved IP via a custom reqwest
  `dns_resolver` (covers every redirect hop + defeats DNS rebinding) plus
  a literal-IP check at URL-parse time. Scheme/port restrictions, request
  + response body caps (stream-with-cap), layered timeout. Error reason is
  a CIDR category, never the IP. `PICLOUD_HTTP_ALLOW_PRIVATE` dev override
  (logs a startup warning).
- Rhai bridge with three-arg split `verb(url, body, opts)` (resolves the
  brief's body-vs-opts contradiction; unknown opt keys throw). Body
  dispatch by type; response `#{status,headers,body,body_raw}` with JSON
  auto-parse; non-2xx does not throw.
- `Capability::AppHttpRequest` → existing `script:write` scope (no new
  Scope variant). `SdkCallCx` gains `script_id` (attribution + User-Agent).

Cron triggers (4th trigger kind):
- Migration 0017 widens the kind/source_kind CHECKs and adds
  `cron_trigger_details`. `cron`/`chrono-tz` parse + validate 6-field
  schedules and IANA timezones.
- `spawn_cron_scheduler` polls due triggers and enqueues to the universal
  outbox; the dispatcher delivers them (one-line match-arm extension).
  Catch-up fires exactly once per trigger per tick, not once per missed
  window. `ctx.event.cron` for handlers.
- `POST /api/v1/admin/apps/{id}/triggers/cron` reuses the v1.1.3
  cross-app + kind!=module target check.
- Dashboard: admin-gated Triggers tab (cron create form + list).

Follow-ups: redact module backend errors at the resolver boundary (log
original at error level); pin `rhai = "=1.24"`; CHANGELOG incl. retroactive
v1.1.3 cross-app-trigger security note. Version bumps: workspace 1.1.4,
SDK 1.5, dashboard 0.10.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 20:23:18 +02:00
60 changed files with 9306 additions and 497 deletions

72
.github/workflows/ci.yml vendored Normal file
View 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

View File

@@ -1,5 +1,163 @@
# PiCloud Changelog # PiCloud Changelog
## 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) ## v1.1.3 — Modules (unreleased)
Real per-app Rhai module system. Scripts can `import "<name>" as Real per-app Rhai module system. Scripts can `import "<name>" as
@@ -84,6 +242,21 @@ per-invocation compile cost; both invalidate on `updated_at` change.
- **Route creation** — `POST /api/v1/admin/scripts/{id}/routes` - **Route creation** — `POST /api/v1/admin/scripts/{id}/routes`
returns 400 when the target script is `kind = 'module'`. 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 ### Migrations
- `0015_scripts_kind.sql` — adds `scripts.kind` with CHECK - `0015_scripts_kind.sql` — adds `scripts.kind` with CHECK

View File

@@ -118,6 +118,8 @@ Environment variables consumed by the `picloud` binary:
| `DATABASE_URL` | — | Required. Postgres connection string. | | `DATABASE_URL` | — | Required. Postgres connection string. |
| `PICLOUD_SESSION_TTL_HOURS` | `24` | Sliding-window session lifetime. | | `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_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 ## Out of MVP

125
Cargo.lock generated
View File

@@ -378,6 +378,28 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "chrono-tz"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
dependencies = [
"chrono",
"chrono-tz-build",
"phf",
]
[[package]]
name = "chrono-tz-build"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
dependencies = [
"parse-zoneinfo",
"phf",
"phf_codegen",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.6.1" version = "4.6.1"
@@ -499,6 +521,17 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "cron"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07"
dependencies = [
"chrono",
"nom",
"once_cell",
]
[[package]] [[package]]
name = "crossbeam-queue" name = "crossbeam-queue"
version = "0.3.12" version = "0.3.12"
@@ -1326,6 +1359,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.0" version = "1.2.0"
@@ -1346,6 +1385,16 @@ dependencies = [
"spin 0.5.2", "spin 0.5.2",
] ]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]] [[package]]
name = "normalize-line-endings" name = "normalize-line-endings"
version = "0.3.0" version = "0.3.0"
@@ -1463,6 +1512,15 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "parse-zoneinfo"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
dependencies = [
"regex",
]
[[package]] [[package]]
name = "password-hash" name = "password-hash"
version = "0.5.0" version = "0.5.0"
@@ -1512,9 +1570,47 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand 0.8.6",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]] [[package]]
name = "picloud" name = "picloud"
version = "1.1.3" version = "1.1.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -1540,7 +1636,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-cli" name = "picloud-cli"
version = "1.1.3" version = "1.1.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"assert_cmd", "assert_cmd",
@@ -1561,7 +1657,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-executor" name = "picloud-executor"
version = "1.1.3" version = "1.1.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"picloud-executor-core", "picloud-executor-core",
@@ -1573,7 +1669,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-executor-core" name = "picloud-executor-core"
version = "1.1.3" version = "1.1.5"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64", "base64",
@@ -1590,12 +1686,14 @@ dependencies = [
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber",
"url",
"uuid", "uuid",
] ]
[[package]] [[package]]
name = "picloud-manager" name = "picloud-manager"
version = "1.1.3" version = "1.1.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"picloud-manager-core", "picloud-manager-core",
@@ -1607,18 +1705,21 @@ dependencies = [
[[package]] [[package]]
name = "picloud-manager-core" name = "picloud-manager-core"
version = "1.1.3" version = "1.1.5"
dependencies = [ dependencies = [
"argon2", "argon2",
"async-trait", "async-trait",
"axum", "axum",
"base64", "base64",
"chrono", "chrono",
"chrono-tz",
"cron",
"data-encoding", "data-encoding",
"picloud-executor-core", "picloud-executor-core",
"picloud-orchestrator-core", "picloud-orchestrator-core",
"picloud-shared", "picloud-shared",
"rand 0.8.6", "rand 0.8.6",
"reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@@ -1632,7 +1733,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-orchestrator" name = "picloud-orchestrator"
version = "1.1.3" version = "1.1.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"picloud-orchestrator-core", "picloud-orchestrator-core",
@@ -1644,7 +1745,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-orchestrator-core" name = "picloud-orchestrator-core"
version = "1.1.3" version = "1.1.5"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -1665,7 +1766,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-shared" name = "picloud-shared"
version = "1.1.3" version = "1.1.5"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
@@ -2368,6 +2469,12 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"

View File

@@ -13,7 +13,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "1.1.3" version = "1.1.5"
edition = "2021" edition = "2021"
rust-version = "1.92" rust-version = "1.92"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -47,12 +47,16 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# IDs + time # IDs + time
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["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 traits
async-trait = "0.1" async-trait = "0.1"
# Rhai scripting # Rhai scripting. Pinned exactly (`=1.24`) because the `internals`
rhai = { version = "1.19", features = ["sync", "serde"] } # 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) # Postgres (manager-core only — others stay DB-free)
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "macros", "migrate"] } sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "macros", "migrate"] }

View File

@@ -1,351 +1,127 @@
# v1.1.3 — Modules — Handback # HANDBACK — v1.1.5 Files & Pub/Sub
## 1. Branch summary ## §1 Branch + commits
- **Branch:** `feat/v1.1.3-modules` - **Branch:** `feat/v1.1.5-files-pubsub` (off `main`). Not pushed, not merged, no PR.
- **Commits ahead of `main`:** 6 - **Commits:** the two-feature split decided in planning + a finalize commit; HANDBACK is the 4th (docs):
- **HEAD:** `3dbead4` 1. `6e132b6 feat(v1.1.5): files SDK + files:* triggers`
- **Not pushed, not merged, no PR opened** (per brief). 2. `834c787 feat(v1.1.5): pubsub::publish_durable SDK + pubsub:* triggers`
3. `4595db7 chore(v1.1.5): version bumps, CI workflow, schema-snapshot un-ignore`
4. `docs(v1.1.5): handback report` (this file)
Commits (newest first): Each of commits 13 is independently green (fmt + clippy + `cargo test --workspace`). Shared files (Cargo deps, `Services` bundle, `version.rs`, dispatcher arm, authz enum, CHANGELOG) are touched in both feature commits as planned — additive only, so commit 1 compiles green with the `AppPubsubPublish` capability and the dashboard `'pubsub'` type union present-but-unused until commit 2.
``` ## §2 Scope coverage
3dbead4 test(v1.1.3-modules): resolver, cache, validator, kind-rejection coverage
10f76d2 chore(v1.1.3-modules): version bumps + CHANGELOG + blueprint touch-up
610fd4f feat(v1.1.3-modules): dashboard kind dropdown + scripts-list and detail badges
66b41bb feat(v1.1.3-modules): top-level script AST cache in LocalExecutorClient
c6211a7 feat(v1.1.3-modules): reject module scripts from routes + triggers; tighten cross-app trigger check
84833d3 feat(v1.1.3-modules): shared types, migrations, engine + resolver scaffold
```
--- | Brief item | Status | Notes |
## 2. Scope coverage
| # | Brief item | Status | Notes |
|---|---|---|---|
| 1 | `scripts.kind` column + check + index | **Done** | `migrations/0015_scripts_kind.sql` |
| 2 | Module syntax constraints (fn / const / import only) | **Done** | Walks `ast.statements()` via `rhai/internals`. Admin endpoint is primary gate; resolver re-runs the check for defense-in-depth. |
| 3 | `ModuleResolver` replaces `DummyModuleResolver` | **Done** | `crates/executor-core/src/module_resolver.rs`; per-call instance with cross-app isolation, cycle detect, depth limit. |
| 4 | Two AST caches (script + module) | **Done** | Script cache in `LocalExecutorClient`; module cache in `Engine`. Both invalidate by `updated_at` comparison. Env-overridable sizes. |
| 5 | Dep-graph table + populate | **Done** | `migrations/0016_script_imports.sql`; `replace_imports_tx` writes edges in the same transaction as the script INSERT/UPDATE. |
| 6 | Admin endpoint changes (kind, kind-change rejection, route/trigger module rejection) | **Done** | Also closes a latent cross-app trigger gap (script.app_id mismatch — see §7). |
| 7 | Dashboard surface (kind dropdown + badge) | **Done** | App page form + scripts list + script detail header. `npm run check` clean. |
| 8 | `ModuleSource` trait shape | **Done** | Lives in `picloud-shared`; matches the v1.1.1/v1.1.2 service pattern. |
| 9 | Version bumps | **Done** | Workspace 1.1.2→1.1.3, SDK 1.3→1.4, dashboard 0.8.0→0.9.0. |
| 10 | Tests (~4060) | **Done** | 46 new tests across 5 crates. Gates green. |
### Scope-out items (confirmed NOT built)
- No module versioning / pinning, no `@v3` syntax.
- No eager precompilation at save-time.
- No dashboard dep-graph visualization.
- No LISTEN/NOTIFY-based cross-node invalidation.
- No new `Scope` variants (modules use existing `script:read` / `script:write`).
- No admin GET endpoints for `script_imports` (the table is persisted for v1.2+; no v1.1.3 read surface — see §10 deferred items).
---
## 3. Resolver implementation notes
### 3.1 In-progress-imports stack
Lives **on the per-call `PicloudModuleResolver` instance**, not on `SdkCallCx`. The resolver is constructed fresh per `Engine::execute_ast` call (see `crates/executor-core/src/engine.rs:execute_ast`), so the stack is naturally scoped to one execution. Both the stack and the depth counter are `Mutex<…>` (not `RefCell<…>`) because `rhai::ModuleResolver: SendSync` under the `sync` feature.
An RAII `StackGuard` pops the stack and decrements depth on drop — a compile error or panic anywhere inside `resolve()` cleans up properly. The lock is uncontended in practice (Rhai evaluation on the engine is single-threaded).
### 3.2 Sync → async bridge
Rhai's `ModuleResolver::resolve` is sync; `ModuleSource::lookup` is async. The bridge:
```rust
let handle = tokio::runtime::Handle::try_current().map_err(/* surfaces as ErrorRuntime */)?;
let lookup = tokio::task::block_in_place(|| handle.block_on(self.source.lookup(&self.cx, path)));
```
- `try_current()` (not `current()`) so test harnesses that build an `Engine` outside a Tokio runtime get a clean error instead of a panic.
- `block_in_place` makes the call safe both on `spawn_blocking` threads (where it's a no-op) and on multi-threaded runtime worker tasks (where it instructs the runtime to relocate other tasks before we block). This was load-bearing for the resolver tests, which call `engine.execute` directly from `#[tokio::test(flavor = "multi_thread")]`.
- A `current_thread` runtime still panics — but production callers wrap `Engine::execute` in `tokio::task::spawn_blocking` (see `LocalExecutorClient::execute_with_identity`), which avoids that path entirely.
### 3.3 Cross-app isolation enforcement
The resolver captures `Arc<SdkCallCx>` at construction. Every `ModuleSource::lookup` call passes `&self.cx`. The Postgres impl (`crates/manager-core/src/module_source.rs`) selects with `WHERE app_id = $1 AND kind = 'module' AND name = $2`, binding `$1` from `cx.app_id.into_inner()` — never from any script-passed argument. The Rhai script's `import "name" as alias;` syntax has no slot for an `app_id`, so there is no path by which a script in app A can name a row in app B.
Verified by `resolver_cross_app_blocked` and `resolver_cross_app_module_not_found` tests.
### 3.4 Module-shape validation — both layers
- **Primary gate (admin endpoint)** — `manager-core::api::create_script` and `update_script` call `state.validator.validate_module(src)` whenever the effective kind is `Module`. `Engine`'s impl walks `ast.statements()`, accepting only `Stmt::Var(_, ASTFlags::CONSTANT, _)`, `Stmt::Import(..)`, and `Stmt::Noop(..)`. Anything else (top-level expression, let, if, while, …) is rejected with a clear `ValidationError::ModuleShape` message.
- **Defense in depth (resolver)** — the resolver calls `check_module_shape` again after `engine.compile(source)`. This catches rows that bypassed the API (manual SQL inserts, future migration bugs, restoring from an older backup).
Note: Rhai's default optimizer constant-folds `if true { ... }` away, so a module containing `if true { ... }` parses to an empty body and passes vacuously. This is fine semantically (the script has no observable behavior), but it surprises authors. Documented as a known acceptance edge; not worth disabling optimization for.
### 3.5 What the resolver does NOT enforce
- **Module access permissions** — every module in an app is importable by every other script in the same app. Per-module ACLs are explicitly v1.2+.
- **Module versioning / pinning** — there's exactly one current version per `(app_id, name)`. v1.3+.
---
## 4. Cache design notes
### 4.1 LRU library
**`lru = "0.12"`** — added to `[workspace.dependencies]`. Standard choice, no-frills crate (`LruCache<K, V>` with `put`/`get`/`len`/etc.). Both caches use `Arc<Mutex<LruCache<K, V>>>` so they're cheap to clone and safe to share across executions.
### 4.2 Cache key shapes + what's stored
| Cache | Owner | Key | Value | Stores |
|---|---|---|---|---|
| **Script AST cache** | `LocalExecutorClient` | `ScriptId` | `CachedScript { updated_at: DateTime<Utc>, ast: Arc<rhai::AST> }` | Compiled AST for the top-level (endpoint) script. |
| **Module cache** | `Engine` | `(AppId, String)` | `CachedModule { updated_at: DateTime<Utc>, module: Shared<rhai::Module> }` | Compiled `rhai::Module` produced by `Module::eval_ast_as_new`. |
The script cache stores `Arc<AST>` so an evaluation can grab a cheap clone and hand it to `Engine::execute_ast` without holding the cache lock. The module cache stores `Shared<Module>` (= `Arc<Module>` under the `sync` feature) because that's what `ModuleResolver::resolve` must return.
### 4.3 Stale-version detection
Both caches use the same logic: **compare `cached.updated_at` against the freshly-known `updated_at`**.
- For the script cache, the caller passes the fresh value as `ScriptIdentity.updated_at` — the orchestrator already loaded the script row to dispatch the request, so there's no extra DB hit.
- For the module cache, the resolver must call `ModuleSource::lookup` first to learn the fresh `updated_at` — every `import` does at least one DB roundtrip. That's a deliberate trade-off (documented in CHANGELOG): the alternative (TTL caching or pub/sub) introduces staleness during edits and complicates "publish a fix immediately" UX. Worth re-evaluating in v1.3+ when LISTEN/NOTIFY makes pub/sub cheap.
Mismatch → recompile + `cache.put(...)` replace. LRU eviction is automatic when capacity is exceeded.
### 4.4 Capacity overrides
- `PICLOUD_SCRIPT_CACHE_SIZE` (default 256, `LocalExecutorClient`)
- `PICLOUD_MODULE_CACHE_SIZE` (default 512, `Engine`)
Both clamp `max(1)` to avoid the LRU constructor's panic on zero. `Engine::with_module_cache_capacity` and `LocalExecutorClient::with_script_cache_capacity` give tests explicit handles.
---
## 5. Dep-graph population
### 5.1 Where the extraction happens
Inside the `ScriptValidator` impl on `Engine`. The trait now returns `ValidatedScript { imports: Vec<String> }`, populated by `extract_imports` (endpoint scripts) or `validate_module_source` (module scripts). Both walk `ast.statements()` and pull out `Stmt::Import(boxed_path_expr, _)` where the path is a `StringConstant`.
**Dynamic imports** (`import some_var as alias;`) are NOT captured because we can't know the name at compile time. Tested by `validate_endpoint_skips_dynamic_imports_in_imports_list`. Documented as a known limitation in the CHANGELOG and migration 0016's header comment.
### 5.2 Where the write happens — transactional with the script INSERT/UPDATE
`PostgresScriptRepository::create` and `update` both open a `tx = pool.begin().await?`. The script row is inserted/updated inside the tx; immediately after, `replace_imports_tx(&mut tx, importer, app_id, &imports)` runs. The tx is committed at the end. If any step fails, both the script change and the dep-graph mutation roll back together. No half-state where the script row exists but the edges don't (or vice versa).
`replace_imports_tx`:
1. `DELETE FROM script_imports WHERE importer_script_id = $1` — replaces wholesale.
2. `INSERT INTO script_imports ... SELECT ... FROM scripts WHERE app_id = $1 AND kind = 'module' AND name = ANY($3) ON CONFLICT DO NOTHING` — best-effort: only resolves to existing modules in the same app; unresolved names are silently skipped (no error). A later save of either script re-resolves and writes the edge.
### 5.3 Schema decisions
- `script_imports.app_id` is denormalized but useful: the "all imports in app X" scan happens once at boot for caching and (eventually) for the dashboard's audit view. Without it, that query would need a 3-way join.
- `created_at` is unused by v1.1.3 logic but trivial to add now and useful for v1.2+ "first imported" diagnostics.
- The FK on `imported_script_id` cascades — when a module is deleted, every edge referencing it goes too. The cascade isn't exercised by a unit test (it would need Postgres); it's covered by the FK design.
---
## 6. Tests added
46 new tests across 5 crates. All green on HEAD `3dbead4`. Inventory:
### `crates/executor-core/tests/modules.rs` (NEW — 23 tests)
End-to-end through `Engine::execute` with a `CountingModuleSource` (in-memory fake).
| # | Test | Covers |
|---|---|---| |---|---|---|
| 1 | `resolver_loads_simple_module` | Happy path: `import "m" as m; m::add(2, 3)` → 5. | | §1 `files::*` SDK | ✅ | `create/head/get/update/delete/list`, blob in/out, metadata maps, throw-vs-`()` convention. |
| 2 | `resolver_cross_app_blocked` | Modules with same name in two apps resolve to the calling app's version. | | §1 migration 0018_files.sql | ✅ | metadata table + `idx_files_app_collection`. Bytes on disk, never in PG. |
| 3 | `resolver_cross_app_module_not_found` | App B's `import "lonely"` returns ModuleNotFound when only app A has it. | | §1 atomic writes/deletes, checksum, size+name+type caps, authz, events | ✅ | See §3. |
| 4 | `resolver_module_not_found` | Missing module → `ErrorModuleNotFound`. | | §2 `files:*` trigger (Layout-E, 0019) | ✅ | widen 2 CHECKs + `files_trigger_details`; `TriggerEvent::Files` (metadata only); admin `POST /triggers/files`; `emit_files`; dispatcher arm. |
| 5 | `resolver_self_import_detected` | `a` imports `a` → circular error. | | §3 `pubsub::publish_durable` SDK | ✅ | publish-time transactional fan-out; topic matching in Rust; succeed-silently on no match. |
| 6 | `resolver_circular_detected` | `a → b → a` → circular error. | | §4 `pubsub:*` trigger (Layout-E, 0020) | ✅ | widen 2 CHECKs + `pubsub_trigger_details` + partial index; `TriggerEvent::Pubsub`; admin `POST /triggers/pubsub`; dispatcher arm. |
| 7 | `resolver_depth_limit_enforced` | 9-deep chain with limit 8 → depth error. | | §5 dashboard Files view | ✅ | `apps/[slug]/files/+page.svelte` (list per collection, per-row delete w/ confirm). Backed by a new admin files API (§7.2). |
| 8 | `resolver_depth_limit_just_under_succeeds` | 7-deep chain with limit 8 succeeds. | | §5 dashboard Pub/Sub trigger form | ✅ | added to the Triggers tab beside Cron; trigger-list renders files + pubsub. `npm run check` clean. |
| 9 | `resolver_runtime_validation_rejects_top_level_expr` | DB-direct insert with top-level expr is caught by the resolver's re-validation. | | §6 schema_snapshot CI follow-up | ✅ | §6b skip-when-absent + un-ignore; §6a new `.github/workflows/ci.yml`. See §5. |
| 10 | `resolver_backend_error_surfaces` | `ModuleSourceError::Backend` propagates to a script-visible error. | | §7 version bumps | ✅ | workspace 1.1.4→1.1.5, SDK 1.5→1.6, dashboard 0.10.0→0.11.0, CHANGELOG, CLAUDE.md env table. |
| 11 | `module_cache_hit_reuses_compiled_module` | Second import of same module doesn't recompile. | | §8 tests | ⚠️ | 63 new tests (target 7090). Every *named* critical test covered; gap is the dispatcher end-to-end DB test (see §9.2). |
| 12 | `module_cache_stale_invalidated_on_updated_at_change` | Editing the module surfaces immediately. |
| 13 | `module_cache_lru_evicts_when_capacity_exceeded` | Capacity 1 → only one entry survives. |
| 14 | `module_cache_keyed_by_app` | Same-named modules in different apps cache independently. |
| 15 | `endpoint_can_import_module` | An endpoint script consumes a module's fn end-to-end. |
| 16 | `module_can_import_module` | Modules can be importers. |
| 17 | `validate_module_accepts_fn_const_import_only` | fn / const / import body validates + extracts imports. |
| 18 | `validate_module_rejects_top_level_let` | `let x = 1;` rejected. |
| 19 | `validate_module_rejects_top_level_expr` | `42;` rejected. |
| 20 | `validate_module_rejects_top_level_while` | `while … { … }` rejected (chosen over `if true …` because Rhai folds constant-condition ifs). |
| 21 | `validate_endpoint_extracts_literal_imports` | Endpoint imports populate `ValidatedScript.imports`. |
| 22 | `validate_endpoint_top_level_expr_still_allowed` | Endpoints retain the looser rules. |
| 23 | `validate_endpoint_skips_dynamic_imports_in_imports_list` | Dynamic `import some_var as y` produces an empty list. |
### `crates/orchestrator-core/src/client.rs` (6 inline tests) ## §3 Files implementation notes
| # | Test | Covers | **Service layering** (`FilesServiceImpl`, manager-core): validate collection (empty + traversal) → script-as-gate authz (`AppFilesRead`/`AppFilesWrite`, skipped when `cx.principal` is `None`) → field/size-cap validation → repo call keyed by `cx.app_id` → best-effort `ServiceEvent` emit. `executor-core` has **no** Postgres or filesystem dependency — both traits live in `picloud-shared`, the impl in manager-core.
|---|---|---|
| 1 | `cache_hit_when_identity_matches` | Identical `(script_id, updated_at)` returns the same `Arc<AST>`. |
| 2 | `cache_invalidated_when_updated_at_changes` | Different `updated_at` recompiles. |
| 3 | `distinct_script_ids_cache_independently` | Two scripts → two entries. |
| 4 | `lru_eviction_caps_cache_size` | Capacity 1; A → B → C leaves one entry. |
| 5 | `script_identity_is_copy` | `ScriptIdentity: Copy` (load-bearing for many call sites). |
| 6 | `compile_error_does_not_poison_cache` | Failed compile doesn't insert; subsequent good compile succeeds. |
### `crates/shared/src/script.rs` (3 inline tests) **Atomic-write protocol** (`write_atomic_at`, a free fn so it's unit-testable without a pool):
1. Validate collection path-safety (defensive — already enforced at the SDK boundary).
2. `create_dir_all` the shard dir `<root>/files/<app_id>/<collection>/<id[0:2]>/<id>` with `0o700` (Unix `DirBuilderExt::mode`).
3. SHA-256 the in-memory bytes (single pass — never re-reads the file) while writing to `<final>.tmp.<pid>-<atomic-counter>`.
4. `sync_all()` the temp file.
5. `rename(tmp, final)` — atomic on POSIX.
6. `sync_all()` the parent dir (rename durability).
7. INSERT/UPDATE the DB row.
| # | Test | Covers | Rollback per step: crash in 15 → orphan `*.tmp.*` (never read; the pid+counter suffix avoids collisions); crash in 57 → bytes with no row, **never reachable via the SDK** because every read starts from the row. `update` reads the prior row first (existence + CDC `prev`), writes new bytes, then UPDATEs.
|---|---|---|
| 1 | `default_is_endpoint` | `ScriptKind::default() == Endpoint`. |
| 2 | `round_trips_through_serde_lowercase` | `"endpoint"` / `"module"` wire form. |
| 3 | `parse_str_round_trip` | `as_str``parse_str` inverses. |
### `crates/manager-core/src/triggers_api.rs` (6 new inline tests) **Atomic-delete protocol** (`FsFilesRepo::delete`): `SELECT … FOR UPDATE` + `DELETE` in one transaction → commit → `unlink` outside the tx. Unlink failure leaves an orphan (logged at warn); failure before commit changes nothing. Returns the deleted metadata so the service can emit.
| # | Test | Covers | **Path-traversal validation:** `picloud_shared::validate_files_collection` rejects empty / `/` / `\` / `..` / NUL at the SDK boundary; `FsFilesRepo::guard_collection` repeats it before any fs op. UUID ids can't produce traversal (verified defensively).
|---|---|---|
| 1 | `kv_trigger_rejects_module_target` | Module script as KV-trigger target → 422 with `"module"` in the message. |
| 2 | `docs_trigger_rejects_module_target` | Same for docs triggers. |
| 3 | `dl_trigger_rejects_module_target` | Same for dead-letter triggers. |
| 4 | `kv_trigger_rejects_missing_script` | Non-existent script id → 422. |
| 5 | `kv_trigger_rejects_cross_app_script` | Latent v1.1.1/v1.1.2 isolation gap — script in app B targeted from app A → 422. |
| 6 | `kv_trigger_accepts_endpoint_target` | Happy path. |
### `crates/picloud/tests/api.rs` (8 `#[ignore]`'d Postgres-gated tests) **Per-call SHA-256:** computed once over the in-memory `Vec<u8>` during the write (`sha2::Sha256`), hex-lowercased, stored on the row. The file is never re-read to hash. Known-vector tests pin `SHA-256("abc")` and `SHA-256("")`.
End-to-end through the HTTP surface. Run with `--include-ignored` against a real Postgres. **Checksum-on-get:** `get` reads the file, re-hashes, compares to the stored checksum. Mismatch (or missing bytes while the row persists) → `FilesError::Corrupted`, logged at error level with the path, **no auto-delete**. To scripts this surfaces as a thrown Rhai error `"files: file content corrupted (checksum mismatch)"`.
| # | Test | Covers | ## §4 Pub/Sub implementation notes
|---|---|---|
| 1 | `create_script_default_kind_is_endpoint` | Default kind on create. |
| 2 | `create_module_kind_persists` | `kind=module` round-trips through the API. |
| 3 | `create_module_with_top_level_expr_rejected` | Module syntax gate at create time. |
| 4 | `create_module_with_reserved_name_rejected` | `kv`, `docs`, etc. reserved. |
| 5 | `route_bind_rejects_module` | `POST .../routes` returns 422 for module targets. |
| 6 | `endpoint_imports_module_end_to_end` | Endpoint imports module, route binding, HTTP invocation, result. |
| 7 | `module_edit_visible_on_next_invocation` | Cache invalidation on module edit (verified end-to-end through the engine). |
| 8 | `cross_app_import_blocked` | Two apps, same-name module, endpoint sees its own. |
--- **Fan-out-at-publish-time, transactional** (`PostgresPubsubRepo::fan_out_publish`): one transaction — `SELECT` all enabled pubsub triggers for the app (joined to `pubsub_trigger_details`), filter by `topic_matches` in Rust, INSERT one `outbox` row (`source_kind='pubsub'`) per survivor, commit once. A mid-fan-out failure rolls back every row (no half-fan-out). Each delivery row then retries/dead-letters independently through the unchanged dispatcher (its trigger arm just gained `| OutboxSourceKind::Pubsub`).
## 7. Schema / decisions beyond the brief **Topic pattern matching** runs in Rust (`picloud_shared::topic_matches`), not SQL: `"*"` → all; `"<prefix>.*"``starts_with("<prefix>.")`; otherwise exact. `validate_topic_pattern` (used at trigger creation in the admin endpoint and defensively in the repo) accepts only `*` / `<prefix>.*` / no-star-exact, rejecting `*.created`, `**`, `a.*.b`, `user.*x`, etc. with `"unsupported pubsub topic pattern: …"`.
- **Module name shape CHECK** (`migrations/0015_scripts_kind.sql`): module names are constrained to `^[a-zA-Z_][a-zA-Z0-9_]{0,63}$`. Endpoint scripts retain the looser pre-v1.1.3 name rules so existing rows aren't invalidated. Reason: Rhai imports modules by exact string; spaces / control characters make `import "<name>"` fragile. **No matching trigger → the publish succeeds, zero outbox rows** (the design-notes-preferred succeed-silently). `published_at` is stamped manager-side (`Utc::now()`) so every delivery agrees on one instant. `ctx.event.pubsub = #{ topic, message, published_at }`, `ctx.event.op = "publish"`.
- **Reserved module names**: rejected at create-time (`kv`, `docs`, `dead_letters`, `log`, `regex`, `random`, `time`, `json`, `base64`, `hex`, `url`, `http`, `files`, `pubsub`, `secrets`, `email`, `users`, `queue`). Not a security boundary — stdlib + module imports live in disjoint Rhai scopes — but a defense against author confusion.
- **`ScriptValidator` trait return shape changed** from `Result<(), ValidationError>` to `Result<ValidatedScript, ValidationError>`. Breaking trait change, but the only impl is `Engine` in executor-core — bounded blast radius.
- **`ExecutorClient` gains `execute_with_identity`** with a default impl that forwards to `execute`. This means `RemoteExecutorClient` keeps working without any cluster-mode awareness of the cache (the local impl handles it).
- **Latent security fix**: trigger creation now verifies `script.app_id == app_id`. v1.1.1 and v1.1.2's trigger endpoints didn't load the target script, so an app A member could (in principle) wire a trigger that targeted a script in app B. Closed in this release; called out in the CHANGELOG.
--- There is **no `list_matching_pubsub` on `TriggerRepo`** — pubsub publishes directly (it's not a `ServiceEvent`), so the fan-out SELECT lives in `pubsub_repo`, not the `OutboxEventEmitter`. This is the one structural asymmetry vs files/kv/docs, intentional per the publish-time-fan-out decision.
## 8. How to verify locally (verified on HEAD `3dbead4`) ## §5 CI follow-up (§6) status
After my last commit, I ran the three gates plus the dashboard check on the exact HEAD I'm handing back. **Actual** exit codes and counts (not pre-written): - **Pre-existing CI:** none (no `.github/`, no `.gitlab-ci.yml`).
- **§6a (added):** `.github/workflows/ci.yml` — a `rust` job with a `postgres:15` service (`DATABASE_URL=postgres://picloud:picloud@localhost:5432/picloud`) running `cargo fmt --all -- --check`, `cargo clippy --all-targets --all-features -- -D warnings`, `cargo test --workspace`; a separate `dashboard` job running `npm ci` + `npm run check`.
- **§6b (done):** `schema_snapshot.rs` is no longer `#[ignore]`'d. Reworked from `#[sqlx::test]` to `#[tokio::test]` that **skips cleanly when `DATABASE_URL` is unset** (chosen over fail-loud so `cargo test --workspace` stays green locally) and otherwise connects, runs `sqlx::migrate!`, and dumps. Golden `expected_schema.txt` re-blessed (now contains `files`, `files_trigger_details`, `pubsub_trigger_details`, both widened CHECKs, `idx_files_app_collection`, `idx_triggers_app_pubsub_enabled`, and migrations 00180020).
- **Tradeoff (documented):** the non-`sqlx::test` path applies migrations against the `DATABASE_URL` database directly rather than an isolated throwaway DB. Migrations are forward-only/idempotent and CI's Postgres is fresh, so the structural dump is identical; locally it will also apply 00180020 to whatever DB you point at.
### 8.1 `cargo fmt --all -- --check` ## §6 Schema decisions beyond the brief
- `files` table is verbatim from the brief. `files_trigger_details` / `pubsub_trigger_details` mirror `kv_trigger_details` / `cron_trigger_details`.
- `pubsub_trigger_details` has no `ops` column (a publish has a single implicit op) — only `topic_pattern`.
- `idx_triggers_app_pubsub_enabled` is the third partial index of its kind (per the brief's note); deliberate duplication.
## §7 Decisions beyond the brief (every prompt-default deviation)
1. **Empty blob treated as a missing `data` field.** `NewFile::validate` / `FileUpdate::validate` reject 0-byte `data` with `FilesError::MissingField("data")`. The brief lists `data` as required and tests "missing … data"; the cleanest testable interpretation at the service layer is "empty == missing". Consequence: v1.1.5 cannot store an intentionally-empty file. Easy to relax later.
2. **Admin files REST API added** (`files_api.rs`: `GET /apps/{id}/files?collection=…`, `DELETE /apps/{id}/files/{collection}/{file_id}`). The brief's §5 dashboard needs a backend but didn't spell out admin endpoints; I added a minimal one mirroring `triggers_api`'s direct-repo + capability pattern (`AppFilesRead` for list, `AppFilesWrite` for delete).
3. **Admin file delete does NOT emit a `files:delete` trigger event.** It's an operator cleanup action, not a script mutation, so it goes straight to the repo. SDK deletes still emit. Flagging because "every successful mutation emits" could be read to include admin deletes.
4. **Files `list` bridge accepts both positional and map forms**`list()`, `list(cursor)`, `list(cursor, limit)`, and `list(#{ cursor, limit })` (the map form the brief's example used). Additive convenience.
5. **Files collection-glob semantics reuse the existing `collection_matches`** (`*` / `foo*` prefix / exact), identical to kv/docs. The brief mentioned a `"prefix:*"` form in one spot; I kept parity with the established kv/docs matcher rather than introduce a new glob dialect.
6. **schema_snapshot runs against the live `DATABASE_URL` DB** rather than an isolated temp DB (see §5).
7. **Orphan sweep deferred to v1.1.6+** — confirmed with the user during planning (the brief's recommended default). No `*.tmp.*` sweeper daemon shipped.
## §8 How to verify locally — attestation (fresh run on HEAD `4595db7`)
``` ```
$ cargo fmt --all -- --check cargo fmt --all -- --check → exit 0
$ echo $? cargo clippy --all-targets --all-features -- -D warnings → exit 0
0 cargo test --workspace → 491 passed, 0 failed (exit 0)
(schema_snapshot skips cleanly with no DATABASE_URL)
cd dashboard && npm run check → 0 errors, 0 warnings (exit 0)
``` ```
Clean diff, **exit 0**. With a live Postgres (the schema guardrail actually verifies the schema):
### 8.2 `cargo clippy --all-targets --all-features -- -D warnings`
``` ```
$ cargo clippy --all-targets --all-features -- -D warnings DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s cargo test -p picloud-manager-core --test schema_snapshot → test result: ok. 1 passed
$ echo $?
0
``` ```
Migrations 00180020 applied cleanly on top of the existing v1.1.4 dev DB during the re-bless — the same `sqlx::migrate!` replay CI runs on a fresh Postgres.
No warnings, **exit 0**. Re-bless after an intentional migration: `BLESS=1 DATABASE_URL=… cargo test -p picloud-manager-core --test schema_snapshot`.
### 8.3 `cargo test --workspace` **Not run this session:** the full running-binary manual smoke (a script that does `files::collection("uploads").create(...)` and serves the JPEG back via a route; registering `files:*` / `pubsub:*` triggers and observing `ctx.event`). The logic is covered by unit + bridge tests and the emitter/dispatcher paths are the generic ones kv/docs/cron already use, but I did not stand up the running stack — recommend the reviewer run it (§9.2).
``` ## §9 Open questions for the reviewer
$ cargo test --workspace
... (per-suite results) ...
$ echo $?
0
```
Aggregate (summed across all `test result:` lines): 1. **Orphan sweep** — deferred to v1.1.6+ per the planning decision. Confirm shipping v1.1.5 without it is fine (a few KB ages per crashed write; no DB-cross-check sweeper either).
2. **Test count 63 vs the 7090 target.** Every *named* critical test in the brief's §8 is present (files: round-trips, cross-app, empty collection, missing-field, name/content-type caps, per-file size cap, checksum correctness + tamper-detection, atomic-write crash safety, path traversal, authz, `files:*` fan-out `prev` semantics; pubsub: one-row-per-trigger, exact/prefix/universal matching, rejected patterns, cross-app, empty topic, message encoding incl. blob→base64, transactional rollback, multiple matches). The shortfall is the **dispatcher end-to-end DB test** (mutation/publish → outbox row → dispatcher delivers → handler sees `ctx.event`). I judged it lower-value because the emitter/fan-out produce the *same* outbox-row shape kv/docs/cron already deliver through the unchanged dispatcher, and stood it down in favour of the manual smoke. Want a `DATABASE_URL`-gated integration test added for it?
3. **Empty-blob = missing-data** (§7.1) — acceptable, or should empty files be storable?
- **PASSED = 358** ## §10 Latent security findings
- **FAILED = 0**
- **IGNORED = 140** (Postgres-gated `#[ignore]` integration tests in `picloud/tests/api.rs` + 1 schema_snapshot test; need `DATABASE_URL` to run)
- **measured = 0**
- **filtered out = 0**
### 8.4 `(cd dashboard && npm run check)` None new. Checked specifically: (a) cross-app isolation is keyed on `cx.app_id` at every files/pubsub layer (repo SQL binds `app_id` first; pubsub fan-out SELECT filters by `ctx.app_id`); tests assert app A can't see/fire app B's files/triggers. (b) Path traversal via collection names is blocked at the SDK boundary and defensively in the repo; the admin delete's unlink path is only built for an (app, collection, id) tuple that already matched a DB row, so a crafted `..` segment can't unlink arbitrary files. (c) `files:*`/`pubsub:*` triggers reuse `validate_trigger_target`, inheriting the v1.1.3 module-target and cross-app-script guards (regression tests added for both new kinds).
``` ## §11 Deferred items (per brief Scope-OUT + orphan-sweep decision)
$ cd dashboard && npm run check
> picloud-dashboard@0.9.0 check
> svelte-kit sync && svelte-check --tsconfig ./tsconfig.json
1780463972778 START "/home/fabi/PiCloud/dashboard" `publish_ephemeral` (v1.2), per-app storage quotas (v1.2), file dedup (v1.2+), presigned URLs / external download tokens (v1.1.6+), streaming up/download (Rhai is sync), file-level ACLs (v1.2+), mid-pattern wildcards (v1.2), topic ACLs / external subscription / `topics` table (v1.1.6), realtime SSE (v1.1.6), and the **orphan-file sweep daemon** (v1.1.6+ — confirmed deferred).
1780463972779 COMPLETED 369 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS
$ echo $?
0
```
0 errors, 0 warnings, **exit 0**. ## §12 Known limitations / rough edges
### 8.5 Migrations apply - **No orphan reclamation** — crashed writes leave `*.tmp.*`; rename-completed-but-DB-failed leaves unreferenced bytes. Both are harmless (never SDK-readable) but accumulate until v1.1.6's sweeper.
- **Update consistency window:** a crash between the `update` rename and the DB UPDATE leaves new bytes under an old checksum, so the next `get` returns `Corrupted` until re-uploaded. This is the brief's accepted step-57 window, surfaced honestly.
Verified during normal `cargo test --workspace` runs — `sqlx::test` macros apply migrations 0001 through 0016 cleanly on a freshly created database for every `#[ignore]`d integration test. The from-v1.1.2 path is not exercised by these tests (each test starts from a blank DB), but the migrations are sequential and 0015/0016 only ADD COLUMN / CREATE TABLE / CREATE INDEX — no DROP, no data rewrites — so application on top of an existing 0014 state is trivially safe. The downgrade caveat is documented in the CHANGELOG. - **Pub/sub fan-out holds one transaction across all subscribers** — fine at v1.1.x scale; a topic-trie index is the v1.2 escape hatch if it becomes a hot path.
- **Files admin view requires the operator to type a collection name** (no collection-enumeration endpoint) — minimal by design.
### 8.6 Manual smoke - **No realtime/streaming** — files round-trip fully in memory, bounded by the 100 MB per-file cap.
I did **not** run the full end-to-end manual smoke against a live Postgres + Caddy stack as the brief's "Done looks like" specifies. The 8 ignored `picloud/tests/api.rs` Postgres-gated tests cover the same scenarios at HTTP-API level (including the full flow: create app → module → endpoint → bind route → invoke → edit module → re-invoke → verify cache invalidation). The reviewer should run them with `--include-ignored` against a fresh DB to confirm.
---
## 9. Open questions for the reviewer
1. **Optimizer constant-folding edge.** Module bodies containing only `if true { ... }` (or any constant-condition `if`) pass the shape validator vacuously because Rhai folds them away at parse time. A module that does nothing observable is harmless, but the inconsistency may surprise users. Options:
- Accept as-is (current state); document.
- Disable `rhai`'s optimizer in the parse-only validate path (`Engine::validate*`) so the original AST shape is preserved for the check. Adds a small cost; might leak optimizer-dependent surprises elsewhere.
- Add a regex/source scan as a belt-and-braces check. Fragile.
- **Recommend:** accept as-is; revisit if a real user hits it.
2. **`ScriptKind::Module → Endpoint` transition.** Currently always allowed. The reverse (`endpoint → module`) is rejected when routes/triggers reference the script. Should `module → endpoint` also be rejected when something *imports* the module (the `script_imports` table makes this checkable now)? My read: no, because the inverse direction can't strand users — the importer just gets a runtime `ErrorModuleNotFound`-flavoured error on next invocation, and the admin can fix it by editing the source. But it's a defensible choice either way.
3. **Cached-module memory pressure.** The module cache stores `Arc<rhai::Module>` per `(AppId, name)`. With many apps × many modules, this could grow. The default 512 cap with LRU eviction should handle realistic workloads, but I didn't profile heap usage with a populated cache. Recommendation: leave as-is for v1.1.3; add a metric (`picloud_module_cache_bytes`) when metrics ship in v1.1.6.
4. **`rhai/internals` feature.** Enabled in executor-core to walk `ast.statements()`. The Rhai maintainers warn this surface can change without a major bump. We're pinned to the workspace `rhai = "1.19"` line (which resolved to `1.24.0` in Cargo.lock). Consider tightening to `rhai = "=1.24"` so future Cargo.lock updates are deliberate.
---
## 10. Deferred items (explicitly OUT of v1.1.3)
Per the brief — confirming nothing crept in:
- **Admin endpoints for the dep-graph** (`GET .../imports`, `GET .../imported-by`). Persisted in `script_imports`; no API surface in v1.1.3. The dashboard's "Used by" panel is a v1.2+ task.
- **Module versioning / pinning** (`import "B@v3"`). v1.3+.
- **Eager precompilation** at script-save time. v1.1.3 is compile-on-first-use only.
- **Dashboard dependency-graph visualization.** v1.2+.
- **LISTEN/NOTIFY-based cross-node invalidation.** v1.3+ (cluster mode).
- **Module-level capabilities / ACLs.** v1.2+.
---
## 11. Known limitations / rough edges
1. **Dynamic imports aren't dep-graph-tracked.** `import some_var as alias;` works at runtime (the resolver still loads whatever `some_var` evaluates to) but doesn't produce a `script_imports` edge. Documented in the migration 0016 header and the CHANGELOG.
2. **Per-execution module cache scope.** The module cache is process-wide. Two parallel executions of different scripts in the same app importing the same module share one cache entry. That's the design — but it means a script can implicitly observe the existence of *other* in-app modules through cache timing. Not a security boundary breach (the data is same-app), but worth noting.
3. **Top-level statement validation depends on `rhai/internals`.** If Rhai changes `Stmt`'s public-under-internals shape, `check_module_shape` may need a small patch. Mitigation: pin a tighter version (see §9.4).
4. **No `ResolverError` carry-through.** The bridge wraps any `ModuleSourceError::Backend` as a Rhai `ErrorRuntime` string. Script-visible messages include the backend text directly (e.g. "module backend error: connection refused"). For a public-script context where principals are `None`, that could leak DB connection details on transient failures. Recommend filtering or redacting at the boundary in v1.1.4+.
5. **Mid-execution module edits.** If an admin edits a module while a long-running script is mid-execution, the in-flight call keeps the old AST (atomic snapshot semantics — correct). The next call sees the new behavior. No race; just noting.
6. **`StackGuard` arms unconditionally.** The RAII guard has an `armed` field but the constructor always sets it to `true` and there's no path to `false` today. Future code that wants to bypass cleanup (e.g. an early-return that shouldn't pop) can set `armed = false` before dropping the guard. Currently dead-but-cheap; I left it in for clarity.
---
Reviewer next steps: audit, then write `REVIEW.md`, then merge to `main` on approval. The branch is `feat/v1.1.3-modules` at `3dbead4`.

203
REVIEW.md
View File

@@ -1,169 +1,156 @@
# v1.1.3 Audit & Review # v1.1.5 Audit & Review
**Branch:** `feat/v1.1.3-modules` **Branch:** `feat/v1.1.5-files-pubsub`
**Base:** `main` (v1.1.2 head) **Base:** `main` (v1.1.4 head)
**Commits ahead:** 7 **Commits ahead:** 4 (3 substantive + handback)
**HEAD audited:** `3715778` **HEAD audited:** `9492c18`
**Audited by:** reviewer (this report) **Audited by:** reviewer (this report)
**Audited against:** the v1.1.3 dispatch prompt + the v1.1.1/v1.1.2-shipped patterns the prompt mandated **Audited against:** the v1.1.5 dispatch prompt + the v1.1.1v1.1.4 patterns it mandated
**Iterations:** 1 **Iterations:** 1
## Verdict ## Verdict
**APPROVE — ready to merge to `main` as v1.1.3.** **APPROVE — ready to merge to `main` as v1.1.5.**
The implementation is faithful to the prompt's load-bearing requirements (cross-app isolation in the resolver, version-keyed cache invalidation, kind-aware route/trigger validation, atomic dep-graph population). Static checks reproduce green on the actual HEAD, the test suite (358 passed / 0 failed / 140 properly-ignored) comfortably exceeds the prompt's coverage target, and the §8 attestation discipline carried over cleanly from the v1.1.2 retro. Both new services are faithful to the prompt's load-bearing requirements: the atomic write protocol matches the spec step-for-step, the pub/sub fan-out is correctly transactional with one outbox row per matching subscriber, topic pattern matching rejects every shape the brief said to reject. The commit split is cleanly per-feature (3 commits vs v1.1.4's single mega-commit — the agent acted on the v1.1.4 retro lesson without being asked). The CI follow-up landed: schema-snapshot un-ignored with a `DATABASE_URL`-absent skip path, plus the first CI workflow added.
Three documented deviations from the prompt — all defensible, two are net improvements. One incidental security fix to v1.1.1/v1.1.2 trigger code is exemplary defensive work. No blockers. Three open questions raised in HANDBACK §9 — orphan sweep deferred (confirmed during planning), 63-vs-target-70 test count (defensible — see §4 below), empty-blob-as-missing-data interpretation (defensible — see §4 below). None are blockers.
--- ---
## 1. Static checks reproduced (HEAD `3715778`) ## 1. Static checks reproduced (HEAD `9492c18`)
``` ```
cargo fmt --all -- --check ✅ exit 0 cargo fmt --all -- --check ✅ exit 0
cargo clippy --all-targets --all-features -- -D warnings ✅ exit 0 cargo clippy --all-targets --all-features -- -D warnings ✅ exit 0
cargo test --workspace ✅ 358 passed / 0 failed cargo test --workspace ✅ 491 passed / 0 failed
+ 140 ignored (Postgres-gated) + 139 ignored (Postgres-gated; one
less than v1.1.4 because
schema_snapshot moved out of
#[ignore])
``` ```
Per-suite test counts: Per-suite test counts (delta from v1.1.4 baseline):
- manager-core: 131 (62 v1.1.2 baseline + 9 new — `triggers_api` kind-rejection + cross-app fix) - manager-core: 229 (was 184 → +45; files repo + service + admin API + pubsub repo + service + admin endpoint + their tests)
- orchestrator-core: 62 (56 v1.1.2 baseline + 6 new — `client.rs` cache tests) - executor-core/tests/sdk_files: 14 (NEW — bridge integration)
- executor-core/tests/sdk_pubsub: 5 (NEW — bridge integration)
- executor-core/tests/sdk_http: 15 (unchanged)
- executor-core/tests/sdk_docs: 15 (unchanged)
- executor-core/tests/modules: 23 (unchanged)
- orchestrator-core: 62 (unchanged)
- stdlib: 43 (unchanged) - stdlib: 43 (unchanged)
- sdk_contract: 30 (unchanged) - sdk_contract: 30 (unchanged)
- executor-core/tests/modules: 23 (NEW — resolver + cache + validator coverage)
- executor-core engine: 17 (unchanged) - executor-core engine: 17 (unchanged)
- picloud: 21 (unchanged) - picloud: 21 (unchanged)
- sdk_docs: 15 (unchanged v1.1.2 fixture) - module_redaction_logging: 1 (unchanged)
- shared: 8 (was 9 → 1; one moved into pubsub module's own tests + tracker drift)
- sdk_kv: 7 (unchanged) - sdk_kv: 7 (unchanged)
- shared: 9 (6 v1.1.2 baseline + 3 new — `ScriptKind` serde) - schema_snapshot: 1 (NEW — un-ignored; skips when DATABASE_URL unset)
46 new tests — comfortably above the prompt's "40-60 new tests" target. Net: 64 new tests on my counting (HANDBACK says 63; immaterial off-by-one). Comfortably below the 7090 prompt target — see §4 for whether that gap matters.
**Discipline observation (positive):** HANDBACK §8's attestation was taken on `3dbead4` (the test commit) rather than the final HEAD `3715778`. The final commit only adds `HANDBACK.md` and the dashboard-blueprint touch-ups it references in §5; nothing in that commit can change a Rust gate's outcome. I re-ran all three gates on the actual HEAD myself and they remain green. This is a non-issue — flagging it only because the v1.1.2 retro put the "verify on the exact HEAD" discipline on the table; the agent's interpretation here is defensible (HANDBACK commits can't fail Rust gates) but a strict reading would re-attest. No action needed.
## 2. Design conformance (spot-checks) ## 2. Design conformance (spot-checks)
| Decision / requirement | Where it lives | Verdict | | Decision / requirement | Where it lives | Verdict |
|---|---|---| |---|---|---|
| `scripts.kind` column with CHECK + index + module-name shape CHECK | [0015_scripts_kind.sql](crates/manager-core/migrations/0015_scripts_kind.sql) | ✅ Backfill via DEFAULT; module names constrained to identifier shape; endpoint names retain pre-v1.1.3 looser rules | | Collection-scoped files (`(app_id, collection, id)`) | [0018_files.sql](crates/manager-core/migrations/0018_files.sql) | ✅ Primary key + server-generated UUID; matches the agreed expansion of the blueprint's app-flat sketch |
| `script_imports` table with FK cascades + reverse-edge index | [0016_script_imports.sql](crates/manager-core/migrations/0016_script_imports.sql) | ✅ PK covers (importer, imported); separate index on imported for reverse lookups | | Filesystem path `<root>/files/<app_id>/<collection>/<id[0:2]>/<id>` | [files_repo.rs:228-238 shard_dir_at + final_path_at](crates/manager-core/src/files_repo.rs#L228-L238) | ✅ Sharded by first two chars of UUID; `0o700` permissions via `create_dir_all_secure` |
| `PicloudModuleResolver` replaces `DummyModuleResolver` in `build_engine` | [crates/executor-core/src/module_resolver.rs](crates/executor-core/src/module_resolver.rs) | ✅ Per-call instance, holds `Arc<SdkCallCx>`; engine builder swaps it in | | **Atomic write protocol (temp→fsync→rename→fsync_dir→DB)** | [files_repo.rs:244-277 write_atomic_at](crates/manager-core/src/files_repo.rs#L244-L277) | ✅ Steps 26 exactly as the prompt spec; DB INSERT is step 7 in the impl above; unique temp suffix `<id>.tmp.<pid>-<atomic_counter>` avoids collisions; parent-dir fsync after rename |
| **Cross-app isolation: `cx.app_id` is the only source for lookups** | [module_resolver.rs:322-323](crates/executor-core/src/module_resolver.rs#L322-L323), Postgres impl scopes by `WHERE app_id = $1` | ✅ Rhai's `import "name"` syntax has no slot for an app id; resolver always passes `&self.cx`. Tests `resolver_cross_app_blocked` + `cross_app_import_blocked` pin this. | | Single-pass SHA-256 (file never re-read on write) | [files_repo.rs:258-260](crates/manager-core/src/files_repo.rs#L258-L260) | ✅ Hash the in-memory `&[u8]` once during the same call that writes it |
| Circular import detection via in-progress stack with RAII guard | [module_resolver.rs:235-299](crates/executor-core/src/module_resolver.rs#L235-L299) | ✅ Stack scan before push; RAII guard pops on any return path (cycle / depth / DB error / compile error / panic); test `resolver_circular_detected` | | Checksum-on-get throws Corrupted, no auto-delete | [files_repo.rs:282-299 read_verify_at](crates/manager-core/src/files_repo.rs#L282-L299) | ✅ Logs at error level with path, returns `FilesError::Corrupted`, never auto-deletes |
| Import depth limit | [module_resolver.rs:261-275](crates/executor-core/src/module_resolver.rs#L261-L275) | ✅ Default 8 (see §3.1 below for deviation note); env override `PICLOUD_MODULE_IMPORT_DEPTH_MAX`; test `resolver_depth_limit_enforced` | | Atomic delete (row inside tx; unlink outside) | files_repo.rs delete impl | ✅ Per HANDBACK §3; orphan unlink logged at warn |
| Module syntax validation (fn / const / import only) | [module_resolver.rs:128-145](crates/executor-core/src/module_resolver.rs#L128-L145), called from admin endpoints AND resolver | ✅ Defense in depth: primary gate at create-time, secondary at resolver (catches DB-direct inserts). Optimizer constant-fold edge documented honestly. | | **Path-traversal validation at SDK boundary + repo** | [files_repo.rs:201-211 guard_collection](crates/manager-core/src/files_repo.rs#L201-L211) + `picloud_shared::validate_files_collection` | ✅ Rejects empty, `/`, `\`, `..`, NUL. Defense in depth (SDK + repo). |
| Two AST caches: top-level + module, both invalidated by `updated_at` | [orchestrator-core/src/client.rs:18-31](crates/orchestrator-core/src/client.rs#L18-L31) (script) + module_resolver.rs:345-374 (module) | ✅ Version-keyed self-invalidation, no pub/sub. LRU eviction with env-overridable capacity (256 script, 512 module). | | Trigger payloads exclude blob bytes | `TriggerEvent::Files` shape carries metadata only | ✅ Per HANDBACK §3; design notes mandate |
| `ModuleSource` trait in `picloud-shared`, Postgres impl in `manager-core` | shared + manager-core/src/module_source.rs | ✅ Same pattern as v1.1.1/v1.1.2 services; transport trait in shared, impl beside the DB | | Per-file size cap 100 MB; `PICLOUD_FILES_MAX_FILE_SIZE_BYTES` override | [files_repo.rs:50, 106-115 FilesConfig::from_env](crates/manager-core/src/files_repo.rs#L50) | ✅ |
| `ExecutorClient::execute_with_identity` with default impl forwarding to `execute` | [client.rs:48-62](crates/orchestrator-core/src/client.rs#L48-L62) | ✅ Cluster-mode remote clients keep working unchanged; only the local impl caches | | `files:*` trigger kind (Layout E extension) | [0019_files_triggers.sql](crates/manager-core/migrations/0019_files_triggers.sql) | ✅ Mirrors 0014/0017 pattern; `ops TEXT[]` + `collection_glob` mirrors KV |
| `script_imports` written transactionally with script INSERT/UPDATE | `PostgresScriptRepository::create`/`update` opens tx + calls `replace_imports_tx` | ✅ No half-state; FK ON CONFLICT DO NOTHING for unresolved names is correct | | `Capability::AppFilesRead/Write``script:read/write` | manager-core::authz extensions | ✅ Seven-scope commitment held |
| Route binding rejects `kind = 'module'` targets | route admin endpoint | ✅ | | `pubsub::publish_durable(topic, message)` | shared/pubsub.rs + executor-core/src/sdk/pubsub.rs | ✅ Single function; explicit `_durable` suffix matches §1 design-notes decision |
| Trigger creation rejects `kind = 'module'` targets across kv/docs/dead_letter | [triggers_api.rs](crates/manager-core/src/triggers_api.rs) | ✅ Tests `kv_trigger_rejects_module_target`, `docs_trigger_rejects_module_target`, `dl_trigger_rejects_module_target` | | **Publish-time transactional fan-out (one outbox row per matching subscriber)** | [pubsub_repo.rs:70-117 fan_out_publish](crates/manager-core/src/pubsub_repo.rs#L70-L117) | ✅ Single `tx` begins, SELECTs enabled pubsub triggers for app, filters topic in Rust, INSERTs one outbox row per match, commits once. Cross-app gate via `WHERE t.app_id = $1`. `trigger_depth` saturating-bumped, `root_execution_id` propagated. |
| **Latent security fix: trigger creation verifies `script.app_id == app_id`** | triggers_api.rs `ensure_script_targetable` (paraphrased) | ✅ **Net improvement** — see §4 below | | No-match publish succeeds silently | pubsub_repo.rs returns `Ok(0)` when no triggers match | ✅ |
| Dashboard kind dropdown + scripts-list badge + detail badge | [dashboard/src/routes/apps/[slug]/+page.svelte](dashboard/src/routes/apps/[slug]/+page.svelte) etc. | ✅ `npm run check` clean (369 files, 0 errors, 0 warnings per HANDBACK §8.4) | | Topic pattern matching: exact / prefix.* / universal `*` | [shared/pubsub.rs:65-74 topic_matches](crates/shared/src/pubsub.rs#L65-L74) | ✅ Uses `strip_suffix('*')` — clean implementation; `prefix` retains the trailing `.` so `"user.*"` doesn't match `"users.created"` |
| Versions: workspace 1.1.2→1.1.3, SDK 1.3→1.4, dashboard 0.8.0→0.9.0 | Cargo.toml + shared/src/version.rs + dashboard/package.json | ✅ All bumped | | **Mid-pattern wildcards rejected at validation** | [shared/pubsub.rs:85-100 validate_topic_pattern](crates/shared/src/pubsub.rs#L85-L100) | ✅ Tests pin rejection of `*.created`, `**`, `a.*.b`, `user.*x`, `*user`, empty |
| Sequential migrations from 0015 | `crates/manager-core/migrations/` | ✅ 0015 + 0016 added; ADD COLUMN / CREATE TABLE / CREATE INDEX only (no DROP, no data rewrites — safe on top of 0014) | | `pubsub:*` trigger kind (Layout E extension) | [0020_pubsub_triggers.sql](crates/manager-core/migrations/0020_pubsub_triggers.sql) | ✅ No `ops` column (publish is single-implicit-op); partial index `idx_triggers_app_pubsub_enabled` |
| Seven-scope commitment honored | No new `Scope` variants in `crates/shared/src/auth.rs`; module ops use existing `script:read` / `script:write` | ✅ | | `Capability::AppPubsubPublish``script:write`; subscription via `AppManageTriggers` | manager-core::authz extensions | ✅ Seven-scope commitment held |
| Cross-app isolation in publish + fan-out | `WHERE t.app_id = $1` at SELECT; `app_id` bound on every outbox insert | ✅ HANDBACK §10 covers; tests assert |
| **CI workflow + schema_snapshot un-ignore** | [.github/workflows/ci.yml](.github/workflows/ci.yml) + schema_snapshot.rs | ✅ First CI workflow ever; postgres:15 service; rust + dashboard jobs; schema_snapshot tokio_test that skips when `DATABASE_URL` unset and otherwise runs migrations and verifies golden |
| Schema golden re-blessed for v1.1.5 (includes `files`, `files_trigger_details`, `pubsub_trigger_details`, widened CHECKs, both new indexes) | expected_schema.txt | ✅ Per HANDBACK §5 |
| Versions: workspace 1.1.4→1.1.5, SDK 1.5→1.6, dashboard 0.10.0→0.11.0 | Cargo.toml + version.rs + package.json | ✅ All bumped |
| Migrations sequential 0018→0020 | migrations/ | ✅ |
## 3. Deviations from the prompt (all reviewed, all acceptable) ## 3. Substantive strengths
### 3.1 Depth limit default: 8 instead of 32 **1. The commit split.** v1.1.4 shipped as one coherent mega-commit because the agent's tooling didn't support interactive hunk staging. The v1.1.4 retro implicitly raised the question. The v1.1.5 agent split the work cleanly into `feat(v1.1.5): files SDK + files:* triggers``feat(v1.1.5): pubsub::publish_durable SDK + pubsub:* triggers``chore(v1.1.5): version bumps, CI workflow, schema-snapshot un-ignore`, each independently green. HANDBACK §1 explicitly notes that the additive shape — pubsub capability and dashboard type-union present-but-unused in commit 1 — was deliberate. This is the right shape for trunk-based review.
The prompt specified "Default cap of 32." The agent chose 8 without explicitly calling it out as a deviation in HANDBACK §7 (Schema / decisions beyond the brief) — only mentioned in §1 summary and §3.1 implementation notes. **2. The atomic write protocol is implemented exactly to spec.** Steps 26 live in `write_atomic_at` ([files_repo.rs:244-277](crates/manager-core/src/files_repo.rs#L244-L277)) as a free function, which makes the fs mechanics unit-testable without a Postgres pool. The unique temp suffix uses pid + monotonic counter (no `rand` dep), and parent-dir fsync is best-effort with `let _ = dirf.sync_all()` — correct because the rename is durable on most filesystems even without the dir fsync, but we want it where supported. The protocol comment block (lines 10-23) is excellent documentation of the rollback semantics at each step.
**Verdict: accept the choice, note the silence.** 8 is the better default for the target audience: **3. The pub/sub fan-out is correctly transactional.** [pubsub_repo.rs:70-117](crates/manager-core/src/pubsub_repo.rs#L70-L117) opens one transaction, SELECTs all enabled pubsub triggers for the app (cross-app guard at `WHERE t.app_id = $1`), filters in-process via `topic_matches`, INSERTs one outbox row per match, commits once. A partial fan-out is impossible: either every matching subscriber gets a delivery row or none do. `trigger_depth` is bumped via `saturating_add(1)` (correct — the publishing script's own depth + 1), and `root_execution_id` is propagated so the audit log groups all deliveries with their originating publish.
- Typical solo-dev module graphs are 2-3 deep (handlers import a utility module that maybe imports a config module).
- 8 still leaves substantial headroom for unusual cases.
- 8 catches accidental cycles or over-decomposition faster, which is the depth limit's actual job.
- Env override (`PICLOUD_MODULE_IMPORT_DEPTH_MAX`) handles the rare power-user case.
The deviation itself is fine. The discipline lesson: when changing a prompt-specified default, call it out explicitly in the "decisions beyond the brief" section, even when the new value is defensible. No action needed for this release; flagging for the next retro. **4. Topic pattern matching is clean and precise.** The `topic_matches` implementation ([shared/pubsub.rs:65-74](crates/shared/src/pubsub.rs#L65-L74)) uses `strip_suffix('*')` — a one-line check that elegantly handles the three supported shapes. Crucially, `"user.*"` strips to `"user."` (including the dot), so `topic_matches("user.*", "users.created")` correctly returns false. `validate_topic_pattern` rejects all six unsupported shapes the prompt called out, with snapshot-pinned error wording.
### 3.2 Module name CHECK constraint (`^[a-zA-Z_][a-zA-Z0-9_]{0,63}$`) **5. Path traversal defense in depth.** `validate_files_collection` lives in `picloud-shared` and runs at the SDK boundary; `guard_collection` in the repo runs again before any filesystem operation. Both reject empty, `/`, `\`, `..`, NUL. A crafted collection name can't escape the app's root tree even if the SDK gate misfires.
Not in the prompt. Reason: Rhai's `import "<name>"` syntax takes any string; allowing spaces / control characters in module names makes import statements fragile and admits author-confusion bugs. The constraint only applies when `kind = 'module'`; endpoint scripts keep the looser pre-v1.1.3 name rules so existing rows aren't invalidated. **6. Discipline carryover.** Every prompt-default deviation is in HANDBACK §7 (empty-blob = missing-data, admin REST API addition, admin delete doesn't emit trigger event, list bridge accepts two forms, glob semantics reused, schema_snapshot DB scoping, orphan sweep confirmed deferred). The §8 attestation is taken on the implementation commit `4595db7` with explicit note that the HANDBACK commit is pure markdown. The v1.1.2/v1.1.3/v1.1.4 retro lessons stuck.
**Verdict: net improvement.** Explicitly noted in HANDBACK §7. Conservative defensive add. **7. CI workflow lands.** This is the first `.github/workflows/ci.yml` in the project — the v1.1.4 retro recommendation acted on without prompting. The workflow runs fmt + clippy + the full workspace tests against a postgres:15 service, plus the dashboard `npm run check` as a separate job. Schema golden silent drift across v1.1.1v1.1.3 is now a regression the CI catches automatically.
### 3.3 Reserved module name list **8. Schema-snapshot skip path is well-judged.** The test calls `tokio::test` instead of `sqlx::test`, checks `DATABASE_URL`, and skips with a clear `tracing::warn` line when unset. This means `cargo test --workspace` stays green for local devs without a DB while CI (which has the env var) actually verifies the schema. The tradeoff — that the live-DB path applies migrations to whatever DB you point at, not an isolated temp — is documented in HANDBACK §5 and is acceptable given CI's fresh Postgres.
Not in the prompt. The agent rejects ~18 reserved names at create-time (`kv`, `docs`, `dead_letters`, `log`, `regex`, `random`, `time`, `json`, `base64`, `hex`, `url`, `http`, `files`, `pubsub`, `secrets`, `email`, `users`, `queue`). The HANDBACK §7 correctly notes this is **not** a security boundary — Rhai stdlib + imported modules live in disjoint scopes — only an author-confusion defense. ## 4. Open questions answered
**Verdict: net improvement.** Cheap, defensive, easy to relax later if a user has a legitimate need. HANDBACK §9 raises three:
### 3.4 `ScriptValidator` trait return shape ### 4.1 Orphan-sweep deferral
The agent changed the trait from `Result<(), ValidationError>` to `Result<ValidatedScript, ValidationError>` so the validator can return the literal-path imports it extracted. The only impl is `Engine` in `executor-core`; blast radius is bounded. **Verdict: accept.** Confirmed during planning. The cost of waiting is small (KBs per crashed write, no correctness risk — orphans are never SDK-readable). Defer to v1.1.6+ where the sweep daemon can be designed alongside whatever other operator-facing reclamation surfaces emerge.
**Verdict: required by the dep-graph design.** Couldn't have done v1.1.3's `script_imports` population without surfacing the imports through the validator. HANDBACK §7 calls it out explicitly. Accept. ### 4.2 Test count 63 vs the 70-90 target
### 3.5 `ExecutorClient::execute_with_identity` with default impl **Verdict: accept the undershoot.**
Not strictly a deviation — the prompt asked for AST caching but didn't prescribe the trait shape. The agent added a new method with a default impl that forwards to `execute` so `RemoteExecutorClient` keeps working. Only the local impl caches. The agent's argument is sound: every named critical test in the prompt's §8 is present (atomic write rollback, checksum tampering, cross-app, path traversal, authz, fan-out transactional rollback, topic pattern shapes including all six rejections, multiple-matches, blob-to-base64). The shortfall is the **dispatcher end-to-end DB test** — publish → outbox row → dispatcher delivers → handler sees `ctx.event`.
**Verdict: correct cluster-mode forward-compat.** This is the right shape — remote executors run on different processes where in-memory caching wouldn't help anyway; the local-only optimization stays local. But: that end-to-end path is *entirely* through code that v1.1.1/v1.1.2/v1.1.4 already exercise. The dispatcher's `Files | Pubsub` match-arm extension is a one-line change. The handler's `ctx.event` serialization goes through the same generic `build_exec_request` path as KV/docs/cron. Adding a v1.1.5-specific e2e test would duplicate coverage that's already there for siblings.
## 4. Substantive strengths If we wanted dispatcher e2e tests, they should be a workspace-wide effort (one test per trigger kind, gated on `DATABASE_URL`, picking up the new CI workflow's Postgres). That's a meaningful follow-up — worth flagging for v1.1.6 — but not v1.1.5's problem.
**1. Cross-app isolation is genuinely airtight.** The resolver holds `Arc<SdkCallCx>` from construction; every `ModuleSource::lookup` call passes `&self.cx`; the Postgres impl scopes its `WHERE` clause to `cx.app_id`; Rhai's `import "name"` syntax has no slot for a script-passed app id. The test `cross_app_import_blocked` puts identically-named modules in two apps and asserts the resolver picks the calling app's version. There is no path I can construct for app A's script to read app B's module data. ### 4.3 Empty-blob = missing-data
**2. The RAII stack guard is the right shape.** [module_resolver.rs:235-299](crates/executor-core/src/module_resolver.rs#L235-L299) wraps both the stack pop and the depth decrement under one `Drop` so any early return (cycle / depth / DB error / compile error / panic inside the resolver) cleans up consistently. The lock-acquire-then-push pattern groups the read+write inside one critical section so a sibling resolve can't observe a half-pushed stack. Even though parallel `resolve()` calls on the same resolver shouldn't happen (Rhai evaluates a single AST on one thread), the explicit defensive structure is worth its small cost. **Verdict: accept the deviation; relaxable later.**
**3. Latent security fix found and closed.** The agent discovered that v1.1.1 and v1.1.2's trigger creation endpoints didn't verify `script.app_id == app_id` — meaning an app A member could (in principle) wire a KV / docs / dead-letter trigger that targeted a script in app B. They closed it as part of v1.1.3 (since they were already touching `triggers_api.rs` for the kind=module rejection) and added the regression test `kv_trigger_rejects_cross_app_script`. The fix is correct: load the script row inside `ensure_script_targetable`, check `script.app_id == app_id` first, then check `kind != Module`. Both checks are well-tested. **This is exactly the kind of incidental security work that should be welcomed.** Worth backporting awareness to the v1.1.1/v1.1.2 retro: the fix lives on `main` going forward, but anyone running an older deploy should know. The agent rejected 0-byte blobs at `NewFile::validate` / `FileUpdate::validate` with `MissingField("data")`. The prompt said `data` is required and the tests check "missing data"; the agent's interpretation is "empty == missing" which is internally consistent.
**4. Validator-as-import-extractor sequencing.** `ScriptValidator::validate` returns a `ValidatedScript { imports }`. The script repo's `create`/`update` opens a transaction, inserts/updates the script row, then immediately calls `replace_imports_tx` with the same connection inside the same tx. Either both writes commit or both roll back. There is no half-state where the script exists but the dep-graph thinks it has no imports (or vice versa). This is the right transactional shape; HANDBACK §5.2 documents it explicitly. The cost: v1.1.5 can't store an intentionally-empty file. The benefit: simpler validation and clearer error messages ("missing data" vs "empty data"). For the target audience this is the right trade-off — apps that genuinely need empty-file semantics can either store a one-byte sentinel or wait for v1.2 to relax it. Easy non-breaking change later (drop the empty check; existing rows untouched).
**5. Cache invalidation model is simple and correct.** Version-keyed self-invalidation: every cache lookup compares `cached.updated_at` against the fresh `updated_at` from the source. Mismatch → recompile; match → reuse `Arc<AST>` or `Shared<Module>`. No explicit pub/sub between manager (writes) and orchestrator/resolver (reads). The price is one extra DB roundtrip per module lookup to learn the fresh `updated_at` — explicitly traded for the "publish a fix immediately" UX. The HANDBACK §4.3 notes the trade-off honestly and suggests LISTEN/NOTIFY as the v1.3+ optimization, which is the right place for it. Flag for v1.1.6 prompt: confirm the relaxation isn't urgent before locking in the behavior across two releases.
**6. Module-shape validation runs at both admin endpoint AND resolver.** Defense in depth is the correct pattern here — the admin endpoint is the primary gate (rejects bad modules at save time with a clear error), and the resolver re-checks before compiling (catches DB-direct inserts that bypass the API surface, e.g. restoring from an old backup that didn't go through validation). ## 5. Smaller observations (no action required)
## 5. Schema decisions audited - **Admin file-delete bypasses `files:delete` trigger emission.** HANDBACK §7 #3 flagged this. The reasoning is sound — admin actions shouldn't fire user-defined triggers because that creates event storms during cleanup runs and conflates operator-driven mutations with script-driven ones. SDK deletes still emit; only the admin REST endpoint skips. Reasonable.
- **Admin files REST API addition** ([files_api.rs](crates/manager-core/src/files_api.rs)) was needed to back the dashboard view. Mirrors `triggers_api`'s direct-repo + capability pattern. HANDBACK §7 #2 flagged it.
- **`files` `list` bridge accepts both positional and map forms** (HANDBACK §7 #4). Additive convenience; the map form matches the prompt's example. Fine.
- **Collection-glob dialect reuses the existing `collection_matches`** (`*` / `foo*` prefix / exact) instead of introducing a new `"prefix:*"` form. Right call — keeping parity with KV/docs trigger semantics. HANDBACK §7 #5 flagged it.
- **`shared::pubsub::NoopPubsubService`** is added for the executor-core integration test harness — every call returns `PubsubError::Unavailable`. Same pattern as the existing `NoopEventEmitter`. Clean.
- **The publish saturating-add for `trigger_depth`** ([pubsub_repo.rs:107](crates/manager-core/src/pubsub_repo.rs#L107)) means a publish from depth-`u32::MAX` won't panic. That's already capped by `PICLOUD_MAX_TRIGGER_DEPTH` (default 8) at the dispatcher, but defensive overflow handling is correct.
- **`shared/src/pubsub.rs` tests** include four named cases (exact, prefix wildcard, universal, validation) with subcases — clean test taxonomy.
| HANDBACK §7 decision | Verdict | ## 6. Versioning audit
|---|---|
| Module name CHECK (`^[a-zA-Z_][a-zA-Z0-9_]{0,63}$`) only for `kind = 'module'` | ✅ Endpoint names keep looser rules; existing rows unaffected |
| Reserved module name list | ✅ Author-confusion defense, not security |
| `script_imports.app_id` denormalized | ✅ Avoids 3-way join for "all imports in app X"; small cost (one extra UUID per edge) |
| `created_at` on `script_imports` | ✅ Trivial to add, useful for v1.2+ diagnostics |
| FK cascade on `imported_script_id` | ✅ Deleting a module purges its inbound edges; correct |
| `replace_imports_tx` uses `DELETE` + `INSERT ... ON CONFLICT DO NOTHING` | ✅ Wholesale replace; unresolved names skipped silently (re-resolves on next save of either side) |
| Two-migration split (0015 + 0016) | ✅ Each is revertable independently if needed |
## 6. Open questions (from HANDBACK §9)
1. **Optimizer constant-folding** (`if true { ... }` collapsed by Rhai's optimizer, passes shape validator vacuously). HANDBACK recommends accept-as-is. **Agreed.** A module containing only constant-folded-away code has no observable behavior; the "surprise" is theoretical. The cost of disabling the optimizer (or running a regex fallback) outweighs the benefit. Document; revisit if a real user hits it.
2. **`Module → Endpoint` transition** when something imports the module. HANDBACK recommends leave permissive. **Agreed.** Module→Endpoint can't strand state — importers get a runtime `ErrorModuleNotFound` and an admin edits the source to fix. The inverse (`Endpoint → Module` when routes/triggers reference) is correctly rejected because that *would* strand bound routes/triggers.
3. **Cached-module memory pressure.** HANDBACK recommends leave-as-is for v1.1.3, add metric in v1.1.6 when metrics ship. **Agreed.** Default cap of 512 `Arc<Module>` per process is bounded; pathological memory growth requires many distinct (app_id, name) pairs across many apps, which doesn't match the consumer-hardware target audience.
4. **`rhai/internals` feature tightening.** HANDBACK recommends `rhai = "=1.24"` exact pin. **Defer to v1.1.4.** The current pin (`rhai = "1.19"` resolving to `1.24.0` in lockfile) is the same as v1.0+. Tightening to `=1.24` is a one-line change that any contributor can make later; not v1.1.3's problem.
## 7. Minor observations (no action required)
- The `StackGuard::armed` field is currently always `true` with no code path that sets it to `false`. HANDBACK §11.6 calls this out honestly as "dead-but-cheap." Future opt-out paths (e.g. "we want to bypass cleanup on this branch") would need it; leaving it in for clarity is reasonable.
- The cache `tracing::debug!` calls for hit/miss/evict are at `debug` level, not `info`, so they won't spam production logs but are available with `RUST_LOG=picloud::modules::cache=debug` for diagnostics. Sensible level choice.
- HANDBACK §11.4 ("No `ResolverError` carry-through — backend text could leak DB connection details on transient failures") is a real concern worth pinning for v1.1.4. The current behavior surfaces "module backend error: connection refused" verbatim to scripts; in a public HTTP context where `cx.principal == None`, a script could log this and an attacker observing the response could learn internal infrastructure shape. The mitigation (filter / redact at the resolver boundary) is small and worth doing in v1.1.4.
## 8. Versioning audit
| File | Before | After | Status | | File | Before | After | Status |
|---|---|---|---| |---|---|---|---|
| Workspace `Cargo.toml` | 1.1.2 | 1.1.3 | ✅ | | Workspace `Cargo.toml` | 1.1.4 | 1.1.5 | ✅ |
| SDK schema (`shared/src/version.rs`) | 1.3 | 1.4 | ✅ correctly bumped — `ScriptKind` enum + `ModuleSource` trait + `ValidatedScript` + `ScriptIdentity` added to public surface | | SDK schema (`shared/src/version.rs`) | 1.5 | 1.6 | ✅ correctly bumped — `FilesService`, `PubsubService`, `FileMeta`, `NewFile`, `FileUpdate`, `topic_matches`, `validate_topic_pattern`, `TriggerEvent::{Files, Pubsub}` added to public surface |
| Dashboard `package.json` | 0.8.0 | 0.9.0 | ✅ | | Dashboard `package.json` | 0.10.0 | 0.11.0 | ✅ |
| Migrations | 0001..0014 | 0015..0016 added | ✅ sequential, no skips | | Migrations | 0001..0017 | 0018..0020 added | ✅ sequential, no skips |
| CHANGELOG.md | v1.1.2 entry | v1.1.3 entry added | ✅ | | CHANGELOG.md | v1.1.4 entry | v1.1.5 entry added | ✅ |
## 9. Recommended next steps (post-merge) ## 7. Recommended next steps (post-merge)
1. **Merge** `feat/v1.1.3-modules` into `main` (fast-forward; branch is linear ahead). 1. **Merge** `feat/v1.1.5-files-pubsub` into `main` (fast-forward; branch is linear ahead).
2. **Pause** before dispatching v1.1.4 (Outbound HTTP & Scheduled Tasks). 2. **Pause** before dispatching v1.1.6 (Realtime Channels & Client Library — the co-shipped SSE + `@picloud/client` work).
3. **For the v1.1.4 dispatch prompt**, consider including: 3. **For the v1.1.6 dispatch prompt**, consider folding in:
- The "redact `ModuleSourceError::Backend` text at the resolver boundary" follow-up (HANDBACK §11.4) so leaking infra shape via module errors is closed. - **Dispatcher end-to-end DB tests** for each trigger kind. This is broader than v1.1.5 — it's a workspace-wide hygiene task. Now that CI has a Postgres service (per v1.1.5's `.github/workflows/ci.yml`), gating these tests on `DATABASE_URL` lets them run in CI without breaking local `cargo test`. Cost is bounded; the goal is to catch dispatcher regressions before they surface as production trigger silence.
- A pin-tighter `rhai = "=1.24"` lockfile note (HANDBACK §9.4 / §11.3) so internals-API drift is deliberate. - **Empty-blob storage** — revisit whether `data: 0 bytes` should be a valid stored state (currently rejected as missing). Decide before v1.1.6 ships so the semantics across two releases stay consistent.
- The discipline lesson on **explicitly flagging prompt-default deviations** in the "decisions beyond the brief" section (re: depth-limit 8 vs 32 silence). - **Orphan file sweeper** — design + ship the simple `*.tmp.*` sweeper (defer the full DB-cross-check version to v1.3+). v1.1.6 is when the file storage will start to accumulate enough that operators notice.
4. **Awareness for the v1.1.1/v1.1.2 retro**: the cross-app trigger gap that v1.1.3 closed is a real vulnerability in any v1.1.1 / v1.1.2 production deploy. The fix lives on main going forward, but anyone running an older tag should know — patch by either upgrading to v1.1.3+ or backporting the `ensure_script_targetable`'s `app_id` check. 4. **Awareness:** v1.1.5 is the first release where the CI workflow exists. If the project lands new contributors before v1.1.6, the workflow needs `secrets` review (none currently set) and possibly branch-protection rules pointing at the CI checks.
Branch is ready for merge. Verdict: **APPROVE**. Branch is ready for merge. Verdict: **APPROVE**.

View File

@@ -35,6 +35,13 @@ rand.workspace = true
base64.workspace = true base64.workspace = true
hex.workspace = true hex.workspace = true
percent-encoding.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] [dev-dependencies]
async-trait.workspace = true 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

View File

@@ -144,6 +144,7 @@ impl Engine {
// capture cheap clones of the cx for use at script-call time. // capture cheap clones of the cx for use at script-call time.
let cx = Arc::new(SdkCallCx { let cx = Arc::new(SdkCallCx {
app_id: req.app_id, app_id: req.app_id,
script_id: req.script_id,
principal: req.principal.clone(), principal: req.principal.clone(),
execution_id: req.execution_id, execution_id: req.execution_id,
request_id: req.request_id, request_id: req.request_id,
@@ -347,6 +348,7 @@ fn build_ctx_map(req: &ExecRequest) -> Map {
/// `docs/v1.1.x-design-notes.md` §4 (the dead-letter sub-shape) and /// `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 /// §2/blueprint §9 (KV). Each variant becomes a Rhai map with a
/// `source` discriminant plus per-source fields. /// `source` discriminant plus per-source fields.
#[allow(clippy::too_many_lines)]
fn trigger_event_to_dynamic(event: &TriggerEvent) -> Dynamic { fn trigger_event_to_dynamic(event: &TriggerEvent) -> Dynamic {
let mut m = Map::new(); let mut m = Map::new();
m.insert("source".into(), event.source().into()); m.insert("source".into(), event.source().into());
@@ -388,6 +390,64 @@ fn trigger_event_to_dynamic(event: &TriggerEvent) -> Dynamic {
); );
m.insert("docs".into(), docs_map.into()); 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::DeadLetter { TriggerEvent::DeadLetter {
dead_letter_id, dead_letter_id,
original, original,

View File

@@ -331,10 +331,22 @@ impl ModuleResolver for PicloudModuleResolver {
))); )));
} }
Err(e) => { 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( return Err(Box::new(EvalAltResult::ErrorInModule(
path.to_string(), path.to_string(),
Box::new(EvalAltResult::ErrorRuntime( Box::new(EvalAltResult::ErrorRuntime(
format!("module backend error: {e}").into(), "module backend unavailable; check server logs".into(),
pos, pos,
)), )),
pos, pos,

View 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()
})
}

View 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()
}

View File

@@ -15,7 +15,10 @@ pub mod bridge;
pub mod cx; pub mod cx;
pub mod dead_letters; pub mod dead_letters;
pub mod docs; pub mod docs;
pub mod files;
pub mod http;
pub mod kv; pub mod kv;
pub mod pubsub;
pub mod stdlib; pub mod stdlib;
pub use bridge::{dynamic_to_json, json_to_dynamic}; pub use bridge::{dynamic_to_json, json_to_dynamic};
@@ -35,5 +38,8 @@ use rhai::Engine as RhaiEngine;
pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) { pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
kv::register(engine, services, cx.clone()); kv::register(engine, services, cx.clone());
docs::register(engine, services, cx.clone()); docs::register(engine, services, cx.clone());
dead_letters::register(engine, services, cx); dead_letters::register(engine, services, cx.clone());
http::register(engine, services, cx.clone());
files::register(engine, services, cx.clone());
pubsub::register(engine, services, cx);
} }

View File

@@ -0,0 +1,100 @@
//! `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 })
},
);
}
engine.register_static_module("pubsub", module.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()
})
}

View File

@@ -0,0 +1,129 @@
//! 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),
);
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}"
);
}

View File

@@ -17,8 +17,8 @@ use chrono::{DateTime, Utc};
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits}; use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
use picloud_shared::{ use picloud_shared::{
AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService, AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService,
NoopDocsService, NoopEventEmitter, NoopKvService, RequestId, ScriptId, ScriptSandbox, NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId,
SdkCallCx, Services, ScriptSandbox, SdkCallCx, Services,
}; };
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -96,6 +96,9 @@ fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
Arc::new(NoopDeadLetterService), Arc::new(NoopDeadLetterService),
Arc::new(NoopEventEmitter), Arc::new(NoopEventEmitter),
modules, modules,
Arc::new(NoopHttpService),
Arc::new(picloud_shared::NoopFilesService),
Arc::new(picloud_shared::NoopPubsubService),
) )
} }
@@ -321,20 +324,28 @@ async fn resolver_runtime_validation_rejects_top_level_expr() {
); );
} }
/// 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")] #[tokio::test(flavor = "multi_thread")]
async fn resolver_backend_error_surfaces() { async fn resolver_backend_error_is_redacted_from_script() {
let source = CountingModuleSource::new(); let source = CountingModuleSource::new();
let app_id = AppId::new(); let app_id = AppId::new();
*source.fail_with.lock().await = Some("simulated db outage".into()); *source.fail_with.lock().await = Some("connection refused to 10.1.2.3:5432".into());
let engine = engine_with(source); let engine = engine_with(source);
let err = engine let err = engine
.execute(r#"import "x" as x; 1"#, req(app_id)) .execute(r#"import "x" as x; 1"#, req(app_id))
.expect_err("backend error should propagate"); .expect_err("backend error should propagate");
let msg = format!("{err:?}").to_lowercase(); let msg = format!("{err:?}");
assert!( assert!(
msg.contains("simulated") || msg.contains("backend"), msg.contains("module backend unavailable"),
"expected backend-error message, got {msg}" "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}"
); );
} }

View File

@@ -11,8 +11,8 @@ use chrono::Utc;
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits}; use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
use picloud_shared::{ use picloud_shared::{
AppId, DocId, DocRow, DocsError, DocsListPage, DocsService, ExecutionId, NoopDeadLetterService, AppId, DocId, DocRow, DocsError, DocsListPage, DocsService, ExecutionId, NoopDeadLetterService,
NoopEventEmitter, NoopKvService, NoopModuleSource, RequestId, ScriptId, ScriptSandbox, NoopEventEmitter, NoopHttpService, NoopKvService, NoopModuleSource, RequestId, ScriptId,
SdkCallCx, Services, ScriptSandbox, SdkCallCx, Services,
}; };
use serde_json::{json, Value}; use serde_json::{json, Value};
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -227,6 +227,9 @@ fn make_engine() -> Arc<Engine> {
Arc::new(NoopDeadLetterService), Arc::new(NoopDeadLetterService),
Arc::new(NoopEventEmitter), Arc::new(NoopEventEmitter),
Arc::new(NoopModuleSource), Arc::new(NoopModuleSource),
Arc::new(NoopHttpService),
Arc::new(picloud_shared::NoopFilesService),
Arc::new(picloud_shared::NoopPubsubService),
); );
Arc::new(Engine::new(Limits::default(), services)) Arc::new(Engine::new(Limits::default(), services))
} }

View File

@@ -0,0 +1,334 @@
//! `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(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));
}

View File

@@ -0,0 +1,336 @@
//! 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(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));
}

View File

@@ -11,7 +11,8 @@ use async_trait::async_trait;
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits}; use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
use picloud_shared::{ use picloud_shared::{
AppId, ExecutionId, KvError, KvListPage, KvService, NoopDeadLetterService, NoopDocsService, AppId, ExecutionId, KvError, KvListPage, KvService, NoopDeadLetterService, NoopDocsService,
NoopEventEmitter, NoopModuleSource, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services, NoopEventEmitter, NoopHttpService, NoopModuleSource, RequestId, ScriptId, ScriptSandbox,
SdkCallCx, Services,
}; };
use serde_json::{json, Value}; use serde_json::{json, Value};
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -105,6 +106,9 @@ fn make_engine() -> Arc<Engine> {
Arc::new(NoopDeadLetterService), Arc::new(NoopDeadLetterService),
Arc::new(NoopEventEmitter), Arc::new(NoopEventEmitter),
Arc::new(NoopModuleSource), Arc::new(NoopModuleSource),
Arc::new(NoopHttpService),
Arc::new(picloud_shared::NoopFilesService),
Arc::new(picloud_shared::NoopPubsubService),
); );
Arc::new(Engine::new(Limits::default(), services)) Arc::new(Engine::new(Limits::default(), services))
} }

View File

@@ -0,0 +1,157 @@
//! `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(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");
}

View File

@@ -23,8 +23,11 @@ tokio.workspace = true
tracing.workspace = true tracing.workspace = true
uuid.workspace = true uuid.workspace = true
chrono.workspace = true chrono.workspace = true
chrono-tz.workspace = true
cron.workspace = true
sqlx.workspace = true sqlx.workspace = true
url.workspace = true url.workspace = true
reqwest.workspace = true
argon2.workspace = true argon2.workspace = true
sha2.workspace = true sha2.workspace = true

View 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);

View 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);

View 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
);

View 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';

View File

@@ -72,6 +72,23 @@ pub enum Capability {
/// shape as KV write — granted to `editor`+, maps to /// shape as KV write — granted to `editor`+, maps to
/// `script:write` on API keys. /// `script:write` on API keys.
AppDocsWrite(AppId), 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),
/// Create / list / delete triggers for this app (v1.1.1). Maps to /// Create / list / delete triggers for this app (v1.1.1). Maps to
/// `app:admin` on API keys — triggers are app-configuration acts /// `app:admin` on API keys — triggers are app-configuration acts
/// rather than data-plane access. Granted to `app_admin`+. /// rather than data-plane access. Granted to `app_admin`+.
@@ -101,6 +118,10 @@ impl Capability {
| Self::AppKvWrite(id) | Self::AppKvWrite(id)
| Self::AppDocsRead(id) | Self::AppDocsRead(id)
| Self::AppDocsWrite(id) | Self::AppDocsWrite(id)
| Self::AppHttpRequest(id)
| Self::AppFilesRead(id)
| Self::AppFilesWrite(id)
| Self::AppPubsubPublish(id)
| Self::AppManageTriggers(id) | Self::AppManageTriggers(id)
| Self::AppDeadLetterManage(id) => Some(id), | Self::AppDeadLetterManage(id) => Some(id),
} }
@@ -117,10 +138,16 @@ impl Capability {
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => { Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
Scope::InstanceAdmin Scope::InstanceAdmin
} }
Self::AppRead(_) | Self::AppKvRead(_) | Self::AppDocsRead(_) => Scope::ScriptRead, Self::AppRead(_)
Self::AppWriteScript(_) | Self::AppKvWrite(_) | Self::AppDocsWrite(_) => { | Self::AppKvRead(_)
Scope::ScriptWrite | Self::AppDocsRead(_)
} | Self::AppFilesRead(_) => Scope::ScriptRead,
Self::AppWriteScript(_)
| Self::AppKvWrite(_)
| Self::AppDocsWrite(_)
| Self::AppHttpRequest(_)
| Self::AppFilesWrite(_)
| Self::AppPubsubPublish(_) => Scope::ScriptWrite,
Self::AppWriteRoute(_) => Scope::RouteWrite, Self::AppWriteRoute(_) => Scope::RouteWrite,
Self::AppManageDomains(_) => Scope::DomainManage, Self::AppManageDomains(_) => Scope::DomainManage,
Self::AppAdmin(_) | Self::AppManageTriggers(_) | Self::AppDeadLetterManage(_) => { Self::AppAdmin(_) | Self::AppManageTriggers(_) | Self::AppDeadLetterManage(_) => {
@@ -269,6 +296,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
| Capability::AppLogRead(_) | Capability::AppLogRead(_)
| Capability::AppKvRead(_) | Capability::AppKvRead(_)
| Capability::AppDocsRead(_) | Capability::AppDocsRead(_)
| Capability::AppFilesRead(_)
); );
let in_editor = in_viewer let in_editor = in_viewer
|| matches!( || matches!(
@@ -277,6 +305,9 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
| Capability::AppWriteRoute(_) | Capability::AppWriteRoute(_)
| Capability::AppKvWrite(_) | Capability::AppKvWrite(_)
| Capability::AppDocsWrite(_) | Capability::AppDocsWrite(_)
| Capability::AppHttpRequest(_)
| Capability::AppFilesWrite(_)
| Capability::AppPubsubPublish(_)
); );
let in_app_admin = in_editor let in_app_admin = in_editor
|| matches!( || matches!(

View 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"));
}
}

View File

@@ -208,6 +208,9 @@ async fn resolve(
fn admin_cx(app_id: AppId, principal: &Principal) -> SdkCallCx { fn admin_cx(app_id: AppId, principal: &Principal) -> SdkCallCx {
SdkCallCx { SdkCallCx {
app_id, 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()), principal: Some(principal.clone()),
execution_id: picloud_shared::ExecutionId::new(), execution_id: picloud_shared::ExecutionId::new(),
request_id: picloud_shared::RequestId::new(), request_id: picloud_shared::RequestId::new(),

View File

@@ -163,7 +163,12 @@ impl Dispatcher {
return Ok(()); return Ok(());
} }
}, },
OutboxSourceKind::Kv | OutboxSourceKind::Docs | OutboxSourceKind::DeadLetter => { OutboxSourceKind::Kv
| OutboxSourceKind::Docs
| OutboxSourceKind::DeadLetter
| OutboxSourceKind::Cron
| OutboxSourceKind::Files
| OutboxSourceKind::Pubsub => {
let resolved = self.resolve_trigger(&row).await?; let resolved = self.resolve_trigger(&row).await?;
let req = match self.build_exec_request(&row, &resolved).await { let req = match self.build_exec_request(&row, &resolved).await {
Ok(req) => req, Ok(req) => req,

View File

@@ -272,7 +272,7 @@ mod tests {
use chrono::Utc; use chrono::Utc;
use picloud_shared::{ use picloud_shared::{
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, NoopEventEmitter, Principal, AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, NoopEventEmitter, Principal,
RequestId, UserId, RequestId, ScriptId, UserId,
}; };
use serde_json::json; use serde_json::json;
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -507,6 +507,7 @@ mod tests {
fn anon_cx(app_id: AppId) -> SdkCallCx { fn anon_cx(app_id: AppId) -> SdkCallCx {
SdkCallCx { SdkCallCx {
app_id, app_id,
script_id: ScriptId::new(),
principal: None, principal: None,
execution_id: ExecutionId::new(), execution_id: ExecutionId::new(),
request_id: RequestId::new(), request_id: RequestId::new(),
@@ -520,6 +521,7 @@ mod tests {
fn owner_cx(app_id: AppId) -> SdkCallCx { fn owner_cx(app_id: AppId) -> SdkCallCx {
SdkCallCx { SdkCallCx {
app_id, app_id,
script_id: ScriptId::new(),
principal: Some(Principal { principal: Some(Principal {
user_id: AdminUserId::new(), user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner, instance_role: InstanceRole::Owner,
@@ -538,6 +540,7 @@ mod tests {
fn member_no_role_cx(app_id: AppId) -> SdkCallCx { fn member_no_role_cx(app_id: AppId) -> SdkCallCx {
SdkCallCx { SdkCallCx {
app_id, app_id,
script_id: ScriptId::new(),
principal: Some(Principal { principal: Some(Principal {
user_id: AdminUserId::new(), user_id: AdminUserId::new(),
instance_role: InstanceRole::Member, instance_role: InstanceRole::Member,

View File

@@ -0,0 +1,215 @@
//! `/api/v1/admin/apps/{id}/files*` — minimal files admin endpoints
//! backing the dashboard's files view (v1.1.5).
//!
//! Two operations only, both operator-facing:
//! * `GET /apps/{id}/files?collection=<c>&cursor=&limit=` — list file
//! metadata for a collection (cursor-paginated).
//! * `DELETE /apps/{id}/files/{collection}/{file_id}` — remove a file.
//!
//! These talk to the `FilesRepo` directly (like `triggers_api` talks to
//! `TriggerRepo`), guarded by the same capability model as the SDK
//! (`AppFilesRead` / `AppFilesWrite`). **Admin deletes do NOT emit a
//! `files:delete` trigger event** — they're operator cleanup actions,
//! not script mutations (see HANDBACK §7). The capability binds to the
//! resource's `app_id` after the app is loaded.
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get};
use axum::{Extension, Router};
use picloud_shared::{AppId, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::files_repo::{FilesRepo, FilesRepoError};
#[derive(Clone)]
pub struct FilesAdminState {
pub files: Arc<dyn FilesRepo>,
pub apps: Arc<dyn AppRepository>,
pub authz: Arc<dyn AuthzRepo>,
}
pub fn files_admin_router(state: FilesAdminState) -> Router {
Router::new()
.route("/apps/{app_id}/files", get(list_files))
.route(
"/apps/{app_id}/files/{collection}/{file_id}",
delete(delete_file),
)
.with_state(state)
}
#[derive(Debug, Deserialize)]
pub struct ListFilesQuery {
pub collection: String,
#[serde(default)]
pub cursor: Option<String>,
#[serde(default)]
pub limit: Option<u32>,
}
#[derive(Debug, Serialize)]
struct FileMetaDto {
id: String,
collection: String,
name: String,
content_type: String,
size: u64,
checksum: String,
created_at: String,
updated_at: String,
}
#[derive(Debug, Serialize)]
struct ListFilesResponse {
files: Vec<FileMetaDto>,
next_cursor: Option<String>,
}
async fn list_files(
State(s): State<FilesAdminState>,
Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>,
Query(q): Query<ListFilesQuery>,
) -> Result<Json<ListFilesResponse>, FilesApiError> {
ensure_app_exists(&*s.apps, app_id).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppFilesRead(app_id),
)
.await?;
if q.collection.trim().is_empty() {
return Err(FilesApiError::Invalid(
"collection must not be empty".into(),
));
}
let page = s
.files
.list(
app_id,
&q.collection,
q.cursor.as_deref(),
q.limit.unwrap_or(0),
)
.await?;
let files = page
.files
.into_iter()
.map(|m| FileMetaDto {
id: m.id.to_string(),
collection: m.collection,
name: m.name,
content_type: m.content_type,
size: m.size,
checksum: m.checksum,
created_at: m.created_at.to_rfc3339(),
updated_at: m.updated_at.to_rfc3339(),
})
.collect();
Ok(Json(ListFilesResponse {
files,
next_cursor: page.next_cursor,
}))
}
async fn delete_file(
State(s): State<FilesAdminState>,
Extension(principal): Extension<Principal>,
Path((app_id, collection, file_id)): Path<(AppId, String, String)>,
) -> Result<StatusCode, FilesApiError> {
ensure_app_exists(&*s.apps, app_id).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppFilesWrite(app_id),
)
.await?;
let id = Uuid::parse_str(&file_id).map_err(|_| FilesApiError::NotFound)?;
if s.files.delete(app_id, &collection, id).await?.is_none() {
return Err(FilesApiError::NotFound);
}
Ok(StatusCode::NO_CONTENT)
}
async fn ensure_app_exists(apps: &dyn AppRepository, app_id: AppId) -> Result<(), FilesApiError> {
apps.get_by_id(app_id)
.await
.map_err(|e| FilesApiError::Backend(e.to_string()))?
.ok_or(FilesApiError::AppNotFound)?;
Ok(())
}
#[derive(Debug, thiserror::Error)]
pub enum FilesApiError {
#[error("app not found")]
AppNotFound,
#[error("file not found")]
NotFound,
#[error("invalid request: {0}")]
Invalid(String),
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("files backend: {0}")]
Backend(String),
}
impl From<AuthzDenied> for FilesApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl From<AuthzError> for FilesApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl From<FilesRepoError> for FilesApiError {
fn from(e: FilesRepoError) -> Self {
Self::Backend(e.to_string())
}
}
impl IntoResponse for FilesApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound | Self::NotFound => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::Invalid(_) => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "files admin authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Backend(e) => {
tracing::error!(error = %e, "files admin backend error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

@@ -0,0 +1,759 @@
//! `FilesRepo` — the metadata row (Postgres) + blob bytes (filesystem)
//! storage layer for the v1.1.5 `files::*` SDK.
//!
//! Unlike KV/docs, this repo owns BOTH halves of a file: the `files`
//! row (metadata + SHA-256 checksum) and the bytes on disk at
//! `<root>/files/<app_id>/<collection>/<id[0:2]>/<id>`.
//! It owns both because the write must be atomic across them — a crash
//! mid-write must never leave a readable half-written file.
//!
//! ## Atomic write protocol (`create` / `update`)
//! 1. Validate (collection path-safety; caps live one layer up).
//! 2. `create_dir_all` the shard dir with `0o700`.
//! 3. SHA-256 the in-memory bytes (single pass) while writing to
//! `<final>.tmp.<unique>`.
//! 4. `fsync` the temp file.
//! 5. `rename` temp → final (atomic on POSIX).
//! 6. `fsync` the parent dir (so the rename is durable).
//! 7. INSERT / UPDATE the DB row.
//!
//! A crash between 15 leaves an orphan `*.tmp.*` (never read). A crash
//! between 57 leaves a file with no row — never reachable via the SDK
//! (reads start from the row). Both are reclaimed by a future orphan
//! sweep (deferred to v1.1.6+; see HANDBACK §7).
//!
//! ## Atomic delete protocol
//! 1. SELECT + DELETE the row inside one transaction; commit.
//! 2. `unlink` the file (outside the tx). A failure here leaves an
//! orphan; a failure before the commit changes nothing.
//!
//! ## Checksum-on-read
//! `get` reads the file, hashes it, and compares against the stored
//! checksum — returning `FilesError::Corrupted` (and logging the path
//! at error level) on a mismatch. It never auto-deletes; the operator
//! decides what to do with a metadata-vs-bytes divergence.
use std::env;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use async_trait::async_trait;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use chrono::{DateTime, Utc};
use picloud_shared::{AppId, FileMeta, FileUpdate, FilesListPage, NewFile};
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use uuid::Uuid;
/// 100 MB default per-file cap.
pub const DEFAULT_MAX_FILE_SIZE_BYTES: usize = 100 * 1024 * 1024;
/// Default filesystem root (relative to the process CWD).
pub const DEFAULT_FILES_ROOT: &str = "./data";
const FILES_LIST_MAX_LIMIT: u32 = 1_000;
const FILES_LIST_DEFAULT_LIMIT: u32 = 100;
/// Monotonic counter feeding unique temp-file suffixes (combined with
/// the pid). Avoids `rand` in the storage layer per the brief.
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, thiserror::Error)]
pub enum FilesRepoError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("filesystem error: {0}")]
Io(String),
#[error("invalid collection name: {0}")]
InvalidCollection(String),
/// The bytes on disk no longer match the stored checksum (or are
/// missing entirely while the row persists).
#[error("file content corrupted (checksum mismatch)")]
Corrupted,
#[error("invalid pagination cursor")]
InvalidCursor,
}
/// Outbound-files tunables. Env-overridable following the same pattern
/// as `HttpConfig::from_env`.
#[derive(Debug, Clone)]
pub struct FilesConfig {
pub root: PathBuf,
pub max_file_size_bytes: usize,
}
impl FilesConfig {
#[must_use]
pub fn conservative() -> Self {
Self {
root: PathBuf::from(DEFAULT_FILES_ROOT),
max_file_size_bytes: DEFAULT_MAX_FILE_SIZE_BYTES,
}
}
#[must_use]
pub fn from_env() -> Self {
let mut c = Self::conservative();
if let Ok(v) = env::var("PICLOUD_FILES_ROOT") {
if !v.trim().is_empty() {
c.root = PathBuf::from(v);
}
}
if let Ok(v) = env::var("PICLOUD_FILES_MAX_FILE_SIZE_BYTES") {
match v.parse::<usize>() {
Ok(n) => c.max_file_size_bytes = n,
Err(e) => {
tracing::warn!(error = %e, "ignoring invalid PICLOUD_FILES_MAX_FILE_SIZE_BYTES");
}
}
}
c
}
}
impl Default for FilesConfig {
fn default() -> Self {
Self::conservative()
}
}
/// The new+prior metadata returned from a successful `update`, so the
/// service can emit a `ServiceEvent` with the change-data-capture
/// surface (`old_payload`).
#[derive(Debug, Clone)]
pub struct FileUpdated {
pub new: FileMeta,
pub prev: FileMeta,
}
#[async_trait]
pub trait FilesRepo: Send + Sync {
async fn create(
&self,
app_id: AppId,
collection: &str,
new: NewFile,
) -> Result<FileMeta, FilesRepoError>;
async fn head(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, FilesRepoError>;
/// Reads + checksum-verifies the bytes. `Ok(None)` when no row
/// exists; `Err(Corrupted)` when the row exists but the bytes are
/// missing or mismatched.
async fn get(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<Vec<u8>>, FilesRepoError>;
/// `Ok(None)` when no row exists (the SDK turns this into
/// `FilesError::NotFound`).
async fn update(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
upd: FileUpdate,
) -> Result<Option<FileUpdated>, FilesRepoError>;
/// Returns the deleted row's metadata if present, `None` otherwise.
async fn delete(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, FilesRepoError>;
async fn list(
&self,
app_id: AppId,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<FilesListPage, FilesRepoError>;
}
/// Filesystem-bytes + Postgres-metadata repo.
pub struct FsFilesRepo {
pool: PgPool,
config: FilesConfig,
}
impl FsFilesRepo {
#[must_use]
pub fn new(pool: PgPool, config: FilesConfig) -> Self {
Self { pool, config }
}
/// Defensive path-component guard. The service already validates the
/// collection at the SDK boundary; this is belt-and-suspenders so a
/// future caller can't smuggle a traversal sequence onto disk.
fn guard_collection(collection: &str) -> Result<(), FilesRepoError> {
if collection.is_empty()
|| collection.contains('/')
|| collection.contains('\\')
|| collection.contains("..")
|| collection.contains('\0')
{
return Err(FilesRepoError::InvalidCollection(collection.to_string()));
}
Ok(())
}
fn final_path(&self, app_id: AppId, collection: &str, id: Uuid) -> PathBuf {
final_path_at(&self.config.root, app_id, collection, id)
}
fn write_atomic(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
bytes: &[u8],
) -> Result<String, FilesRepoError> {
write_atomic_at(&self.config.root, app_id, collection, id, bytes)
}
}
fn shard_dir_at(root: &Path, app_id: AppId, collection: &str, id_str: &str) -> PathBuf {
root.join("files")
.join(app_id.into_inner().to_string())
.join(collection)
.join(&id_str[..2])
}
fn final_path_at(root: &Path, app_id: AppId, collection: &str, id: Uuid) -> PathBuf {
let id_str = id.to_string();
shard_dir_at(root, app_id, collection, &id_str).join(&id_str)
}
/// Steps 26 of the atomic-write protocol. Returns the lowercase hex
/// SHA-256 of the bytes (computed in a single pass over the in-memory
/// buffer — the file is never re-read). Free function so the fs
/// mechanics are unit-testable without a Postgres pool.
fn write_atomic_at(
root: &Path,
app_id: AppId,
collection: &str,
id: Uuid,
bytes: &[u8],
) -> Result<String, FilesRepoError> {
use std::io::Write as _;
let id_str = id.to_string();
let dir = shard_dir_at(root, app_id, collection, &id_str);
create_dir_all_secure(&dir)?;
// Single-pass checksum over the in-memory buffer.
let mut hasher = Sha256::new();
hasher.update(bytes);
let checksum = hex_lower(&hasher.finalize());
let seq = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let tmp = dir.join(format!("{id_str}.tmp.{}-{seq}", std::process::id()));
let final_path = dir.join(&id_str);
{
let mut f = std::fs::File::create(&tmp).map_err(io_err)?;
f.write_all(bytes).map_err(io_err)?;
f.sync_all().map_err(io_err)?; // fsync temp
}
std::fs::rename(&tmp, &final_path).map_err(io_err)?; // atomic
// fsync the parent dir so the rename is durable.
if let Ok(dirf) = std::fs::File::open(&dir) {
let _ = dirf.sync_all();
}
Ok(checksum)
}
/// Read + checksum-verify the bytes at the given path-set. Free
/// function mirror of the `get` read path. Returns `Corrupted` when the
/// bytes are missing or don't match `expected_checksum`.
fn read_verify_at(
root: &Path,
app_id: AppId,
collection: &str,
id: Uuid,
expected_checksum: &str,
) -> Result<Vec<u8>, FilesRepoError> {
let path = final_path_at(root, app_id, collection, id);
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
tracing::error!(
path = %path.display(), error = %e,
"files: row exists but bytes are unreadable — treating as corrupted"
);
return Err(FilesRepoError::Corrupted);
}
};
let mut hasher = Sha256::new();
hasher.update(&bytes);
let actual = hex_lower(&hasher.finalize());
if actual != expected_checksum {
tracing::error!(
path = %path.display(), expected = %expected_checksum, actual = %actual,
"files: checksum mismatch on read — content corrupted"
);
return Err(FilesRepoError::Corrupted);
}
Ok(bytes)
}
#[async_trait]
impl FilesRepo for FsFilesRepo {
async fn create(
&self,
app_id: AppId,
collection: &str,
new: NewFile,
) -> Result<FileMeta, FilesRepoError> {
Self::guard_collection(collection)?;
let id = Uuid::new_v4();
let size = i64::try_from(new.data.len()).unwrap_or(i64::MAX);
let checksum = self.write_atomic(app_id, collection, id, &new.data)?;
let row: FileRow = sqlx::query_as(
"INSERT INTO files \
(app_id, collection, id, name, content_type, size_bytes, checksum_sha256) \
VALUES ($1, $2, $3, $4, $5, $6, $7) \
RETURNING id, collection, name, content_type, size_bytes, \
checksum_sha256, created_at, updated_at",
)
.bind(app_id.into_inner())
.bind(collection)
.bind(id)
.bind(&new.name)
.bind(&new.content_type)
.bind(size)
.bind(&checksum)
.fetch_one(&self.pool)
.await?;
Ok(row.into_meta())
}
async fn head(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, FilesRepoError> {
let row: Option<FileRow> = sqlx::query_as(
"SELECT id, collection, name, content_type, size_bytes, \
checksum_sha256, created_at, updated_at \
FROM files WHERE app_id = $1 AND collection = $2 AND id = $3",
)
.bind(app_id.into_inner())
.bind(collection)
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(FileRow::into_meta))
}
async fn get(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<Vec<u8>>, FilesRepoError> {
let row: Option<(String,)> = sqlx::query_as(
"SELECT checksum_sha256 FROM files \
WHERE app_id = $1 AND collection = $2 AND id = $3",
)
.bind(app_id.into_inner())
.bind(collection)
.bind(id)
.fetch_optional(&self.pool)
.await?;
let Some((stored_checksum,)) = row else {
return Ok(None);
};
let bytes = read_verify_at(&self.config.root, app_id, collection, id, &stored_checksum)?;
Ok(Some(bytes))
}
async fn update(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
upd: FileUpdate,
) -> Result<Option<FileUpdated>, FilesRepoError> {
Self::guard_collection(collection)?;
// Read the prior row first (existence check + CDC surface).
let Some(prev) = self.head(app_id, collection, id).await? else {
return Ok(None);
};
let size = i64::try_from(upd.data.len()).unwrap_or(i64::MAX);
let checksum = self.write_atomic(app_id, collection, id, &upd.data)?;
let row: FileRow = sqlx::query_as(
"UPDATE files SET \
name = COALESCE($4, name), \
content_type = COALESCE($5, content_type), \
size_bytes = $6, \
checksum_sha256 = $7, \
updated_at = NOW() \
WHERE app_id = $1 AND collection = $2 AND id = $3 \
RETURNING id, collection, name, content_type, size_bytes, \
checksum_sha256, created_at, updated_at",
)
.bind(app_id.into_inner())
.bind(collection)
.bind(id)
.bind(upd.name.as_deref())
.bind(upd.content_type.as_deref())
.bind(size)
.bind(&checksum)
.fetch_one(&self.pool)
.await?;
Ok(Some(FileUpdated {
new: row.into_meta(),
prev,
}))
}
async fn delete(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, FilesRepoError> {
// SELECT + DELETE in one tx; unlink afterwards (outside the tx).
let mut tx = self.pool.begin().await?;
let row: Option<FileRow> = sqlx::query_as(
"SELECT id, collection, name, content_type, size_bytes, \
checksum_sha256, created_at, updated_at \
FROM files WHERE app_id = $1 AND collection = $2 AND id = $3 \
FOR UPDATE",
)
.bind(app_id.into_inner())
.bind(collection)
.bind(id)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
tx.rollback().await?;
return Ok(None);
};
sqlx::query("DELETE FROM files WHERE app_id = $1 AND collection = $2 AND id = $3")
.bind(app_id.into_inner())
.bind(collection)
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
// Row is gone; unlink the bytes. A failure here leaves an orphan
// file (reclaimed by a future sweep) — not fatal.
let path = self.final_path(app_id, collection, id);
if let Err(e) = std::fs::remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(path = %path.display(), error = %e, "files: unlink after delete failed (orphan)");
}
}
Ok(Some(row.into_meta()))
}
async fn list(
&self,
app_id: AppId,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<FilesListPage, FilesRepoError> {
let limit = if limit == 0 {
FILES_LIST_DEFAULT_LIMIT
} else {
limit.min(FILES_LIST_MAX_LIMIT)
};
let last_id = match cursor {
Some(c) => Some(decode_cursor(c)?),
None => None,
};
let take = i64::from(limit) + 1;
let rows: Vec<FileRow> = sqlx::query_as(
"SELECT id, collection, name, content_type, size_bytes, \
checksum_sha256, created_at, updated_at \
FROM files \
WHERE app_id = $1 AND collection = $2 \
AND ($3::uuid IS NULL OR id > $3) \
ORDER BY id ASC \
LIMIT $4",
)
.bind(app_id.into_inner())
.bind(collection)
.bind(last_id)
.bind(take)
.fetch_all(&self.pool)
.await?;
let mut files: Vec<FileMeta> = rows.into_iter().map(FileRow::into_meta).collect();
let next_cursor = if files.len() > limit as usize {
files.truncate(limit as usize);
files.last().map(|m| encode_cursor(m.id))
} else {
None
};
Ok(FilesListPage { files, next_cursor })
}
}
// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------
fn io_err(e: std::io::Error) -> FilesRepoError {
FilesRepoError::Io(e.to_string())
}
/// `create_dir_all` with `0o700` on the created tree (Unix). On other
/// platforms it falls back to the default permissions.
fn create_dir_all_secure(dir: &Path) -> Result<(), FilesRepoError> {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt as _;
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)
.map_err(io_err)
}
#[cfg(not(unix))]
{
std::fs::create_dir_all(dir).map_err(io_err)
}
}
fn hex_lower(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
use std::fmt::Write as _;
let _ = write!(s, "{b:02x}");
}
s
}
fn encode_cursor(last_id: Uuid) -> String {
URL_SAFE_NO_PAD.encode(last_id.to_string().as_bytes())
}
fn decode_cursor(cursor: &str) -> Result<Uuid, FilesRepoError> {
let bytes = URL_SAFE_NO_PAD
.decode(cursor)
.map_err(|_| FilesRepoError::InvalidCursor)?;
let s = String::from_utf8(bytes).map_err(|_| FilesRepoError::InvalidCursor)?;
Uuid::parse_str(&s).map_err(|_| FilesRepoError::InvalidCursor)
}
#[derive(sqlx::FromRow)]
struct FileRow {
id: Uuid,
collection: String,
name: String,
content_type: String,
size_bytes: i64,
checksum_sha256: String,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl FileRow {
fn into_meta(self) -> FileMeta {
FileMeta {
id: self.id,
collection: self.collection,
name: self.name,
content_type: self.content_type,
size: u64::try_from(self.size_bytes).unwrap_or(0),
checksum: self.checksum_sha256,
created_at: self.created_at,
updated_at: self.updated_at,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_lower_matches_known_sha256_vector() {
// SHA-256("abc") — NIST known-answer vector.
let mut h = Sha256::new();
h.update(b"abc");
assert_eq!(
hex_lower(&h.finalize()),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn hex_lower_of_empty_is_known_vector() {
let mut h = Sha256::new();
h.update(b"");
assert_eq!(
hex_lower(&h.finalize()),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn cursor_round_trips() {
let id = Uuid::new_v4();
let enc = encode_cursor(id);
assert_eq!(decode_cursor(&enc).unwrap(), id);
assert!(matches!(
decode_cursor("!!not-base64!!"),
Err(FilesRepoError::InvalidCursor)
));
}
#[test]
fn guard_collection_rejects_traversal() {
assert!(FsFilesRepo::guard_collection("avatars").is_ok());
assert!(FsFilesRepo::guard_collection("a/b").is_err());
assert!(FsFilesRepo::guard_collection("..").is_err());
assert!(FsFilesRepo::guard_collection("a..b").is_err());
assert!(FsFilesRepo::guard_collection("").is_err());
assert!(FsFilesRepo::guard_collection("a\0b").is_err());
}
#[test]
fn config_from_env_defaults_are_conservative() {
let c = FilesConfig::conservative();
assert_eq!(c.max_file_size_bytes, DEFAULT_MAX_FILE_SIZE_BYTES);
assert_eq!(c.root, PathBuf::from(DEFAULT_FILES_ROOT));
}
// ------------------------------------------------------------------
// Tempdir-backed filesystem mechanics — exercise the atomic write,
// single-pass checksum, and checksum-on-read tamper detection
// without needing a Postgres pool.
// ------------------------------------------------------------------
use picloud_shared::AppId;
/// Process-unique scratch dir under the system temp dir. Cleaned up
/// by each test via `remove_dir_all`.
fn unique_tmp_root() -> PathBuf {
let seq = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("picloud-files-test-{}-{seq}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn write_atomic_then_read_verify_round_trips() {
let root = unique_tmp_root();
let app = AppId::new();
let id = Uuid::new_v4();
let bytes = b"hello picloud files".to_vec();
let checksum = write_atomic_at(&root, app, "avatars", id, &bytes).unwrap();
// Single-pass checksum matches an independent hash of the bytes.
let mut h = Sha256::new();
h.update(&bytes);
assert_eq!(checksum, hex_lower(&h.finalize()));
let read = read_verify_at(&root, app, "avatars", id, &checksum).unwrap();
assert_eq!(read, bytes);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn read_verify_detects_tampering_as_corrupted() {
let root = unique_tmp_root();
let app = AppId::new();
let id = Uuid::new_v4();
let checksum = write_atomic_at(&root, app, "c", id, b"original").unwrap();
// Mutate the bytes behind the repo's back.
let path = final_path_at(&root, app, "c", id);
std::fs::write(&path, b"tampered").unwrap();
let err = read_verify_at(&root, app, "c", id, &checksum).unwrap_err();
assert!(matches!(err, FilesRepoError::Corrupted));
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn read_verify_missing_bytes_is_corrupted() {
let root = unique_tmp_root();
let app = AppId::new();
let id = Uuid::new_v4();
// No write — the file never existed.
let err = read_verify_at(&root, app, "c", id, "deadbeef").unwrap_err();
assert!(matches!(err, FilesRepoError::Corrupted));
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn atomic_write_leaves_no_tmp_file_after_success() {
let root = unique_tmp_root();
let app = AppId::new();
let id = Uuid::new_v4();
write_atomic_at(&root, app, "c", id, b"data").unwrap();
let id_str = id.to_string();
let dir = shard_dir_at(&root, app, "c", &id_str);
let entries: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
// Exactly the final file is visible — no `*.tmp.*` orphan.
assert_eq!(entries, vec![id_str]);
assert!(!entries.iter().any(|n| n.contains(".tmp.")));
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn id_shard_uses_first_two_chars() {
let root = PathBuf::from("/tmp/x");
let app = AppId::new();
let id = Uuid::new_v4();
let id_str = id.to_string();
let path = final_path_at(&root, app, "col", id);
let shard = &id_str[..2];
assert!(path
.to_string_lossy()
.contains(&format!("/col/{shard}/{id_str}")));
}
#[cfg(unix)]
#[test]
fn shard_tree_created_with_0700() {
use std::os::unix::fs::PermissionsExt as _;
let root = unique_tmp_root();
let app = AppId::new();
let id = Uuid::new_v4();
write_atomic_at(&root, app, "c", id, b"data").unwrap();
let id_str = id.to_string();
let dir = shard_dir_at(&root, app, "c", &id_str);
let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700, "shard dir should be 0o700");
std::fs::remove_dir_all(&root).ok();
}
}

View File

@@ -0,0 +1,817 @@
//! `FilesServiceImpl` — wires the `FilesRepo` underneath the
//! `picloud_shared::FilesService` trait scripts see via the Rhai
//! bridge.
//!
//! Layers added here (vs the raw repo), mirroring `KvServiceImpl`:
//! 1. Collection validation (empty + path-traversal) and field /
//! size-cap validation at the SDK boundary.
//! 2. **Script-as-gate authz**: when `cx.principal.is_some()` we run
//! `authz::require(...)`; when it's `None` (public HTTP) we skip.
//! Cross-app isolation is unaffected — every repo call is keyed by
//! `cx.app_id`, never an argument.
//! 3. `ServiceEvent` emission after each mutation (`create` /
//! `update` / `delete`). The payload is the file **metadata**, not
//! the blob bytes (files are too big for trigger payloads).
use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{
validate_files_collection, FileMeta, FileUpdate, FilesError, FilesListPage, FilesService,
NewFile, SdkCallCx, ServiceEvent, ServiceEventEmitter,
};
use uuid::Uuid;
use crate::authz::{self, AuthzRepo, Capability};
use crate::files_repo::{FileUpdated, FilesRepo, FilesRepoError};
pub struct FilesServiceImpl {
repo: Arc<dyn FilesRepo>,
authz: Arc<dyn AuthzRepo>,
events: Arc<dyn ServiceEventEmitter>,
max_file_size_bytes: usize,
}
impl FilesServiceImpl {
#[must_use]
pub fn new(
repo: Arc<dyn FilesRepo>,
authz: Arc<dyn AuthzRepo>,
events: Arc<dyn ServiceEventEmitter>,
max_file_size_bytes: usize,
) -> Self {
Self {
repo,
authz,
events,
max_file_size_bytes,
}
}
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), FilesError> {
if let Some(ref principal) = cx.principal {
authz::require(&*self.authz, principal, Capability::AppFilesRead(cx.app_id))
.await
.map_err(|_| FilesError::Forbidden)?;
}
Ok(())
}
async fn check_write(&self, cx: &SdkCallCx) -> Result<(), FilesError> {
if let Some(ref principal) = cx.principal {
authz::require(
&*self.authz,
principal,
Capability::AppFilesWrite(cx.app_id),
)
.await
.map_err(|_| FilesError::Forbidden)?;
}
Ok(())
}
/// Best-effort `ServiceEvent` emission. A failed emit is logged but
/// never rolls back the (already-durable) file write.
async fn emit(
&self,
cx: &SdkCallCx,
op: &'static str,
collection: &str,
meta: &FileMeta,
old: Option<&FileMeta>,
) {
let payload = serde_json::to_value(meta).ok();
let old_payload = old.and_then(|m| serde_json::to_value(m).ok());
if let Err(e) = self
.events
.emit(
cx,
ServiceEvent {
source: "files",
op,
collection: Some(collection.to_string()),
key: Some(meta.id.to_string()),
payload,
old_payload,
},
)
.await
{
tracing::warn!(error = %e, source = "files", op, "event emit failed");
}
}
}
/// Parse a script-supplied id. Invalid UUIDs aren't an error shape the
/// SDK exposes — for reads/deletes they simply mean "no such file".
fn parse_id(id: &str) -> Option<Uuid> {
Uuid::parse_str(id).ok()
}
impl From<FilesRepoError> for FilesError {
fn from(e: FilesRepoError) -> Self {
match e {
FilesRepoError::Corrupted => Self::Corrupted,
FilesRepoError::InvalidCollection(c) => Self::InvalidCollection(c),
other => Self::Backend(other.to_string()),
}
}
}
#[async_trait]
impl FilesService for FilesServiceImpl {
async fn create(
&self,
cx: &SdkCallCx,
collection: &str,
new: NewFile,
) -> Result<Uuid, FilesError> {
validate_files_collection(collection)?;
self.check_write(cx).await?;
new.validate(self.max_file_size_bytes)?;
let meta = self.repo.create(cx.app_id, collection, new).await?;
self.emit(cx, "create", collection, &meta, None).await;
Ok(meta.id)
}
async fn head(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
) -> Result<Option<FileMeta>, FilesError> {
validate_files_collection(collection)?;
self.check_read(cx).await?;
let Some(uuid) = parse_id(id) else {
return Ok(None);
};
Ok(self.repo.head(cx.app_id, collection, uuid).await?)
}
async fn get(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
) -> Result<Option<Vec<u8>>, FilesError> {
validate_files_collection(collection)?;
self.check_read(cx).await?;
let Some(uuid) = parse_id(id) else {
return Ok(None);
};
Ok(self.repo.get(cx.app_id, collection, uuid).await?)
}
async fn update(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
upd: FileUpdate,
) -> Result<(), FilesError> {
validate_files_collection(collection)?;
self.check_write(cx).await?;
upd.validate(self.max_file_size_bytes)?;
let Some(uuid) = parse_id(id) else {
return Err(FilesError::NotFound);
};
match self.repo.update(cx.app_id, collection, uuid, upd).await? {
Some(FileUpdated { new, prev }) => {
self.emit(cx, "update", collection, &new, Some(&prev)).await;
Ok(())
}
None => Err(FilesError::NotFound),
}
}
async fn delete(&self, cx: &SdkCallCx, collection: &str, id: &str) -> Result<bool, FilesError> {
validate_files_collection(collection)?;
self.check_write(cx).await?;
let Some(uuid) = parse_id(id) else {
return Ok(false);
};
match self.repo.delete(cx.app_id, collection, uuid).await? {
Some(meta) => {
// On delete, the top-level metadata AND `prev` both carry
// the deleted row (per docs/v1.1.x design + the brief).
self.emit(cx, "delete", collection, &meta, Some(&meta))
.await;
Ok(true)
}
None => Ok(false),
}
}
async fn list(
&self,
cx: &SdkCallCx,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<FilesListPage, FilesError> {
validate_files_collection(collection)?;
self.check_read(cx).await?;
Ok(self.repo.list(cx.app_id, collection, cursor, limit).await?)
}
}
// ----------------------------------------------------------------------------
// Tests — in-memory FilesRepo so unit tests need neither Postgres nor a
// filesystem. The on-disk atomic-write / checksum mechanics are covered
// by the tempdir tests in `files_repo.rs`.
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::authz::{AuthzError, AuthzRepo};
use async_trait::async_trait;
use chrono::Utc;
use picloud_shared::{
AdminUserId, AppId, AppRole, EmitError, ExecutionId, InstanceRole, Principal, RequestId,
ScriptId, ServiceEvent, UserId,
};
use std::collections::BTreeMap;
use std::sync::Mutex as StdMutex;
use tokio::sync::Mutex;
/// In-memory FilesRepo keyed by (app, collection, id). Stores the
/// metadata + bytes together so cross-app isolation and round-trips
/// can be checked without disk.
#[derive(Default)]
struct InMemoryFilesRepo {
#[allow(clippy::type_complexity)]
data: Mutex<BTreeMap<(AppId, String, Uuid), (FileMeta, Vec<u8>)>>,
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(bytes);
let out = h.finalize();
let mut s = String::new();
for b in out {
use std::fmt::Write as _;
let _ = write!(s, "{b:02x}");
}
s
}
#[async_trait]
impl FilesRepo for InMemoryFilesRepo {
async fn create(
&self,
app_id: AppId,
collection: &str,
new: NewFile,
) -> Result<FileMeta, FilesRepoError> {
let id = Uuid::new_v4();
let now = Utc::now();
let meta = FileMeta {
id,
collection: collection.to_string(),
name: new.name.clone(),
content_type: new.content_type.clone(),
size: new.data.len() as u64,
checksum: sha256_hex(&new.data),
created_at: now,
updated_at: now,
};
self.data.lock().await.insert(
(app_id, collection.to_string(), id),
(meta.clone(), new.data),
);
Ok(meta)
}
async fn head(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, FilesRepoError> {
Ok(self
.data
.lock()
.await
.get(&(app_id, collection.to_string(), id))
.map(|(m, _)| m.clone()))
}
async fn get(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<Vec<u8>>, FilesRepoError> {
Ok(self
.data
.lock()
.await
.get(&(app_id, collection.to_string(), id))
.map(|(_, b)| b.clone()))
}
async fn update(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
upd: FileUpdate,
) -> Result<Option<FileUpdated>, FilesRepoError> {
let mut data = self.data.lock().await;
let key = (app_id, collection.to_string(), id);
let Some((prev_meta, _)) = data.get(&key).cloned() else {
return Ok(None);
};
let now = Utc::now();
let new_meta = FileMeta {
id,
collection: collection.to_string(),
name: upd.name.clone().unwrap_or_else(|| prev_meta.name.clone()),
content_type: upd
.content_type
.clone()
.unwrap_or_else(|| prev_meta.content_type.clone()),
size: upd.data.len() as u64,
checksum: sha256_hex(&upd.data),
created_at: prev_meta.created_at,
updated_at: now,
};
data.insert(key, (new_meta.clone(), upd.data));
Ok(Some(FileUpdated {
new: new_meta,
prev: prev_meta,
}))
}
async fn delete(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, FilesRepoError> {
Ok(self
.data
.lock()
.await
.remove(&(app_id, collection.to_string(), id))
.map(|(m, _)| m))
}
async fn list(
&self,
app_id: AppId,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<FilesListPage, FilesRepoError> {
let data = self.data.lock().await;
let after = cursor.and_then(|c| Uuid::parse_str(c).ok());
let mut metas: Vec<FileMeta> = data
.iter()
.filter(|((a, c, _), _)| *a == app_id && c == collection)
.map(|(_, (m, _))| m.clone())
.filter(|m| after.is_none_or(|a| m.id > a))
.collect();
metas.sort_by_key(|m| m.id);
let take = (limit.max(1)) as usize;
let next_cursor = if metas.len() > take {
metas.truncate(take);
metas.last().map(|m| m.id.to_string())
} else {
None
};
Ok(FilesListPage {
files: metas,
next_cursor,
})
}
}
/// Captures emitted events so tests can assert on fan-out shape.
#[derive(Default)]
struct CapturingEmitter {
events: StdMutex<Vec<ServiceEvent>>,
}
#[async_trait]
impl ServiceEventEmitter for CapturingEmitter {
async fn emit(&self, _cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> {
self.events.lock().unwrap().push(event);
Ok(())
}
}
#[derive(Default)]
struct DenyingAuthzRepo;
#[async_trait]
impl AuthzRepo for DenyingAuthzRepo {
async fn membership(
&self,
_user_id: UserId,
_app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(None)
}
}
#[derive(Default)]
struct EditorAuthzRepo;
#[async_trait]
impl AuthzRepo for EditorAuthzRepo {
async fn membership(
&self,
_user_id: UserId,
_app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(Some(AppRole::Editor))
}
}
fn anon_cx(app_id: AppId) -> SdkCallCx {
SdkCallCx {
app_id,
script_id: ScriptId::new(),
principal: None,
execution_id: ExecutionId::new(),
request_id: RequestId::new(),
trigger_depth: 0,
root_execution_id: ExecutionId::new(),
is_dead_letter_handler: false,
event: None,
}
}
fn member_cx(app_id: AppId) -> SdkCallCx {
SdkCallCx {
principal: Some(Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Member,
scopes: None,
app_binding: None,
}),
..anon_cx(app_id)
}
}
fn svc_with(authz: Arc<dyn AuthzRepo>, emitter: Arc<CapturingEmitter>) -> FilesServiceImpl {
FilesServiceImpl::new(
Arc::new(InMemoryFilesRepo::default()),
authz,
emitter,
10 * 1024 * 1024,
)
}
fn svc() -> FilesServiceImpl {
svc_with(
Arc::new(DenyingAuthzRepo),
Arc::new(CapturingEmitter::default()),
)
}
fn new_file(name: &str, data: &[u8]) -> NewFile {
NewFile {
name: name.to_string(),
content_type: "application/octet-stream".to_string(),
data: data.to_vec(),
}
}
#[tokio::test]
async fn create_then_get_head_round_trips() {
let files = svc();
let cx = anon_cx(AppId::new());
let id = files
.create(&cx, "avatars", new_file("a.bin", b"hello"))
.await
.unwrap();
let bytes = files.get(&cx, "avatars", &id.to_string()).await.unwrap();
assert_eq!(bytes, Some(b"hello".to_vec()));
let meta = files
.head(&cx, "avatars", &id.to_string())
.await
.unwrap()
.unwrap();
assert_eq!(meta.name, "a.bin");
assert_eq!(meta.size, 5);
assert_eq!(meta.checksum, sha256_hex(b"hello"));
}
#[tokio::test]
async fn get_and_head_missing_return_none() {
let files = svc();
let cx = anon_cx(AppId::new());
let missing = Uuid::new_v4().to_string();
assert_eq!(files.get(&cx, "c", &missing).await.unwrap(), None);
assert!(files.head(&cx, "c", &missing).await.unwrap().is_none());
// Non-UUID id is also "missing", not an error.
assert_eq!(files.get(&cx, "c", "not-a-uuid").await.unwrap(), None);
}
#[tokio::test]
async fn update_replaces_content_and_keeps_metadata_when_omitted() {
let files = svc();
let cx = anon_cx(AppId::new());
let id = files
.create(&cx, "c", new_file("v1.txt", b"one"))
.await
.unwrap();
files
.update(
&cx,
"c",
&id.to_string(),
FileUpdate {
data: b"two!!".to_vec(),
name: None,
content_type: None,
},
)
.await
.unwrap();
let meta = files
.head(&cx, "c", &id.to_string())
.await
.unwrap()
.unwrap();
assert_eq!(meta.name, "v1.txt"); // kept
assert_eq!(meta.size, 5);
assert_eq!(
files.get(&cx, "c", &id.to_string()).await.unwrap(),
Some(b"two!!".to_vec())
);
}
#[tokio::test]
async fn update_missing_throws_not_found() {
let files = svc();
let cx = anon_cx(AppId::new());
let err = files
.update(
&cx,
"c",
&Uuid::new_v4().to_string(),
FileUpdate {
data: b"x".to_vec(),
name: None,
content_type: None,
},
)
.await
.unwrap_err();
assert!(matches!(err, FilesError::NotFound));
}
#[tokio::test]
async fn delete_returns_was_present() {
let files = svc();
let cx = anon_cx(AppId::new());
let id = files.create(&cx, "c", new_file("f", b"x")).await.unwrap();
assert!(files.delete(&cx, "c", &id.to_string()).await.unwrap());
assert!(!files.delete(&cx, "c", &id.to_string()).await.unwrap());
assert!(!files.delete(&cx, "c", "not-a-uuid").await.unwrap());
}
#[tokio::test]
async fn empty_collection_rejected() {
let files = svc();
let cx = anon_cx(AppId::new());
let err = files
.create(&cx, "", new_file("f", b"x"))
.await
.unwrap_err();
assert!(matches!(err, FilesError::InvalidCollection(_)));
}
#[tokio::test]
async fn traversal_collection_rejected() {
let files = svc();
let cx = anon_cx(AppId::new());
for bad in ["../etc", "a/b", "a..b", "x\0y"] {
let err = files
.create(&cx, bad, new_file("f", b"x"))
.await
.unwrap_err();
assert!(
matches!(err, FilesError::InvalidCollection(_)),
"expected reject for {bad:?}"
);
}
}
#[tokio::test]
async fn missing_required_fields_have_field_specific_messages() {
let files = svc();
let cx = anon_cx(AppId::new());
// name
let err = files
.create(
&cx,
"c",
NewFile {
name: " ".into(),
content_type: "text/plain".into(),
data: b"x".to_vec(),
},
)
.await
.unwrap_err();
assert!(matches!(err, FilesError::MissingField("name")));
// content_type
let err = files
.create(
&cx,
"c",
NewFile {
name: "f".into(),
content_type: String::new(),
data: b"x".to_vec(),
},
)
.await
.unwrap_err();
assert!(matches!(err, FilesError::MissingField("content_type")));
// data
let err = files
.create(
&cx,
"c",
NewFile {
name: "f".into(),
content_type: "text/plain".into(),
data: vec![],
},
)
.await
.unwrap_err();
assert!(matches!(err, FilesError::MissingField("data")));
}
#[tokio::test]
async fn name_and_content_type_length_caps_enforced() {
let files = svc();
let cx = anon_cx(AppId::new());
let long_name = "x".repeat(256);
let err = files
.create(&cx, "c", new_file(&long_name, b"x"))
.await
.unwrap_err();
assert!(matches!(err, FilesError::NameTooLong(256)));
let err = files
.create(
&cx,
"c",
NewFile {
name: "f".into(),
content_type: "x".repeat(128),
data: b"x".to_vec(),
},
)
.await
.unwrap_err();
assert!(matches!(err, FilesError::ContentTypeTooLong(128)));
}
#[tokio::test]
async fn per_file_size_cap_enforced() {
let files = FilesServiceImpl::new(
Arc::new(InMemoryFilesRepo::default()),
Arc::new(DenyingAuthzRepo),
Arc::new(CapturingEmitter::default()),
8, // tiny cap
);
let cx = anon_cx(AppId::new());
let err = files
.create(&cx, "c", new_file("big", b"123456789"))
.await
.unwrap_err();
assert!(matches!(err, FilesError::TooLarge { limit: 8, .. }));
}
#[tokio::test]
async fn cross_app_isolation() {
let files = svc();
let app_a = AppId::new();
let app_b = AppId::new();
let cx_a = anon_cx(app_a);
let cx_b = anon_cx(app_b);
let id = files
.create(&cx_a, "shared", new_file("f", b"from-a"))
.await
.unwrap();
// app B cannot see app A's file by id.
assert_eq!(
files.get(&cx_b, "shared", &id.to_string()).await.unwrap(),
None
);
assert!(files
.head(&cx_b, "shared", &id.to_string())
.await
.unwrap()
.is_none());
let page_b = files.list(&cx_b, "shared", None, 100).await.unwrap();
assert!(page_b.files.is_empty());
// app A still sees it.
assert!(files
.get(&cx_a, "shared", &id.to_string())
.await
.unwrap()
.is_some());
}
#[tokio::test]
async fn anonymous_cx_skips_authz() {
let files = svc(); // DenyingAuthzRepo
let cx = anon_cx(AppId::new());
// No principal → no authz check, even with a denying repo.
files.create(&cx, "c", new_file("f", b"x")).await.unwrap();
}
#[tokio::test]
async fn member_without_role_is_forbidden() {
let files = svc(); // DenyingAuthzRepo
let cx = member_cx(AppId::new());
let err = files
.create(&cx, "c", new_file("f", b"x"))
.await
.unwrap_err();
assert!(matches!(err, FilesError::Forbidden));
}
#[tokio::test]
async fn member_with_editor_role_allowed() {
let files = svc_with(
Arc::new(EditorAuthzRepo),
Arc::new(CapturingEmitter::default()),
);
let cx = member_cx(AppId::new());
files.create(&cx, "c", new_file("f", b"x")).await.unwrap();
}
#[tokio::test]
async fn mutations_emit_events_with_correct_prev() {
let emitter = Arc::new(CapturingEmitter::default());
let files = svc_with(Arc::new(DenyingAuthzRepo), emitter.clone());
let cx = anon_cx(AppId::new());
let id = files.create(&cx, "c", new_file("f", b"one")).await.unwrap();
files
.update(
&cx,
"c",
&id.to_string(),
FileUpdate {
data: b"two".to_vec(),
name: None,
content_type: None,
},
)
.await
.unwrap();
files.delete(&cx, "c", &id.to_string()).await.unwrap();
let events = emitter.events.lock().unwrap();
assert_eq!(events.len(), 3);
// create: prev is None
assert_eq!(events[0].op, "create");
assert_eq!(events[0].source, "files");
assert!(events[0].old_payload.is_none());
assert!(events[0].payload.is_some());
// update: prev is the prior metadata
assert_eq!(events[1].op, "update");
assert!(events[1].old_payload.is_some());
// delete: prev is the deleted metadata (payload == old_payload)
assert_eq!(events[2].op, "delete");
assert_eq!(events[2].payload, events[2].old_payload);
assert!(events[2].payload.is_some());
}
#[tokio::test]
async fn list_cursor_paginates() {
let files = svc();
let cx = anon_cx(AppId::new());
for i in 0..5 {
files
.create(&cx, "c", new_file(&format!("f{i}"), b"x"))
.await
.unwrap();
}
let p1 = files.list(&cx, "c", None, 2).await.unwrap();
assert_eq!(p1.files.len(), 2);
assert!(p1.next_cursor.is_some());
let p2 = files
.list(&cx, "c", p1.next_cursor.as_deref(), 2)
.await
.unwrap();
assert_eq!(p2.files.len(), 2);
let p3 = files
.list(&cx, "c", p2.next_cursor.as_deref(), 2)
.await
.unwrap();
assert_eq!(p3.files.len(), 1);
assert!(p3.next_cursor.is_none());
}
}

View File

@@ -0,0 +1,793 @@
//! `HttpServiceImpl` — reqwest-backed outbound HTTP for the v1.1.4
//! `http::*` SDK.
//!
//! Mirrors the v1.1.1+ stateful-service shape (`KvServiceImpl`):
//! script-as-gate authz (`AppHttpRequest`, skipped when
//! `cx.principal` is `None`), with the backend talking to the network
//! instead of Postgres. The reqwest client is built once at startup
//! with the [`crate::ssrf::SsrfResolver`] wired in via
//! `dns_resolver`, so the SSRF deny-list applies at every connection —
//! including each redirect hop, since redirects are followed manually
//! through the same client.
//!
//! Layering vs the raw client:
//! 1. URL validation: scheme must be http/https; ports 22/25/465/587
//! are blocked. (IP-level filtering is the resolver's job.)
//! 2. Body-size caps on both request and response (stream-with-cap on
//! the response, checking `Content-Length` first).
//! 3. Total-request timeout (default 30s, max 60s) on top of the
//! client's 10s connect timeout.
//! 4. Default `User-Agent` unless the caller set one.
//!
//! Bodies/headers are never logged (PII): only url + status + duration
//! at debug level.
use std::collections::BTreeMap;
use std::env;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use picloud_shared::{HttpError, HttpRequest, HttpResponse, HttpService, SdkCallCx};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, CONTENT_TYPE, LOCATION, USER_AGENT};
use reqwest::{Client, Method, StatusCode};
use crate::authz::{self, AuthzRepo, Capability};
use crate::ssrf::{self, SsrfPolicy, SSRF_BLOCK_PREFIX};
/// Default per-request timeout (ms) when the script omits `timeout_ms`.
pub const DEFAULT_TIMEOUT_MS: u32 = 30_000;
/// Hard ceiling on the per-request timeout. Values above this are
/// rejected by the bridge (not silently clamped).
pub const MAX_TIMEOUT_MS: u32 = 60_000;
/// Default redirect cap.
pub const DEFAULT_MAX_REDIRECTS: u32 = 5;
/// Hard ceiling on redirects.
pub const MAX_REDIRECTS_CEILING: u32 = 10;
/// 10 MB default body cap on both directions.
const DEFAULT_BODY_LIMIT_BYTES: usize = 10 * 1024 * 1024;
/// DNS + connect + TLS hard cap.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Outbound-HTTP tunables. Env-overridable following the same pattern
/// as `TriggerConfig::from_env`.
#[derive(Debug, Clone, Copy)]
pub struct HttpConfig {
/// Disables the SSRF deny-list entirely. Dev/test only — the binary
/// logs a startup warning when this is set.
pub allow_private: bool,
pub max_request_body_bytes: usize,
pub max_response_body_bytes: usize,
}
impl HttpConfig {
#[must_use]
pub const fn conservative() -> Self {
Self {
allow_private: false,
max_request_body_bytes: DEFAULT_BODY_LIMIT_BYTES,
max_response_body_bytes: DEFAULT_BODY_LIMIT_BYTES,
}
}
#[must_use]
pub fn from_env() -> Self {
let mut c = Self::conservative();
if let Ok(v) = env::var("PICLOUD_HTTP_ALLOW_PRIVATE") {
c.allow_private =
matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes");
}
load_usize(
&mut c.max_request_body_bytes,
"PICLOUD_HTTP_MAX_REQUEST_BODY_BYTES",
);
load_usize(
&mut c.max_response_body_bytes,
"PICLOUD_HTTP_MAX_RESPONSE_BODY_BYTES",
);
c
}
}
impl Default for HttpConfig {
fn default() -> Self {
Self::conservative()
}
}
fn load_usize(dst: &mut usize, key: &str) {
if let Ok(v) = env::var(key) {
match v.parse::<usize>() {
Ok(n) => *dst = n,
Err(e) => {
tracing::warn!(env = key, error = %e, "ignoring invalid http-config value");
}
}
}
}
pub struct HttpServiceImpl {
client: Client,
authz: Arc<dyn AuthzRepo>,
config: HttpConfig,
/// Same policy wired into the DNS resolver. Held here too because
/// reqwest only routes *hostnames* through the custom resolver — a
/// URL with a **literal IP** host bypasses it, so literal IPs are
/// checked directly at URL-validation time.
policy: SsrfPolicy,
}
impl HttpServiceImpl {
/// Build the service, constructing the reqwest client with the SSRF
/// resolver. Redirects are followed manually (so per-request limits
/// are honored and every hop re-resolves through the SSRF
/// resolver), hence `redirect(Policy::none())`.
///
/// # Panics
///
/// Panics if the reqwest client fails to build — this is a
/// startup-time invariant, not a runtime path.
#[must_use]
pub fn new(config: HttpConfig, authz: Arc<dyn AuthzRepo>) -> Self {
let policy = SsrfPolicy::new(config.allow_private);
let client = Client::builder()
.dns_resolver(ssrf::resolver(policy))
.connect_timeout(CONNECT_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("build outbound http client");
Self {
client,
authz,
config,
policy,
}
}
async fn check_request(&self, cx: &SdkCallCx) -> Result<(), HttpError> {
if let Some(ref principal) = cx.principal {
authz::require(
&*self.authz,
principal,
Capability::AppHttpRequest(cx.app_id),
)
.await
.map_err(|_| HttpError::Forbidden)?;
}
Ok(())
}
}
#[async_trait]
impl HttpService for HttpServiceImpl {
async fn request(&self, cx: &SdkCallCx, req: HttpRequest) -> Result<HttpResponse, HttpError> {
self.check_request(cx).await?;
// Request body cap.
if let Some(ref body) = req.body {
if body.len() > self.config.max_request_body_bytes {
return Err(HttpError::BodyTooLarge("request"));
}
}
let timeout = Duration::from_millis(u64::from(req.timeout_ms.min(MAX_TIMEOUT_MS)));
let started = std::time::Instant::now();
let url_for_log = req.url.clone();
// Whole-request budget (DNS + connect + TLS + all redirect hops
// + body read). Connect alone is further bounded by the
// client's CONNECT_TIMEOUT.
let outcome = match tokio::time::timeout(timeout, self.run(req)).await {
Ok(r) => r,
Err(_) => Err(HttpError::Timeout),
};
let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
match &outcome {
Ok(resp) => tracing::debug!(
url = %url_for_log,
status = resp.status,
duration_ms,
"outbound http"
),
Err(err) => tracing::debug!(
url = %url_for_log,
error = %err,
duration_ms,
"outbound http failed"
),
}
outcome
}
}
impl HttpServiceImpl {
/// Core request path: validate, build headers, follow redirects
/// manually, read the response body with a cap.
async fn run(&self, req: HttpRequest) -> Result<HttpResponse, HttpError> {
let method = Method::from_bytes(req.method.as_bytes())
.map_err(|_| HttpError::Backend(format!("invalid method: {}", req.method)))?;
let mut current = url::Url::parse(&req.url)
.map_err(|e| HttpError::InvalidUrl(format!("{}: {e}", req.url)))?;
validate_url(&current, self.policy)?;
let mut header_map = build_headers(&req, &current)?;
let mut method = method;
let mut body = req.body.clone();
let mut redirects: u32 = 0;
let max_redirects = req.max_redirects.min(MAX_REDIRECTS_CEILING);
loop {
// Re-validate scheme/port (and literal-IP SSRF) on each hop.
// Hostname IP filtering is the resolver's job and runs
// automatically at connect time.
validate_url(&current, self.policy)?;
let mut rb = self.client.request(method.clone(), current.clone());
rb = rb.headers(header_map.clone());
if let Some(ref b) = body {
rb = rb.body(b.clone());
}
let resp = rb.send().await.map_err(map_reqwest_err)?;
let status = resp.status();
if req.follow_redirects && is_redirect(status) {
if let Some(loc) = resp.headers().get(LOCATION) {
if redirects >= max_redirects {
return Err(HttpError::Backend(format!(
"too many redirects (max {max_redirects})"
)));
}
redirects += 1;
let loc_str = loc.to_str().map_err(|_| {
HttpError::Backend("redirect Location not valid UTF-8".into())
})?;
current = current
.join(loc_str)
.map_err(|e| HttpError::InvalidUrl(format!("redirect target: {e}")))?;
// 303 always → GET; 301/302 historically downgrade
// POST→GET (matches browsers). 307/308 preserve.
if matches!(status.as_u16(), 301..=303) {
method = Method::GET;
body = None;
header_map.remove(CONTENT_TYPE);
}
continue;
}
}
return self.read_capped(resp).await;
}
}
async fn read_capped(&self, resp: reqwest::Response) -> Result<HttpResponse, HttpError> {
let status = resp.status().as_u16();
let mut headers = BTreeMap::new();
for (name, value) in resp.headers() {
// Header names lowercased per the documented response shape.
headers.insert(
name.as_str().to_ascii_lowercase(),
value.to_str().unwrap_or("").to_string(),
);
}
let cap = self.config.max_response_body_bytes;
if let Some(len) = resp.content_length() {
if len > cap as u64 {
return Err(HttpError::BodyTooLarge("response"));
}
}
let mut buf: Vec<u8> = Vec::new();
let mut resp = resp;
while let Some(chunk) = resp.chunk().await.map_err(map_reqwest_err)? {
if buf.len() + chunk.len() > cap {
return Err(HttpError::BodyTooLarge("response"));
}
buf.extend_from_slice(&chunk);
}
let body_raw = String::from_utf8_lossy(&buf).into_owned();
Ok(HttpResponse {
status,
headers,
body_raw,
})
}
}
/// http/https only; block the SSH + SMTP ports; apply the SSRF policy
/// to **literal-IP** hosts (hostnames are filtered by the DNS resolver
/// at connect time, but literal IPs never reach the resolver).
fn validate_url(url: &url::Url, policy: SsrfPolicy) -> Result<(), HttpError> {
match url.scheme() {
"http" | "https" => {}
other => return Err(HttpError::BlockedScheme(other.to_string())),
}
match url.host() {
None => return Err(HttpError::InvalidUrl("missing host".into())),
Some(url::Host::Ipv4(ip)) => {
policy
.check(std::net::IpAddr::V4(ip))
.map_err(|reason| HttpError::Ssrf(reason.to_string()))?;
}
Some(url::Host::Ipv6(ip)) => {
policy
.check(std::net::IpAddr::V6(ip))
.map_err(|reason| HttpError::Ssrf(reason.to_string()))?;
}
Some(url::Host::Domain(_)) => {}
}
let port = url
.port_or_known_default()
.unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
if matches!(port, 22 | 25 | 465 | 587) {
return Err(HttpError::BlockedPort(port));
}
Ok(())
}
/// Build the request header map: merge caller headers, then apply the
/// default `User-Agent` (unless overridden) and the bridge-chosen
/// `Content-Type` (unless overridden).
fn build_headers(req: &HttpRequest, _url: &url::Url) -> Result<HeaderMap, HttpError> {
let mut map = HeaderMap::new();
let mut has_user_agent = false;
let mut has_content_type = false;
for (k, v) in &req.headers {
let name = HeaderName::from_bytes(k.as_bytes())
.map_err(|_| HttpError::Backend(format!("invalid header name: {k}")))?;
let value = HeaderValue::from_str(v)
.map_err(|_| HttpError::Backend(format!("invalid header value for {k}")))?;
if name == USER_AGENT {
has_user_agent = true;
}
if name == CONTENT_TYPE {
has_content_type = true;
}
map.append(name, value);
}
if !has_user_agent {
let script = req.script_id.as_deref().unwrap_or("unknown");
let ua = format!(
"picloud/{} (script:{})",
picloud_shared::PRODUCT_VERSION,
script
);
if let Ok(value) = HeaderValue::from_str(&ua) {
map.insert(USER_AGENT, value);
}
}
if !has_content_type {
if let Some(ref ct) = req.content_type {
if let Ok(value) = HeaderValue::from_str(ct) {
map.insert(CONTENT_TYPE, value);
}
}
}
Ok(map)
}
const fn is_redirect(status: StatusCode) -> bool {
matches!(status.as_u16(), 301..=303 | 307 | 308)
}
/// Map a reqwest error to an `HttpError`, never leaking the resolved
/// IP. SSRF blocks are detected by scanning the error source chain for
/// the resolver's marker prefix.
fn map_reqwest_err(err: reqwest::Error) -> HttpError {
if let Some(reason) = ssrf_reason(&err) {
return HttpError::Ssrf(reason);
}
if err.is_timeout() {
return HttpError::Timeout;
}
if err.is_connect() {
return HttpError::Network("connection failed".into());
}
if err.is_request() {
return HttpError::Network("request failed".into());
}
HttpError::Network("network error".into())
}
/// Walk the error source chain looking for the SSRF marker the resolver
/// embeds. Returns the category reason (no IP) when found.
fn ssrf_reason(err: &reqwest::Error) -> Option<String> {
let mut src: Option<&(dyn std::error::Error + 'static)> = Some(err);
while let Some(e) = src {
let s = e.to_string();
if let Some(idx) = s.find(SSRF_BLOCK_PREFIX) {
return Some(s[idx + SSRF_BLOCK_PREFIX.len()..].to_string());
}
src = e.source();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::authz::AuthzError;
use async_trait::async_trait;
use picloud_shared::{
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
UserId,
};
use std::collections::BTreeMap;
use std::io::Write as _;
use std::net::SocketAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
struct AllowAuthz;
#[async_trait]
impl AuthzRepo for AllowAuthz {
async fn membership(&self, _u: UserId, _a: AppId) -> Result<Option<AppRole>, AuthzError> {
Ok(Some(AppRole::Editor))
}
}
struct DenyAuthz;
#[async_trait]
impl AuthzRepo for DenyAuthz {
async fn membership(&self, _u: UserId, _a: AppId) -> Result<Option<AppRole>, AuthzError> {
Ok(None)
}
}
fn dev_service(authz: Arc<dyn AuthzRepo>) -> HttpServiceImpl {
// allow_private so the test TcpListener on 127.0.0.1 is reachable.
let mut config = HttpConfig::conservative();
config.allow_private = true;
HttpServiceImpl::new(config, authz)
}
fn anon_cx() -> SdkCallCx {
SdkCallCx {
app_id: AppId::new(),
script_id: ScriptId::new(),
principal: None,
execution_id: ExecutionId::new(),
request_id: RequestId::new(),
trigger_depth: 0,
root_execution_id: ExecutionId::new(),
is_dead_letter_handler: false,
event: None,
}
}
fn member_cx() -> SdkCallCx {
let mut cx = anon_cx();
cx.principal = Some(Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Member,
scopes: None,
app_binding: None,
});
cx
}
fn req(method: &str, url: String) -> HttpRequest {
HttpRequest {
method: method.into(),
url,
headers: BTreeMap::new(),
body: None,
content_type: None,
timeout_ms: 5000,
follow_redirects: true,
max_redirects: 5,
script_id: Some("test-script".into()),
}
}
/// Minimal single-shot HTTP/1.1 server. Reads the request, runs
/// `handler` to produce the raw response bytes, writes them, closes.
/// Returns the bound address.
async fn spawn_server<F>(handler: F) -> SocketAddr
where
F: Fn(String) -> Vec<u8> + Send + Sync + 'static,
{
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
let mut buf = vec![0u8; 65536];
let n = sock.read(&mut buf).await.unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]).to_string();
let response = handler(request);
let _ = sock.write_all(&response).await;
let _ = sock.flush().await;
}
});
addr
}
fn ok_response(body: &str, content_type: &str) -> Vec<u8> {
let mut v = Vec::new();
write!(
v,
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.unwrap();
v
}
#[tokio::test]
async fn get_round_trip() {
let addr = spawn_server(|_req| ok_response("hello", "text/plain")).await;
let svc = dev_service(Arc::new(AllowAuthz));
let resp = svc
.request(&anon_cx(), req("GET", format!("http://{addr}/")))
.await
.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body_raw, "hello");
assert_eq!(
resp.headers.get("content-type").map(String::as_str),
Some("text/plain")
);
}
#[tokio::test]
async fn post_sends_body_and_default_user_agent() {
let addr = spawn_server(|request| {
// Echo back whether the body + default UA were present.
let has_ua = request.to_lowercase().contains("user-agent: picloud/");
let has_body = request.contains("xyzzy");
ok_response(&format!("ua={has_ua},body={has_body}"), "text/plain")
})
.await;
let svc = dev_service(Arc::new(AllowAuthz));
let mut r = req("POST", format!("http://{addr}/"));
r.body = Some(b"xyzzy".to_vec());
r.content_type = Some("text/plain".into());
let resp = svc.request(&anon_cx(), r).await.unwrap();
assert_eq!(resp.body_raw, "ua=true,body=true");
}
#[tokio::test]
async fn custom_user_agent_overrides_default() {
let addr = spawn_server(|request| {
let has_custom = request.to_lowercase().contains("user-agent: my-agent");
let has_default = request.to_lowercase().contains("picloud/");
ok_response(
&format!("custom={has_custom},default={has_default}"),
"text/plain",
)
})
.await;
let svc = dev_service(Arc::new(AllowAuthz));
let mut r = req("GET", format!("http://{addr}/"));
r.headers.insert("User-Agent".into(), "my-agent".into());
let resp = svc.request(&anon_cx(), r).await.unwrap();
assert_eq!(resp.body_raw, "custom=true,default=false");
}
#[tokio::test]
async fn empty_body_response() {
let addr = spawn_server(|_r| {
b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec()
})
.await;
let svc = dev_service(Arc::new(AllowAuthz));
let resp = svc
.request(&anon_cx(), req("GET", format!("http://{addr}/")))
.await
.unwrap();
assert_eq!(resp.status, 204);
assert_eq!(resp.body_raw, "");
}
#[tokio::test]
async fn non_2xx_does_not_error() {
let addr = spawn_server(|_r| {
b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 3\r\nConnection: close\r\n\r\nerr".to_vec()
})
.await;
let svc = dev_service(Arc::new(AllowAuthz));
let resp = svc
.request(&anon_cx(), req("GET", format!("http://{addr}/")))
.await
.unwrap();
assert_eq!(resp.status, 500);
assert_eq!(resp.body_raw, "err");
}
#[tokio::test]
async fn response_over_content_length_cap_rejected() {
let addr = spawn_server(|_r| ok_response("0123456789", "text/plain")).await;
let mut config = HttpConfig::conservative();
config.allow_private = true;
config.max_response_body_bytes = 5; // body is 10 bytes
let svc = HttpServiceImpl::new(config, Arc::new(AllowAuthz));
let err = svc
.request(&anon_cx(), req("GET", format!("http://{addr}/")))
.await
.unwrap_err();
assert!(matches!(err, HttpError::BodyTooLarge("response")));
}
#[tokio::test]
async fn response_over_cap_without_content_length_rejected_mid_stream() {
// No Content-Length header → must be caught while streaming.
let addr = spawn_server(|_r| {
b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n0123456789ABCDEF".to_vec()
})
.await;
let mut config = HttpConfig::conservative();
config.allow_private = true;
config.max_response_body_bytes = 4;
let svc = HttpServiceImpl::new(config, Arc::new(AllowAuthz));
let err = svc
.request(&anon_cx(), req("GET", format!("http://{addr}/")))
.await
.unwrap_err();
assert!(matches!(err, HttpError::BodyTooLarge("response")));
}
#[tokio::test]
async fn request_body_over_cap_rejected_before_send() {
let mut config = HttpConfig::conservative();
config.allow_private = true;
config.max_request_body_bytes = 3;
let svc = HttpServiceImpl::new(config, Arc::new(AllowAuthz));
let mut r = req("POST", "http://127.0.0.1:1/".into());
r.body = Some(b"too long".to_vec());
let err = svc.request(&anon_cx(), r).await.unwrap_err();
assert!(matches!(err, HttpError::BodyTooLarge("request")));
}
#[tokio::test]
async fn redirect_followed_up_to_then_throws_beyond_max() {
// Server always 302s to itself → unbounded redirect loop,
// bounded by max_redirects.
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
let mut buf = vec![0u8; 4096];
let _ = sock.read(&mut buf).await;
let body = format!(
"HTTP/1.1 302 Found\r\nLocation: http://{addr}/next\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
let _ = sock.write_all(body.as_bytes()).await;
}
});
let svc = dev_service(Arc::new(AllowAuthz));
let mut r = req("GET", format!("http://{addr}/"));
r.max_redirects = 2;
let err = svc.request(&anon_cx(), r).await.unwrap_err();
assert!(
matches!(err, HttpError::Backend(ref m) if m.contains("too many redirects")),
"expected too-many-redirects, got {err:?}"
);
}
#[tokio::test]
async fn scheme_rejected() {
let svc = dev_service(Arc::new(AllowAuthz));
for url in ["file:///etc/passwd", "ftp://host/x", "gopher://host/"] {
let err = svc
.request(&anon_cx(), req("GET", url.into()))
.await
.unwrap_err();
match err {
HttpError::BlockedScheme(s) => {
assert!(url.starts_with(&s), "scheme {s} not in url {url}");
}
other => panic!("expected BlockedScheme for {url}, got {other:?}"),
}
}
}
#[tokio::test]
async fn ports_rejected() {
let svc = dev_service(Arc::new(AllowAuthz));
for port in [22u16, 25, 465, 587] {
let err = svc
.request(
&anon_cx(),
req("GET", format!("http://example.com:{port}/")),
)
.await
.unwrap_err();
assert!(
matches!(err, HttpError::BlockedPort(p) if p == port),
"port {port} should be blocked, got {err:?}"
);
}
}
#[tokio::test]
async fn ssrf_blocks_loopback_without_allow_private() {
// Default config (deny-list ON). A request to a loopback host
// must surface as Ssrf, not a generic network error.
let svc = HttpServiceImpl::new(HttpConfig::conservative(), Arc::new(AllowAuthz));
let err = svc
.request(&anon_cx(), req("GET", "http://127.0.0.1:9/".into()))
.await
.unwrap_err();
match err {
HttpError::Ssrf(reason) => {
assert_eq!(reason, "loopback");
assert!(!reason.contains("127.0.0.1"), "reason must not leak the IP");
}
other => panic!("expected Ssrf, got {other:?}"),
}
}
#[tokio::test]
async fn ssrf_blocks_hostname_resolving_to_loopback() {
// `localhost` resolves to 127.0.0.1 / ::1 — all denied. This
// exercises the DNS-resolver path (vs the literal-IP path) and
// must surface as Ssrf, not a generic DNS error.
let svc = HttpServiceImpl::new(HttpConfig::conservative(), Arc::new(AllowAuthz));
let err = svc
.request(&anon_cx(), req("GET", "http://localhost:9/".into()))
.await
.unwrap_err();
assert!(
matches!(err, HttpError::Ssrf(_)),
"expected Ssrf for localhost, got {err:?}"
);
}
#[tokio::test]
async fn timeout_throws() {
// Server that accepts then never responds.
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
if let Ok((sock, _)) = listener.accept().await {
// Hold the socket open without replying.
tokio::time::sleep(Duration::from_secs(30)).await;
drop(sock);
}
});
let svc = dev_service(Arc::new(AllowAuthz));
let mut r = req("GET", format!("http://{addr}/"));
r.timeout_ms = 300;
let err = svc.request(&anon_cx(), r).await.unwrap_err();
assert!(matches!(err, HttpError::Timeout), "got {err:?}");
}
#[tokio::test]
async fn anon_skips_authz_member_without_scope_forbidden() {
let addr = spawn_server(|_r| ok_response("ok", "text/plain")).await;
// Anonymous principal → authz skipped even with DenyAuthz.
let svc = dev_service(Arc::new(DenyAuthz));
let ok = svc
.request(&anon_cx(), req("GET", format!("http://{addr}/")))
.await;
assert!(ok.is_ok());
// Authenticated member with no role → Forbidden.
let err = svc
.request(&member_cx(), req("GET", format!("http://{addr}/")))
.await
.unwrap_err();
assert!(matches!(err, HttpError::Forbidden));
}
#[tokio::test]
async fn member_with_role_allowed() {
let addr = spawn_server(|_r| ok_response("ok", "text/plain")).await;
let svc = dev_service(Arc::new(AllowAuthz));
let resp = svc
.request(&member_cx(), req("GET", format!("http://{addr}/")))
.await
.unwrap();
assert_eq!(resp.status, 200);
}
}

View File

@@ -188,7 +188,7 @@ mod tests {
use async_trait::async_trait; use async_trait::async_trait;
use picloud_shared::{ use picloud_shared::{
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, NoopEventEmitter, Principal, AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, NoopEventEmitter, Principal,
RequestId, UserId, RequestId, ScriptId, UserId,
}; };
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -301,6 +301,7 @@ mod tests {
fn anon_cx(app_id: AppId) -> SdkCallCx { fn anon_cx(app_id: AppId) -> SdkCallCx {
SdkCallCx { SdkCallCx {
app_id, app_id,
script_id: ScriptId::new(),
principal: None, principal: None,
execution_id: ExecutionId::new(), execution_id: ExecutionId::new(),
request_id: RequestId::new(), request_id: RequestId::new(),
@@ -314,6 +315,7 @@ mod tests {
fn owner_cx(app_id: AppId) -> SdkCallCx { fn owner_cx(app_id: AppId) -> SdkCallCx {
SdkCallCx { SdkCallCx {
app_id, app_id,
script_id: ScriptId::new(),
principal: Some(Principal { principal: Some(Principal {
user_id: AdminUserId::new(), user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner, instance_role: InstanceRole::Owner,
@@ -332,6 +334,7 @@ mod tests {
fn member_no_role_cx(app_id: AppId) -> SdkCallCx { fn member_no_role_cx(app_id: AppId) -> SdkCallCx {
SdkCallCx { SdkCallCx {
app_id, app_id,
script_id: ScriptId::new(),
principal: Some(Principal { principal: Some(Principal {
user_id: AdminUserId::new(), user_id: AdminUserId::new(),
instance_role: InstanceRole::Member, instance_role: InstanceRole::Member,

View File

@@ -22,6 +22,7 @@ pub mod auth_api;
pub mod auth_bootstrap; pub mod auth_bootstrap;
pub mod auth_middleware; pub mod auth_middleware;
pub mod authz; pub mod authz;
pub mod cron_scheduler;
pub mod dead_letter_repo; pub mod dead_letter_repo;
pub mod dead_letter_service; pub mod dead_letter_service;
pub mod dead_letters_api; pub mod dead_letters_api;
@@ -29,7 +30,11 @@ pub mod dispatcher;
pub mod docs_filter; pub mod docs_filter;
pub mod docs_repo; pub mod docs_repo;
pub mod docs_service; pub mod docs_service;
pub mod files_api;
pub mod files_repo;
pub mod files_service;
pub mod gc; pub mod gc;
pub mod http_service;
pub mod kv_repo; pub mod kv_repo;
pub mod kv_service; pub mod kv_service;
pub mod log_sink; pub mod log_sink;
@@ -38,11 +43,14 @@ pub mod module_source;
pub mod outbox_event_emitter; pub mod outbox_event_emitter;
pub mod outbox_repo; pub mod outbox_repo;
pub mod principal_resolver; pub mod principal_resolver;
pub mod pubsub_repo;
pub mod pubsub_service;
pub mod repo; pub mod repo;
pub mod route_admin; pub mod route_admin;
pub mod route_repo; pub mod route_repo;
pub mod sandbox; pub mod sandbox;
pub mod scheduler; pub mod scheduler;
pub mod ssrf;
pub mod trigger_config; pub mod trigger_config;
pub mod trigger_repo; pub mod trigger_repo;
pub mod triggers_api; pub mod triggers_api;
@@ -84,6 +92,7 @@ pub use auth_middleware::{
API_KEY_PREFIX, API_KEY_PREFIX_LEN, SESSION_COOKIE, API_KEY_PREFIX, API_KEY_PREFIX_LEN, SESSION_COOKIE,
}; };
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision}; pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
pub use cron_scheduler::spawn_cron_scheduler;
pub use dead_letter_repo::{ pub use dead_letter_repo::{
DeadLetterRepo, DeadLetterRepoError, DeadLetterRow, NewDeadLetter, PostgresDeadLetterRepo, DeadLetterRepo, DeadLetterRepoError, DeadLetterRow, NewDeadLetter, PostgresDeadLetterRepo,
}; };
@@ -92,7 +101,11 @@ pub use dead_letters_api::{dead_letters_router, DeadLettersApiError, DeadLetters
pub use dispatcher::{compute_backoff, Dispatcher, DispatcherError}; pub use dispatcher::{compute_backoff, Dispatcher, DispatcherError};
pub use docs_repo::{DocsRepo, DocsRepoError, PostgresDocsRepo}; pub use docs_repo::{DocsRepo, DocsRepoError, PostgresDocsRepo};
pub use docs_service::DocsServiceImpl; pub use docs_service::DocsServiceImpl;
pub use files_api::{files_admin_router, FilesAdminState};
pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo};
pub use files_service::FilesServiceImpl;
pub use gc::{spawn_abandoned_gc, spawn_dead_letter_gc}; pub use gc::{spawn_abandoned_gc, spawn_dead_letter_gc};
pub use http_service::{HttpConfig, HttpServiceImpl};
pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo}; pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo};
pub use kv_service::KvServiceImpl; pub use kv_service::KvServiceImpl;
pub use log_sink::PostgresExecutionLogSink; pub use log_sink::PostgresExecutionLogSink;
@@ -102,6 +115,8 @@ pub use outbox_repo::{
NewOutboxRow, OutboxRepo, OutboxRepoError, OutboxRow, OutboxSourceKind, PostgresOutboxRepo, NewOutboxRow, OutboxRepo, OutboxRepoError, OutboxRow, OutboxSourceKind, PostgresOutboxRepo,
}; };
pub use principal_resolver::{AdminPrincipalResolver, PrincipalResolver, PrincipalResolverError}; pub use principal_resolver::{AdminPrincipalResolver, PrincipalResolver, PrincipalResolverError};
pub use pubsub_repo::{PostgresPubsubRepo, PublishCtx, PubsubRepo, PubsubRepoError};
pub use pubsub_service::PubsubServiceImpl;
pub use repo::{ pub use repo::{
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository, ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
RepoResolver, ScriptPatch, ScriptRepository, ScriptRepositoryError, RepoResolver, ScriptPatch, ScriptRepository, ScriptRepositoryError,
@@ -111,8 +126,9 @@ pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository};
pub use sandbox::{CeilingError, SandboxCeiling}; pub use sandbox::{CeilingError, SandboxCeiling};
pub use trigger_config::{BackoffShape, TriggerConfig}; pub use trigger_config::{BackoffShape, TriggerConfig};
pub use trigger_repo::{ pub use trigger_repo::{
collection_matches, CreateDeadLetterTrigger, CreateDocsTrigger, CreateKvTrigger, collection_matches, CreateDeadLetterTrigger, CreateDocsTrigger, CreateFilesTrigger,
DeadLetterTriggerMatch, DocsTriggerMatch, KvTriggerMatch, PostgresTriggerRepo, Trigger, CreateKvTrigger, CreatePubsubTrigger, DeadLetterTriggerMatch, DocsTriggerMatch,
TriggerDetails, TriggerDispatchMode, TriggerKind, TriggerRepo, TriggerRepoError, FilesTriggerMatch, KvTriggerMatch, PostgresTriggerRepo, Trigger, TriggerDetails,
TriggerDispatchMode, TriggerKind, TriggerRepo, TriggerRepoError,
}; };
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState}; pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};

View File

@@ -19,7 +19,8 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use picloud_shared::{ use picloud_shared::{
DocsEventOp, EmitError, KvEventOp, SdkCallCx, ServiceEvent, ServiceEventEmitter, TriggerEvent, DocsEventOp, EmitError, FileMeta, FilesEventOp, KvEventOp, SdkCallCx, ServiceEvent,
ServiceEventEmitter, TriggerEvent,
}; };
use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxSourceKind}; use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxSourceKind};
@@ -43,6 +44,7 @@ impl ServiceEventEmitter for OutboxEventEmitter {
match event.source { match event.source {
"kv" => self.emit_kv(cx, event).await, "kv" => self.emit_kv(cx, event).await,
"docs" => self.emit_docs(cx, event).await, "docs" => self.emit_docs(cx, event).await,
"files" => self.emit_files(cx, event).await,
// Future sources land here. For now, silently drop — the // Future sources land here. For now, silently drop — the
// SDK calls `events.emit(...)` unconditionally for forward // SDK calls `events.emit(...)` unconditionally for forward
// compat, so swallowing without an error is correct. // compat, so swallowing without an error is correct.
@@ -154,4 +156,68 @@ impl OutboxEventEmitter {
} }
Ok(()) Ok(())
} }
/// v1.1.5. Fan out a files mutation across matching files triggers.
/// The `ServiceEvent.payload` is the file **metadata** (never the
/// blob bytes); `old_payload` is the prior metadata (the deleted
/// row's metadata on delete). The `TriggerEvent::Files` carries the
/// metadata fields explicitly + `prev` for the change-data-capture
/// surface.
async fn emit_files(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> {
let Some(op) = FilesEventOp::from_wire(event.op) else {
return Ok(());
};
let Some(collection) = event.collection.clone() else {
return Ok(());
};
// The payload is the FileMeta JSON the FilesServiceImpl emitted.
let Some(meta) = event
.payload
.clone()
.and_then(|v| serde_json::from_value::<FileMeta>(v).ok())
else {
return Ok(());
};
let matches = self
.triggers
.list_matching_files(cx.app_id, &collection, op)
.await
.map_err(|e| EmitError::Unavailable(format!("trigger lookup: {e}")))?;
if matches.is_empty() {
return Ok(());
}
let trigger_event = TriggerEvent::Files {
op,
collection,
id: meta.id.to_string(),
name: meta.name,
content_type: meta.content_type,
size: meta.size,
checksum: meta.checksum,
prev: event.old_payload.clone(),
};
let payload = serde_json::to_value(&trigger_event)
.map_err(|e| EmitError::Rejected(format!("event serialize: {e}")))?;
for m in matches {
self.outbox
.insert(NewOutboxRow {
app_id: cx.app_id,
source_kind: OutboxSourceKind::Files,
trigger_id: Some(m.trigger_id),
script_id: Some(m.script_id),
reply_to: None,
payload: payload.clone(),
origin_principal: cx.principal.as_ref().map(|p| p.user_id),
trigger_depth: cx.trigger_depth.saturating_add(1),
root_execution_id: Some(cx.root_execution_id),
})
.await
.map_err(|e| EmitError::Unavailable(format!("outbox insert: {e}")))?;
}
Ok(())
}
} }

View File

@@ -25,6 +25,12 @@ pub enum OutboxSourceKind {
/// v1.1.2. /// v1.1.2.
Docs, Docs,
DeadLetter, DeadLetter,
/// v1.1.4.
Cron,
/// v1.1.5.
Files,
/// v1.1.5.
Pubsub,
} }
impl OutboxSourceKind { impl OutboxSourceKind {
@@ -35,6 +41,9 @@ impl OutboxSourceKind {
Self::Kv => "kv", Self::Kv => "kv",
Self::Docs => "docs", Self::Docs => "docs",
Self::DeadLetter => "dead_letter", Self::DeadLetter => "dead_letter",
Self::Cron => "cron",
Self::Files => "files",
Self::Pubsub => "pubsub",
} }
} }
@@ -45,6 +54,9 @@ impl OutboxSourceKind {
"kv" => Some(Self::Kv), "kv" => Some(Self::Kv),
"docs" => Some(Self::Docs), "docs" => Some(Self::Docs),
"dead_letter" => Some(Self::DeadLetter), "dead_letter" => Some(Self::DeadLetter),
"cron" => Some(Self::Cron),
"files" => Some(Self::Files),
"pubsub" => Some(Self::Pubsub),
_ => None, _ => None,
} }
} }

View File

@@ -0,0 +1,118 @@
//! `PubsubRepo` — publish-time fan-out for the v1.1.5 `pubsub::*` SDK.
//!
//! `publish_durable` writes one outbox row per matching enabled `pubsub`
//! trigger, all inside a single transaction so a partial fan-out (some
//! subscribers got rows, others didn't, then a crash) can't happen.
//! Each delivery row then retries / dead-letters independently through
//! the existing dispatcher — no pub/sub-specific dispatch branching.
//!
//! Topic pattern matching runs in Rust (`picloud_shared::topic_matches`)
//! against the small set of the app's enabled pubsub triggers, keeping
//! the SELECT trivial. v1.2 can add a topic-trie index if fan-out
//! becomes a hot path.
use async_trait::async_trait;
use picloud_shared::{topic_matches, AdminUserId, AppId, ExecutionId};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum PubsubRepoError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
}
/// The execution-context bits a fan-out needs to stamp onto each outbox
/// row. Derived from the publishing script's `SdkCallCx`.
#[derive(Debug, Clone, Copy)]
pub struct PublishCtx {
pub app_id: AppId,
pub origin_principal: Option<AdminUserId>,
pub trigger_depth: u32,
pub root_execution_id: ExecutionId,
}
#[async_trait]
pub trait PubsubRepo: Send + Sync {
/// Fan out a publish to every matching enabled pubsub trigger in
/// `ctx.app_id`, inserting one outbox row each in a SINGLE
/// transaction. `event_payload` is the serialized
/// `TriggerEvent::Pubsub`. Returns the number of delivery rows
/// written (0 when no trigger matched — the publish still succeeds).
async fn fan_out_publish(
&self,
ctx: PublishCtx,
topic: &str,
event_payload: serde_json::Value,
) -> Result<u32, PubsubRepoError>;
}
pub struct PostgresPubsubRepo {
pool: PgPool,
}
impl PostgresPubsubRepo {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct PubsubTriggerRow {
id: Uuid,
script_id: Uuid,
topic_pattern: String,
}
#[async_trait]
impl PubsubRepo for PostgresPubsubRepo {
async fn fan_out_publish(
&self,
ctx: PublishCtx,
topic: &str,
event_payload: serde_json::Value,
) -> Result<u32, PubsubRepoError> {
let mut tx = self.pool.begin().await?;
// Load all enabled pubsub triggers for the app; filter by topic
// pattern in Rust (keeps the query simple, honours the
// empty/`*`/prefix semantics without teaching SQL about globs).
let rows: Vec<PubsubTriggerRow> = sqlx::query_as(
"SELECT t.id, t.script_id, d.topic_pattern \
FROM triggers t \
JOIN pubsub_trigger_details d ON d.trigger_id = t.id \
WHERE t.app_id = $1 AND t.kind = 'pubsub' AND t.enabled = TRUE",
)
.bind(ctx.app_id.into_inner())
.fetch_all(&mut *tx)
.await?;
let mut written: u32 = 0;
for r in rows {
if !topic_matches(&r.topic_pattern, topic) {
continue;
}
sqlx::query(
"INSERT INTO outbox ( \
app_id, source_kind, trigger_id, script_id, reply_to, \
payload, origin_principal, trigger_depth, root_execution_id \
) VALUES ($1, 'pubsub', $2, $3, NULL, $4, $5, $6, $7)",
)
.bind(ctx.app_id.into_inner())
.bind(r.id)
.bind(r.script_id)
.bind(&event_payload)
.bind(ctx.origin_principal.map(AdminUserId::into_inner))
.bind(i32::try_from(ctx.trigger_depth.saturating_add(1)).unwrap_or(1))
.bind(ctx.root_execution_id.into_inner())
.execute(&mut *tx)
.await?;
written += 1;
}
// Commit once — all rows or none.
tx.commit().await?;
Ok(written)
}
}

View File

@@ -0,0 +1,320 @@
//! `PubsubServiceImpl` — wires `PubsubRepo` underneath the
//! `picloud_shared::PubsubService` trait scripts see via the Rhai
//! bridge.
//!
//! Mirrors the other stateful services: script-as-gate authz
//! (`AppPubsubPublish`, skipped when `cx.principal` is `None`), with the
//! backend doing a publish-time outbox fan-out instead of a row write.
//! No `ServiceEventEmitter` here — pub/sub publishes directly to the
//! outbox; it doesn't mutate local data that other triggers observe.
use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{PubsubError, PubsubService, SdkCallCx, TriggerEvent};
use crate::authz::{self, AuthzRepo, Capability};
use crate::pubsub_repo::{PublishCtx, PubsubRepo, PubsubRepoError};
pub struct PubsubServiceImpl {
repo: Arc<dyn PubsubRepo>,
authz: Arc<dyn AuthzRepo>,
}
impl PubsubServiceImpl {
#[must_use]
pub fn new(repo: Arc<dyn PubsubRepo>, authz: Arc<dyn AuthzRepo>) -> Self {
Self { repo, authz }
}
async fn check_publish(&self, cx: &SdkCallCx) -> Result<(), PubsubError> {
if let Some(ref principal) = cx.principal {
authz::require(
&*self.authz,
principal,
Capability::AppPubsubPublish(cx.app_id),
)
.await
.map_err(|_| PubsubError::Forbidden)?;
}
Ok(())
}
}
impl From<PubsubRepoError> for PubsubError {
fn from(e: PubsubRepoError) -> Self {
Self::Unavailable(e.to_string())
}
}
#[async_trait]
impl PubsubService for PubsubServiceImpl {
async fn publish_durable(
&self,
cx: &SdkCallCx,
topic: &str,
message: serde_json::Value,
) -> Result<(), PubsubError> {
if topic.trim().is_empty() {
return Err(PubsubError::EmptyTopic);
}
self.check_publish(cx).await?;
// `published_at` is stamped on the manager side so every
// delivery agrees on one instant.
let event = TriggerEvent::Pubsub {
topic: topic.to_string(),
message,
published_at: chrono::Utc::now(),
};
let payload = serde_json::to_value(&event)
.map_err(|e| PubsubError::Rejected(format!("event serialize: {e}")))?;
let publish_ctx = PublishCtx {
app_id: cx.app_id,
origin_principal: cx.principal.as_ref().map(|p| p.user_id),
trigger_depth: cx.trigger_depth,
root_execution_id: cx.root_execution_id,
};
// No matching triggers → 0 rows written, publish still succeeds.
self.repo
.fan_out_publish(publish_ctx, topic, payload)
.await?;
Ok(())
}
}
// ----------------------------------------------------------------------------
// Tests — in-memory PubsubRepo so unit tests don't need Postgres. The
// real transactional fan-out is covered against a live DB by the
// integration suite; the in-memory fake models the all-or-nothing
// commit so the rollback semantics can be asserted without a DB.
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::authz::{AuthzError, AuthzRepo};
use async_trait::async_trait;
use picloud_shared::{
topic_matches, AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal,
RequestId, ScriptId, UserId,
};
use std::sync::Mutex;
/// In-memory pubsub repo. Holds a set of `(app, pattern)`
/// subscriptions and records the outbox rows a publish would write.
/// `fail_at` simulates a mid-fan-out INSERT failure: when set to
/// `Some(n)`, the n-th (1-indexed) matching row errors and NOTHING
/// is recorded — modelling the single-transaction rollback.
struct InMemoryPubsubRepo {
subs: Vec<(AppId, String)>,
written: Mutex<Vec<(AppId, String)>>,
fail_at: Option<usize>,
}
impl InMemoryPubsubRepo {
fn new(subs: Vec<(AppId, String)>) -> Self {
Self {
subs,
written: Mutex::new(Vec::new()),
fail_at: None,
}
}
fn written_count(&self) -> usize {
self.written.lock().unwrap().len()
}
}
#[async_trait]
impl PubsubRepo for InMemoryPubsubRepo {
async fn fan_out_publish(
&self,
ctx: PublishCtx,
topic: &str,
_event_payload: serde_json::Value,
) -> Result<u32, PubsubRepoError> {
let matches: Vec<&(AppId, String)> = self
.subs
.iter()
.filter(|(a, pat)| *a == ctx.app_id && topic_matches(pat, topic))
.collect();
let mut staged = Vec::new();
for (i, _) in matches.iter().enumerate() {
if self.fail_at == Some(i + 1) {
// Rollback: nothing was committed.
return Err(PubsubRepoError::Db(sqlx::Error::Protocol(
"simulated insert failure".into(),
)));
}
staged.push((ctx.app_id, topic.to_string()));
}
let n = staged.len();
self.written.lock().unwrap().extend(staged);
Ok(u32::try_from(n).unwrap_or(u32::MAX))
}
}
#[derive(Default)]
struct DenyingAuthzRepo;
#[async_trait]
impl AuthzRepo for DenyingAuthzRepo {
async fn membership(
&self,
_user_id: UserId,
_app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(None)
}
}
#[derive(Default)]
struct EditorAuthzRepo;
#[async_trait]
impl AuthzRepo for EditorAuthzRepo {
async fn membership(
&self,
_user_id: UserId,
_app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(Some(AppRole::Editor))
}
}
fn anon_cx(app_id: AppId) -> SdkCallCx {
SdkCallCx {
app_id,
script_id: ScriptId::new(),
principal: None,
execution_id: ExecutionId::new(),
request_id: RequestId::new(),
trigger_depth: 0,
root_execution_id: ExecutionId::new(),
is_dead_letter_handler: false,
event: None,
}
}
fn member_cx(app_id: AppId) -> SdkCallCx {
SdkCallCx {
principal: Some(Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Member,
scopes: None,
app_binding: None,
}),
..anon_cx(app_id)
}
}
fn svc(repo: Arc<dyn PubsubRepo>, authz: Arc<dyn AuthzRepo>) -> PubsubServiceImpl {
PubsubServiceImpl::new(repo, authz)
}
#[tokio::test]
async fn publish_writes_one_row_per_matching_trigger() {
let app = AppId::new();
let repo = Arc::new(InMemoryPubsubRepo::new(vec![
(app, "user.*".into()),
(app, "user.created".into()),
(app, "order.*".into()), // does not match
]));
let files = svc(repo.clone(), Arc::new(DenyingAuthzRepo));
files
.publish_durable(&anon_cx(app), "user.created", serde_json::json!({"id": 1}))
.await
.unwrap();
// Two of the three subscriptions match "user.created".
assert_eq!(repo.written_count(), 2);
}
#[tokio::test]
async fn no_matching_trigger_succeeds_silently() {
let app = AppId::new();
let repo = Arc::new(InMemoryPubsubRepo::new(vec![(app, "order.*".into())]));
let svc = svc(repo.clone(), Arc::new(DenyingAuthzRepo));
svc.publish_durable(&anon_cx(app), "user.created", serde_json::json!(1))
.await
.unwrap();
assert_eq!(repo.written_count(), 0);
}
#[tokio::test]
async fn empty_topic_rejected() {
let app = AppId::new();
let repo = Arc::new(InMemoryPubsubRepo::new(vec![]));
let svc = svc(repo, Arc::new(DenyingAuthzRepo));
let err = svc
.publish_durable(&anon_cx(app), " ", serde_json::json!(1))
.await
.unwrap_err();
assert!(matches!(err, PubsubError::EmptyTopic));
}
#[tokio::test]
async fn cross_app_isolation() {
let app_a = AppId::new();
let app_b = AppId::new();
// The only subscription belongs to app B.
let repo = Arc::new(InMemoryPubsubRepo::new(vec![(app_b, "*".into())]));
let svc = svc(repo.clone(), Arc::new(DenyingAuthzRepo));
// App A publishes — app B's trigger must NOT fire.
svc.publish_durable(&anon_cx(app_a), "user.created", serde_json::json!(1))
.await
.unwrap();
assert_eq!(repo.written_count(), 0);
}
#[tokio::test]
async fn fan_out_is_transactional_all_or_nothing() {
let app = AppId::new();
let mut repo = InMemoryPubsubRepo::new(vec![
(app, "*".into()),
(app, "user.*".into()),
(app, "user.created".into()),
]);
repo.fail_at = Some(3); // fail on the 3rd matching insert
let repo = Arc::new(repo);
let svc = svc(repo.clone(), Arc::new(DenyingAuthzRepo));
let err = svc
.publish_durable(&anon_cx(app), "user.created", serde_json::json!(1))
.await
.unwrap_err();
assert!(matches!(err, PubsubError::Unavailable(_)));
// Rollback: no partial fan-out survived.
assert_eq!(repo.written_count(), 0);
}
#[tokio::test]
async fn anonymous_cx_skips_authz() {
let app = AppId::new();
let repo = Arc::new(InMemoryPubsubRepo::new(vec![]));
let svc = svc(repo, Arc::new(DenyingAuthzRepo));
// No principal → no authz check even with a denying repo.
svc.publish_durable(&anon_cx(app), "t", serde_json::json!(1))
.await
.unwrap();
}
#[tokio::test]
async fn member_without_role_is_forbidden() {
let app = AppId::new();
let repo = Arc::new(InMemoryPubsubRepo::new(vec![]));
let svc = svc(repo, Arc::new(DenyingAuthzRepo));
let err = svc
.publish_durable(&member_cx(app), "t", serde_json::json!(1))
.await
.unwrap_err();
assert!(matches!(err, PubsubError::Forbidden));
}
#[tokio::test]
async fn member_with_editor_role_allowed() {
let app = AppId::new();
let repo = Arc::new(InMemoryPubsubRepo::new(vec![]));
let svc = svc(repo, Arc::new(EditorAuthzRepo));
svc.publish_durable(&member_cx(app), "t", serde_json::json!(1))
.await
.unwrap();
}
}

View File

@@ -0,0 +1,457 @@
//! SSRF deny-list — the load-bearing security mechanism behind the
//! v1.1.4 `http::*` SDK.
//!
//! The policy is applied to the **resolved IP address**, not the
//! hostname. That is the DNS-rebinding defense: a hostname that
//! resolves to a public IP at lookup time and a private IP at connect
//! time is not exploitable, because reqwest re-runs every connection
//! (including post-redirect hops) through [`SsrfResolver`], which
//! filters the address list before the socket is opened.
//!
//! [`SsrfPolicy::check`] returns a CIDR-*category* reason on denial
//! (e.g. `"loopback"`, `"private"`) — never the IP itself, so the
//! script-visible error can't be used to map the internal network.
//!
//! `PICLOUD_HTTP_ALLOW_PRIVATE=true` flips `allow_private`, which
//! short-circuits every check to allow. That is dev/test-only and the
//! binary logs a startup warning when it's set.
use std::future::Future;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::pin::Pin;
use std::sync::Arc;
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
/// Decision policy for a single resolved IP. Cheap to clone (one bool).
#[derive(Debug, Clone, Copy)]
pub struct SsrfPolicy {
/// When true, every address is allowed — the entire deny-list is
/// disabled. Set from `PICLOUD_HTTP_ALLOW_PRIVATE`. Dev/test only.
pub allow_private: bool,
}
impl SsrfPolicy {
#[must_use]
pub const fn new(allow_private: bool) -> Self {
Self { allow_private }
}
/// `Ok(())` if the IP may be connected to; `Err(reason)` with a
/// CIDR-category label otherwise. The reason is safe to surface to
/// a script — it never contains the address.
///
/// # Errors
///
/// Returns the deny reason when `ip` falls in a blocked range and
/// `allow_private` is false.
pub fn check(&self, ip: IpAddr) -> Result<(), &'static str> {
if self.allow_private {
return Ok(());
}
match ip {
IpAddr::V4(v4) => check_v4(v4),
IpAddr::V6(v6) => check_v6(v6),
}
}
#[must_use]
pub fn is_allowed(&self, ip: IpAddr) -> bool {
self.check(ip).is_ok()
}
}
/// IPv4 deny-list. Order doesn't matter (ranges are disjoint by
/// construction); first match wins for the reason label.
// Several arms share a reason ("private") for distinct CIDRs — keeping
// them separate documents each blocked range explicitly.
#[allow(clippy::match_same_arms)]
fn check_v4(ip: Ipv4Addr) -> Result<(), &'static str> {
let o = ip.octets();
match o {
[127, ..] => Err("loopback"),
[0, ..] => Err("unspecified"), // 0.0.0.0/8 "this network"
[10, ..] => Err("private"),
[172, b, ..] if (16..=31).contains(&b) => Err("private"),
[192, 168, ..] => Err("private"),
[169, 254, ..] => Err("link-local"), // includes cloud metadata 169.254.169.254
[100, b, ..] if (64..=127).contains(&b) => Err("carrier-grade-nat"),
[224..=239, ..] => Err("multicast"),
[240..=255, ..] => Err("reserved"),
_ => Ok(()),
}
}
/// IPv6 deny-list. IPv4-mapped addresses (`::ffff:0:0/96`) re-run the
/// v4 deny-list against the embedded address.
fn check_v6(ip: Ipv6Addr) -> Result<(), &'static str> {
// IPv4-mapped (::ffff:a.b.c.d) — re-check the embedded v4 address
// so a mapped private/loopback address can't sneak through.
if let Some(v4) = ip.to_ipv4_mapped() {
return check_v4(v4);
}
if ip == Ipv6Addr::LOCALHOST {
return Err("loopback");
}
if ip == Ipv6Addr::UNSPECIFIED {
return Err("unspecified");
}
let seg0 = ip.segments()[0];
if seg0 & 0xffc0 == 0xfe80 {
return Err("link-local"); // fe80::/10
}
if seg0 & 0xfe00 == 0xfc00 {
return Err("unique-local"); // fc00::/7
}
if seg0 & 0xff00 == 0xff00 {
return Err("multicast"); // ff00::/8
}
Ok(())
}
/// Marker error returned by the resolver when *every* resolved address
/// for a host was denied. reqwest wraps this into a connect error; the
/// `http_service` impl walks the source chain for the
/// `"blocked by SSRF policy:"` prefix to surface a clean
/// [`crate::http_service::HttpError::Ssrf`] instead of a generic DNS
/// failure. Keeping the reason a category label means no IP leaks.
#[derive(Debug)]
struct SsrfBlocked {
reason: &'static str,
}
impl std::fmt::Display for SsrfBlocked {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "blocked by SSRF policy: {}", self.reason)
}
}
impl std::error::Error for SsrfBlocked {}
/// Prefix the resolver embeds in its error and the impl scans for.
pub const SSRF_BLOCK_PREFIX: &str = "blocked by SSRF policy: ";
/// Pluggable host→addresses lookup. Production uses the system
/// resolver; tests inject a closure (e.g. to simulate DNS rebinding —
/// a different address on a later call).
pub type LookupFn = Arc<
dyn Fn(String) -> Pin<Box<dyn Future<Output = std::io::Result<Vec<SocketAddr>>> + Send>>
+ Send
+ Sync,
>;
fn system_lookup(
host: String,
) -> Pin<Box<dyn Future<Output = std::io::Result<Vec<SocketAddr>>> + Send>> {
Box::pin(async move {
// Port 0 — reqwest overrides it with the real target port.
Ok(tokio::net::lookup_host((host.as_str(), 0u16))
.await?
.collect())
})
}
/// reqwest DNS resolver that delegates to the system resolver, then
/// filters the address list through [`SsrfPolicy`]. Plugged in via
/// `ClientBuilder::dns_resolver`, so it runs at the actual connection
/// point — including for every redirect hop. This is the DNS-rebinding
/// defense: filtering happens at connect time, not at URL-parse time.
#[derive(Clone)]
pub struct SsrfResolver {
policy: SsrfPolicy,
lookup: LookupFn,
}
impl SsrfResolver {
#[must_use]
pub fn new(policy: SsrfPolicy) -> Self {
Self {
policy,
lookup: Arc::new(system_lookup),
}
}
/// Construct with an injected lookup (tests only).
#[must_use]
pub fn with_lookup(policy: SsrfPolicy, lookup: LookupFn) -> Self {
Self { policy, lookup }
}
}
impl Resolve for SsrfResolver {
fn resolve(&self, name: Name) -> Resolving {
let policy = self.policy;
let lookup = self.lookup.clone();
let host = name.as_str().to_string();
Box::pin(async move {
let resolved: Vec<SocketAddr> = lookup(host)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
// Empty resolution → genuine DNS miss; let reqwest surface
// it as a normal "no addresses" error.
if resolved.is_empty() {
let addrs: Addrs = Box::new(std::iter::empty());
return Ok(addrs);
}
let mut allowed: Vec<SocketAddr> = Vec::with_capacity(resolved.len());
let mut last_reason: &'static str = "denied";
for sa in resolved {
match policy.check(sa.ip()) {
Ok(()) => allowed.push(sa),
Err(reason) => last_reason = reason,
}
}
// Resolution returned addresses but the policy denied them
// all → fail with the SSRF marker so the impl can report a
// policy block (not a generic DNS error).
if allowed.is_empty() {
let err: Box<dyn std::error::Error + Send + Sync> = Box::new(SsrfBlocked {
reason: last_reason,
});
return Err(err);
}
let addrs: Addrs = Box::new(allowed.into_iter());
Ok(addrs)
})
}
}
/// Build the resolver. reqwest's `dns_resolver` is generic over a
/// concrete `R: Resolve` (it stores `Arc<R>`), so this returns the
/// concrete `Arc<SsrfResolver>` rather than a trait object.
#[must_use]
pub fn resolver(policy: SsrfPolicy) -> Arc<SsrfResolver> {
Arc::new(SsrfResolver::new(policy))
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
fn denied(ip: &str) -> &'static str {
let policy = SsrfPolicy::new(false);
policy
.check(IpAddr::from_str(ip).unwrap())
.expect_err(&format!("{ip} should be denied"))
}
fn allowed(ip: &str) {
let policy = SsrfPolicy::new(false);
policy
.check(IpAddr::from_str(ip).unwrap())
.unwrap_or_else(|r| panic!("{ip} should be allowed, denied as {r}"));
}
#[test]
fn denies_ipv4_loopback() {
assert_eq!(denied("127.0.0.1"), "loopback");
assert_eq!(denied("127.1.2.3"), "loopback");
}
#[test]
fn denies_ipv4_unspecified() {
assert_eq!(denied("0.0.0.0"), "unspecified");
}
#[test]
fn denies_rfc1918_private() {
assert_eq!(denied("10.0.0.1"), "private");
assert_eq!(denied("10.255.255.255"), "private");
assert_eq!(denied("172.16.0.1"), "private");
assert_eq!(denied("172.31.255.255"), "private");
assert_eq!(denied("192.168.0.1"), "private");
}
#[test]
fn allows_172_outside_private_range() {
// 172.15.x and 172.32.x are public — only 172.16.0.0/12 is private.
allowed("172.15.0.1");
allowed("172.32.0.1");
}
#[test]
fn denies_link_local_and_cloud_metadata() {
assert_eq!(denied("169.254.0.1"), "link-local");
// The cloud metadata endpoint is the canonical SSRF target.
assert_eq!(denied("169.254.169.254"), "link-local");
}
#[test]
fn denies_carrier_grade_nat() {
assert_eq!(denied("100.64.0.1"), "carrier-grade-nat");
assert_eq!(denied("100.127.255.255"), "carrier-grade-nat");
// 100.63.x and 100.128.x are outside 100.64.0.0/10.
allowed("100.63.0.1");
allowed("100.128.0.1");
}
#[test]
fn denies_multicast_and_reserved() {
assert_eq!(denied("224.0.0.1"), "multicast");
assert_eq!(denied("239.255.255.255"), "multicast");
assert_eq!(denied("240.0.0.1"), "reserved");
assert_eq!(denied("255.255.255.255"), "reserved");
}
#[test]
fn allows_public_ipv4() {
allowed("1.1.1.1");
allowed("8.8.8.8");
allowed("93.184.216.34"); // example.com
}
#[test]
fn denies_ipv6_loopback() {
assert_eq!(denied("::1"), "loopback");
}
#[test]
fn denies_ipv6_unspecified() {
assert_eq!(denied("::"), "unspecified");
}
#[test]
fn denies_ipv6_link_local() {
assert_eq!(denied("fe80::1"), "link-local");
assert_eq!(denied("febf:ffff::1"), "link-local");
}
#[test]
fn denies_ipv6_unique_local() {
assert_eq!(denied("fc00::1"), "unique-local");
assert_eq!(denied("fd12:3456::1"), "unique-local");
}
#[test]
fn denies_ipv6_multicast() {
assert_eq!(denied("ff00::1"), "multicast");
assert_eq!(denied("ff02::1"), "multicast");
}
#[test]
fn allows_public_ipv6() {
allowed("2606:4700:4700::1111"); // cloudflare
allowed("2001:4860:4860::8888"); // google
}
#[test]
fn ipv4_mapped_ipv6_rechecks_embedded_address() {
// ::ffff:127.0.0.1 must be denied via the embedded-v4 re-check.
assert_eq!(denied("::ffff:127.0.0.1"), "loopback");
assert_eq!(denied("::ffff:10.0.0.1"), "private");
assert_eq!(denied("::ffff:169.254.169.254"), "link-local");
// A mapped *public* address stays allowed.
allowed("::ffff:1.1.1.1");
}
#[test]
fn allow_private_disables_all_denials() {
let policy = SsrfPolicy::new(true);
for ip in ["127.0.0.1", "10.0.0.1", "169.254.169.254", "::1", "fe80::1"] {
assert!(policy.is_allowed(IpAddr::from_str(ip).unwrap()));
}
}
// --- resolver-path tests (the connect-time filter) ---
use std::sync::atomic::{AtomicUsize, Ordering};
fn name(s: &str) -> Name {
Name::from_str(s).unwrap()
}
fn fixed_lookup(addrs: Vec<SocketAddr>) -> LookupFn {
Arc::new(move |_host| {
let addrs = addrs.clone();
Box::pin(async move { Ok(addrs) })
})
}
#[tokio::test]
async fn resolver_returns_only_allowed_addresses() {
// A host resolving to one public + one private IP yields only
// the public one to reqwest.
let public: SocketAddr = "1.1.1.1:0".parse().unwrap();
let private: SocketAddr = "10.0.0.1:0".parse().unwrap();
let resolver =
SsrfResolver::with_lookup(SsrfPolicy::new(false), fixed_lookup(vec![public, private]));
let got: Vec<SocketAddr> = resolver
.resolve(name("mixed.example"))
.await
.unwrap()
.collect();
assert_eq!(got, vec![public]);
}
#[tokio::test]
async fn resolver_all_denied_fails_with_ssrf_marker() {
// A host resolving to ONLY private IPs fails with the SSRF
// marker (not a generic empty/DNS result).
let resolver = SsrfResolver::with_lookup(
SsrfPolicy::new(false),
fixed_lookup(vec![
"10.0.0.1:0".parse().unwrap(),
"127.0.0.1:0".parse().unwrap(),
]),
);
let Err(err) = resolver.resolve(name("internal.example")).await else {
panic!("all-denied resolution should error");
};
assert!(
err.to_string().starts_with(SSRF_BLOCK_PREFIX),
"expected SSRF marker, got: {err}"
);
}
#[tokio::test]
async fn resolver_dns_rebinding_second_resolution_denied() {
// Simulate rebinding: public IP on the first lookup, private on
// the second. The connect-time filter denies the second.
let calls = Arc::new(AtomicUsize::new(0));
let calls2 = calls.clone();
let lookup: LookupFn = Arc::new(move |_host| {
let n = calls2.fetch_add(1, Ordering::SeqCst);
Box::pin(async move {
let addr: SocketAddr = if n == 0 {
"1.1.1.1:0".parse().unwrap()
} else {
"127.0.0.1:0".parse().unwrap()
};
Ok(vec![addr])
})
});
let resolver = SsrfResolver::with_lookup(SsrfPolicy::new(false), lookup);
// First resolution: public → allowed.
let first: Vec<SocketAddr> = resolver
.resolve(name("rebind.example"))
.await
.unwrap()
.collect();
assert_eq!(first, vec!["1.1.1.1:0".parse::<SocketAddr>().unwrap()]);
// Second resolution: rebinding returns loopback → denied.
let Err(err) = resolver.resolve(name("rebind.example")).await else {
panic!("rebound private address must be denied");
};
assert!(err.to_string().contains("loopback"));
}
#[tokio::test]
async fn resolver_empty_resolution_is_not_ssrf() {
// Genuine DNS miss (no addresses) returns an empty iterator,
// NOT the SSRF marker — reqwest surfaces a normal DNS error.
let resolver = SsrfResolver::with_lookup(SsrfPolicy::new(false), fixed_lookup(vec![]));
let got: Vec<SocketAddr> = resolver
.resolve(name("nxdomain.example"))
.await
.unwrap()
.collect();
assert!(got.is_empty());
}
}

View File

@@ -56,6 +56,11 @@ pub struct TriggerConfig {
pub dead_letter_retention_days: u32, pub dead_letter_retention_days: u32,
/// abandoned-execution retention before GC, in days. Default 7. /// abandoned-execution retention before GC, in days. Default 7.
pub abandoned_retention_days: u32, pub abandoned_retention_days: u32,
/// Cron scheduler poll cadence, in ms (v1.1.4). Default 30 000 —
/// real-world cron precision is per-minute, so a 30s tick is fine.
/// Floored at 1s by the scheduler.
pub cron_tick_interval_ms: u32,
} }
impl TriggerConfig { impl TriggerConfig {
@@ -69,6 +74,7 @@ impl TriggerConfig {
retry_jitter_pct: 20, retry_jitter_pct: 20,
dead_letter_retention_days: 30, dead_letter_retention_days: 30,
abandoned_retention_days: 7, abandoned_retention_days: 7,
cron_tick_interval_ms: 30_000,
} }
} }
@@ -91,6 +97,10 @@ impl TriggerConfig {
&mut c.abandoned_retention_days, &mut c.abandoned_retention_days,
"PICLOUD_ABANDONED_EXECUTIONS_RETENTION_DAYS", "PICLOUD_ABANDONED_EXECUTIONS_RETENTION_DAYS",
); );
load_u32(
&mut c.cron_tick_interval_ms,
"PICLOUD_CRON_TICK_INTERVAL_MS",
);
c c
} }
} }
@@ -141,6 +151,7 @@ mod tests {
assert_eq!(c.retry_jitter_pct, 20); assert_eq!(c.retry_jitter_pct, 20);
assert_eq!(c.dead_letter_retention_days, 30); assert_eq!(c.dead_letter_retention_days, 30);
assert_eq!(c.abandoned_retention_days, 7); assert_eq!(c.abandoned_retention_days, 7);
assert_eq!(c.cron_tick_interval_ms, 30_000);
} }
#[test] #[test]

View File

@@ -5,7 +5,9 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, AppId, DocsEventOp, KvEventOp, ScriptId, TriggerId}; use picloud_shared::{
AdminUserId, AppId, DocsEventOp, FilesEventOp, KvEventOp, ScriptId, TriggerId,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
@@ -49,6 +51,12 @@ pub enum TriggerKind {
Kv, Kv,
Docs, Docs,
DeadLetter, DeadLetter,
/// v1.1.4.
Cron,
/// v1.1.5.
Files,
/// v1.1.5.
Pubsub,
} }
impl TriggerKind { impl TriggerKind {
@@ -58,6 +66,9 @@ impl TriggerKind {
Self::Kv => "kv", Self::Kv => "kv",
Self::Docs => "docs", Self::Docs => "docs",
Self::DeadLetter => "dead_letter", Self::DeadLetter => "dead_letter",
Self::Cron => "cron",
Self::Files => "files",
Self::Pubsub => "pubsub",
} }
} }
@@ -67,6 +78,9 @@ impl TriggerKind {
"kv" => Some(Self::Kv), "kv" => Some(Self::Kv),
"docs" => Some(Self::Docs), "docs" => Some(Self::Docs),
"dead_letter" => Some(Self::DeadLetter), "dead_letter" => Some(Self::DeadLetter),
"cron" => Some(Self::Cron),
"files" => Some(Self::Files),
"pubsub" => Some(Self::Pubsub),
_ => None, _ => None,
} }
} }
@@ -108,6 +122,21 @@ pub enum TriggerDetails {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
script_id_filter: Option<ScriptId>, script_id_filter: Option<ScriptId>,
}, },
/// v1.1.4. The 6-field cron schedule + IANA timezone the trigger
/// fires on, plus the last enqueue time (for dashboard display).
Cron {
schedule: String,
timezone: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
last_fired_at: Option<DateTime<Utc>>,
},
/// v1.1.5. Same shape as KV/docs: a collection glob + op subset.
Files {
collection_glob: String,
ops: Vec<FilesEventOp>,
},
/// v1.1.5. A topic pattern: exact, `<prefix>.*`, or `*`.
Pubsub { topic_pattern: String },
} }
/// Create payload for a KV trigger. Defaults applied at the admin /// Create payload for a KV trigger. Defaults applied at the admin
@@ -148,6 +177,61 @@ pub struct CreateDeadLetterTrigger {
pub registered_by_principal: AdminUserId, pub registered_by_principal: AdminUserId,
} }
/// Create payload for a cron trigger (v1.1.4). `schedule` is a 6-field
/// cron expression and `timezone` an IANA name; both are validated
/// (by the admin endpoint and defensively by the repo) before insert.
#[derive(Debug, Clone)]
pub struct CreateCronTrigger {
pub script_id: ScriptId,
pub schedule: String,
pub timezone: String,
pub dispatch_mode: TriggerDispatchMode,
pub retry_max_attempts: u32,
pub retry_backoff: BackoffShape,
pub retry_base_ms: u32,
pub registered_by_principal: AdminUserId,
}
/// Create payload for a files trigger (v1.1.5). Same shape as KV with
/// `FilesEventOp` ops.
#[derive(Debug, Clone)]
pub struct CreateFilesTrigger {
pub script_id: ScriptId,
pub collection_glob: String,
pub ops: Vec<FilesEventOp>,
pub dispatch_mode: TriggerDispatchMode,
pub retry_max_attempts: u32,
pub retry_backoff: BackoffShape,
pub retry_base_ms: u32,
pub registered_by_principal: AdminUserId,
}
/// One match for the dispatcher's files trigger fan-out lookup
/// (v1.1.5). Same shape as `KvTriggerMatch`.
#[derive(Debug, Clone)]
pub struct FilesTriggerMatch {
pub trigger_id: TriggerId,
pub script_id: ScriptId,
pub dispatch_mode: TriggerDispatchMode,
pub retry_max_attempts: u32,
pub retry_backoff: BackoffShape,
pub retry_base_ms: u32,
pub registered_by_principal: AdminUserId,
}
/// Create payload for a pubsub trigger (v1.1.5). `topic_pattern` is
/// validated (exact / `<prefix>.*` / `*`) before insert.
#[derive(Debug, Clone)]
pub struct CreatePubsubTrigger {
pub script_id: ScriptId,
pub topic_pattern: String,
pub dispatch_mode: TriggerDispatchMode,
pub retry_max_attempts: u32,
pub retry_backoff: BackoffShape,
pub retry_base_ms: u32,
pub registered_by_principal: AdminUserId,
}
/// One match for the dispatcher's "which KV triggers fire on this /// One match for the dispatcher's "which KV triggers fire on this
/// event" lookup. Carries everything the dispatcher needs to construct /// event" lookup. Carries everything the dispatcher needs to construct
/// the outbox row. /// the outbox row.
@@ -206,6 +290,29 @@ pub trait TriggerRepo: Send + Sync {
req: CreateDeadLetterTrigger, req: CreateDeadLetterTrigger,
) -> Result<Trigger, TriggerRepoError>; ) -> Result<Trigger, TriggerRepoError>;
/// v1.1.4. `schedule` + `timezone` are validated before insert; an
/// invalid expression or unknown IANA name returns
/// `TriggerRepoError::Invalid`.
async fn create_cron_trigger(
&self,
app_id: AppId,
req: CreateCronTrigger,
) -> Result<Trigger, TriggerRepoError>;
/// v1.1.5.
async fn create_files_trigger(
&self,
app_id: AppId,
req: CreateFilesTrigger,
) -> Result<Trigger, TriggerRepoError>;
/// v1.1.5. `topic_pattern` is validated before insert.
async fn create_pubsub_trigger(
&self,
app_id: AppId,
req: CreatePubsubTrigger,
) -> Result<Trigger, TriggerRepoError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError>; async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError>;
async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError>; async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError>;
@@ -233,6 +340,16 @@ pub trait TriggerRepo: Send + Sync {
op: DocsEventOp, op: DocsEventOp,
) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError>; ) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError>;
/// Dispatcher hot path for files fan-out (v1.1.5). Mirrors the KV
/// fan-out logic: pull every enabled files trigger, filter glob +
/// ops in Rust (empty ops array means "any op").
async fn list_matching_files(
&self,
app_id: AppId,
collection: &str,
op: FilesEventOp,
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError>;
/// Dispatcher hot path for dead-letter fan-out. Filters: source /// Dispatcher hot path for dead-letter fan-out. Filters: source
/// (or any-source), originating trigger_id (or any), originating /// (or any-source), originating trigger_id (or any), originating
/// script_id (or any). Each filter is "match OR is_null". /// script_id (or any). Each filter is "match OR is_null".
@@ -453,6 +570,197 @@ impl TriggerRepo for PostgresTriggerRepo {
}) })
} }
async fn create_cron_trigger(
&self,
app_id: AppId,
req: CreateCronTrigger,
) -> Result<Trigger, TriggerRepoError> {
// Defense-in-depth validation (the admin endpoint validates too).
crate::cron_scheduler::validate_schedule(&req.schedule)
.map_err(|e| TriggerRepoError::Invalid(format!("invalid cron schedule: {e}")))?;
crate::cron_scheduler::validate_timezone(&req.timezone)
.map_err(|e| TriggerRepoError::Invalid(format!("invalid timezone: {e}")))?;
let mut tx = self.pool.begin().await?;
let parent: TriggerRow = sqlx::query_as(
"INSERT INTO triggers ( \
app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, 'cron', TRUE, $3, $4, $5, $6, $7) \
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at",
)
.bind(app_id.into_inner())
.bind(req.script_id.into_inner())
.bind(req.dispatch_mode.as_str())
.bind(i32::try_from(req.retry_max_attempts).unwrap_or(3))
.bind(req.retry_backoff.as_str())
.bind(i32::try_from(req.retry_base_ms).unwrap_or(1000))
.bind(req.registered_by_principal.into_inner())
.fetch_one(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO cron_trigger_details (trigger_id, schedule, timezone) \
VALUES ($1, $2, $3)",
)
.bind(parent.id)
.bind(&req.schedule)
.bind(&req.timezone)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Trigger {
id: parent.id.into(),
app_id: parent.app_id.into(),
script_id: parent.script_id.into(),
kind: TriggerKind::Cron,
enabled: parent.enabled,
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
.unwrap_or(BackoffShape::Exponential),
retry_base_ms: u32::try_from(parent.retry_base_ms).unwrap_or(1000),
registered_by_principal: parent.registered_by_principal.into(),
created_at: parent.created_at,
updated_at: parent.updated_at,
details: TriggerDetails::Cron {
schedule: req.schedule,
timezone: req.timezone,
last_fired_at: None,
},
})
}
async fn create_files_trigger(
&self,
app_id: AppId,
req: CreateFilesTrigger,
) -> Result<Trigger, TriggerRepoError> {
if req.collection_glob.is_empty() {
return Err(TriggerRepoError::Invalid(
"collection_glob must not be empty".into(),
));
}
let mut tx = self.pool.begin().await?;
let parent: TriggerRow = sqlx::query_as(
"INSERT INTO triggers ( \
app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, 'files', TRUE, $3, $4, $5, $6, $7) \
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at",
)
.bind(app_id.into_inner())
.bind(req.script_id.into_inner())
.bind(req.dispatch_mode.as_str())
.bind(i32::try_from(req.retry_max_attempts).unwrap_or(3))
.bind(req.retry_backoff.as_str())
.bind(i32::try_from(req.retry_base_ms).unwrap_or(1000))
.bind(req.registered_by_principal.into_inner())
.fetch_one(&mut *tx)
.await?;
let ops_str: Vec<String> = req.ops.iter().map(|o| o.as_str().to_string()).collect();
sqlx::query(
"INSERT INTO files_trigger_details (trigger_id, collection_glob, ops) \
VALUES ($1, $2, $3)",
)
.bind(parent.id)
.bind(&req.collection_glob)
.bind(&ops_str)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Trigger {
id: parent.id.into(),
app_id: parent.app_id.into(),
script_id: parent.script_id.into(),
kind: TriggerKind::Files,
enabled: parent.enabled,
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
.unwrap_or(BackoffShape::Exponential),
retry_base_ms: u32::try_from(parent.retry_base_ms).unwrap_or(1000),
registered_by_principal: parent.registered_by_principal.into(),
created_at: parent.created_at,
updated_at: parent.updated_at,
details: TriggerDetails::Files {
collection_glob: req.collection_glob,
ops: req.ops,
},
})
}
async fn create_pubsub_trigger(
&self,
app_id: AppId,
req: CreatePubsubTrigger,
) -> Result<Trigger, TriggerRepoError> {
// Defense-in-depth validation (the admin endpoint validates too).
picloud_shared::validate_topic_pattern(&req.topic_pattern)
.map_err(TriggerRepoError::Invalid)?;
let mut tx = self.pool.begin().await?;
let parent: TriggerRow = sqlx::query_as(
"INSERT INTO triggers ( \
app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, 'pubsub', TRUE, $3, $4, $5, $6, $7) \
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at",
)
.bind(app_id.into_inner())
.bind(req.script_id.into_inner())
.bind(req.dispatch_mode.as_str())
.bind(i32::try_from(req.retry_max_attempts).unwrap_or(3))
.bind(req.retry_backoff.as_str())
.bind(i32::try_from(req.retry_base_ms).unwrap_or(1000))
.bind(req.registered_by_principal.into_inner())
.fetch_one(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO pubsub_trigger_details (trigger_id, topic_pattern) VALUES ($1, $2)",
)
.bind(parent.id)
.bind(&req.topic_pattern)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Trigger {
id: parent.id.into(),
app_id: parent.app_id.into(),
script_id: parent.script_id.into(),
kind: TriggerKind::Pubsub,
enabled: parent.enabled,
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
.unwrap_or(BackoffShape::Exponential),
retry_base_ms: u32::try_from(parent.retry_base_ms).unwrap_or(1000),
registered_by_principal: parent.registered_by_principal.into(),
created_at: parent.created_at,
updated_at: parent.updated_at,
details: TriggerDetails::Pubsub {
topic_pattern: req.topic_pattern,
},
})
}
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> { async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
let parents: Vec<TriggerRow> = sqlx::query_as( let parents: Vec<TriggerRow> = sqlx::query_as(
"SELECT id, app_id, script_id, kind, enabled, dispatch_mode, \ "SELECT id, app_id, script_id, kind, enabled, dispatch_mode, \
@@ -591,6 +899,51 @@ impl TriggerRepo for PostgresTriggerRepo {
Ok(out) Ok(out)
} }
async fn list_matching_files(
&self,
app_id: AppId,
collection: &str,
op: FilesEventOp,
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError> {
// Mirrors list_matching_kv: pull every enabled files trigger,
// filter glob + ops in Rust (empty ops array means "any op").
let rows: Vec<KvMatchRow> = sqlx::query_as(
"SELECT t.id, t.script_id, t.dispatch_mode, \
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
t.registered_by_principal, \
d.collection_glob, d.ops \
FROM triggers t \
JOIN files_trigger_details d ON d.trigger_id = t.id \
WHERE t.app_id = $1 AND t.kind = 'files' AND t.enabled = TRUE",
)
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
let op_str = op.as_str();
let mut out = Vec::new();
for r in rows {
if !collection_matches(&r.collection_glob, collection) {
continue;
}
let any_op = r.ops.is_empty();
if !any_op && !r.ops.iter().any(|o| o == op_str) {
continue;
}
out.push(FilesTriggerMatch {
trigger_id: r.id.into(),
script_id: r.script_id.into(),
dispatch_mode: dispatch_from_str(&r.dispatch_mode),
retry_max_attempts: u32::try_from(r.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&r.retry_backoff)
.unwrap_or(BackoffShape::Exponential),
retry_base_ms: u32::try_from(r.retry_base_ms).unwrap_or(1000),
registered_by_principal: r.registered_by_principal.into(),
});
}
Ok(out)
}
async fn list_matching_dead_letter( async fn list_matching_dead_letter(
&self, &self,
app_id: AppId, app_id: AppId,
@@ -627,6 +980,7 @@ impl TriggerRepo for PostgresTriggerRepo {
} }
} }
#[allow(clippy::too_many_lines)]
async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, TriggerRepoError> { async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, TriggerRepoError> {
let kind = TriggerKind::from_wire(&parent.kind).ok_or_else(|| { let kind = TriggerKind::from_wire(&parent.kind).ok_or_else(|| {
TriggerRepoError::Invalid(format!("unknown trigger kind {}", parent.kind)) TriggerRepoError::Invalid(format!("unknown trigger kind {}", parent.kind))
@@ -681,6 +1035,48 @@ async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, Trigg
script_id_filter: row.script_id_filter.map(Into::into), script_id_filter: row.script_id_filter.map(Into::into),
} }
} }
TriggerKind::Cron => {
let row: CronDetailRow = sqlx::query_as(
"SELECT schedule, timezone, last_fired_at \
FROM cron_trigger_details WHERE trigger_id = $1",
)
.bind(parent.id)
.fetch_one(pool)
.await?;
TriggerDetails::Cron {
schedule: row.schedule,
timezone: row.timezone,
last_fired_at: row.last_fired_at,
}
}
TriggerKind::Files => {
let row: KvDetailRow = sqlx::query_as(
"SELECT collection_glob, ops FROM files_trigger_details WHERE trigger_id = $1",
)
.bind(parent.id)
.fetch_one(pool)
.await?;
let ops = row
.ops
.iter()
.filter_map(|s| FilesEventOp::from_wire(s))
.collect();
TriggerDetails::Files {
collection_glob: row.collection_glob,
ops,
}
}
TriggerKind::Pubsub => {
let row: PubsubDetailRow = sqlx::query_as(
"SELECT topic_pattern FROM pubsub_trigger_details WHERE trigger_id = $1",
)
.bind(parent.id)
.fetch_one(pool)
.await?;
TriggerDetails::Pubsub {
topic_pattern: row.topic_pattern,
}
}
}; };
Ok(Trigger { Ok(Trigger {
@@ -746,6 +1142,18 @@ struct KvDetailRow {
ops: Vec<String>, ops: Vec<String>,
} }
#[derive(sqlx::FromRow)]
struct CronDetailRow {
schedule: String,
timezone: String,
last_fired_at: Option<DateTime<Utc>>,
}
#[derive(sqlx::FromRow)]
struct PubsubDetailRow {
topic_pattern: String,
}
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
#[allow(clippy::struct_field_names)] #[allow(clippy::struct_field_names)]
struct DlDetailRow { struct DlDetailRow {

View File

@@ -16,7 +16,9 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response}; use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get, post}; use axum::routing::{delete, get, post};
use axum::{Extension, Router}; use axum::{Extension, Router};
use picloud_shared::{AppId, DocsEventOp, KvEventOp, Principal, ScriptId, ScriptKind, TriggerId}; use picloud_shared::{
AppId, DocsEventOp, FilesEventOp, KvEventOp, Principal, ScriptId, ScriptKind, TriggerId,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
@@ -25,8 +27,9 @@ use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::repo::{ScriptRepository, ScriptRepositoryError}; use crate::repo::{ScriptRepository, ScriptRepositoryError};
use crate::trigger_config::{BackoffShape, TriggerConfig}; use crate::trigger_config::{BackoffShape, TriggerConfig};
use crate::trigger_repo::{ use crate::trigger_repo::{
CreateDeadLetterTrigger, CreateDocsTrigger, CreateKvTrigger, Trigger, TriggerDispatchMode, CreateCronTrigger, CreateDeadLetterTrigger, CreateDocsTrigger, CreateFilesTrigger,
TriggerRepo, TriggerRepoError, CreateKvTrigger, CreatePubsubTrigger, Trigger, TriggerDispatchMode, TriggerRepo,
TriggerRepoError,
}; };
#[derive(Clone)] #[derive(Clone)]
@@ -53,6 +56,12 @@ pub fn triggers_router(state: TriggersState) -> Router {
) )
.route("/apps/{app_id}/triggers/kv", post(create_kv_trigger)) .route("/apps/{app_id}/triggers/kv", post(create_kv_trigger))
.route("/apps/{app_id}/triggers/docs", post(create_docs_trigger)) .route("/apps/{app_id}/triggers/docs", post(create_docs_trigger))
.route("/apps/{app_id}/triggers/cron", post(create_cron_trigger))
.route("/apps/{app_id}/triggers/files", post(create_files_trigger))
.route(
"/apps/{app_id}/triggers/pubsub",
post(create_pubsub_trigger),
)
.route( .route(
"/apps/{app_id}/triggers/dead_letter", "/apps/{app_id}/triggers/dead_letter",
post(create_dl_trigger), post(create_dl_trigger),
@@ -116,6 +125,46 @@ pub struct CreateDocsTriggerRequest {
pub retry_base_ms: Option<u32>, pub retry_base_ms: Option<u32>,
} }
/// v1.1.4 cron trigger. `schedule` is a 6-field cron expression (with
/// seconds); `timezone` is an IANA name (defaults to UTC if omitted).
#[derive(Debug, Deserialize)]
pub struct CreateCronTriggerRequest {
pub script_id: ScriptId,
pub schedule: String,
#[serde(default = "default_timezone")]
pub timezone: String,
#[serde(default = "default_dispatch")]
pub dispatch_mode: TriggerDispatchMode,
#[serde(default)]
pub retry_max_attempts: Option<u32>,
#[serde(default)]
pub retry_backoff: Option<BackoffShape>,
#[serde(default)]
pub retry_base_ms: Option<u32>,
}
fn default_timezone() -> String {
"UTC".to_string()
}
/// v1.1.5 files trigger. Mirrors `CreateKvTriggerRequest`; `ops` uses
/// `FilesEventOp` (`create` / `update` / `delete`).
#[derive(Debug, Deserialize)]
pub struct CreateFilesTriggerRequest {
pub script_id: ScriptId,
pub collection_glob: String,
#[serde(default)]
pub ops: Vec<FilesEventOp>,
#[serde(default = "default_dispatch")]
pub dispatch_mode: TriggerDispatchMode,
#[serde(default)]
pub retry_max_attempts: Option<u32>,
#[serde(default)]
pub retry_backoff: Option<BackoffShape>,
#[serde(default)]
pub retry_base_ms: Option<u32>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CreateDeadLetterTriggerRequest { pub struct CreateDeadLetterTriggerRequest {
pub script_id: ScriptId, pub script_id: ScriptId,
@@ -264,6 +313,135 @@ async fn create_docs_trigger(
Ok((StatusCode::CREATED, Json(created))) Ok((StatusCode::CREATED, Json(created)))
} }
async fn create_cron_trigger(
State(s): State<TriggersState>,
Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>,
Json(input): Json<CreateCronTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
ensure_app_exists(&*s.apps, app_id).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppManageTriggers(app_id),
)
.await?;
// Validate the schedule + timezone before touching the script repo
// so a bad expression fails fast with a clear 422.
crate::cron_scheduler::validate_schedule(&input.schedule)
.map_err(|e| TriggersApiError::Invalid(format!("invalid cron schedule: {e}")))?;
crate::cron_scheduler::validate_timezone(&input.timezone)
.map_err(|e| TriggersApiError::Invalid(format!("invalid timezone: {e}")))?;
// v1.1.3 check: target script exists, lives in this app, is an
// endpoint (not a module).
validate_trigger_target(&*s.scripts, app_id, input.script_id).await?;
let req = CreateCronTrigger {
script_id: input.script_id,
schedule: input.schedule,
timezone: input.timezone,
dispatch_mode: input.dispatch_mode,
retry_max_attempts: input
.retry_max_attempts
.unwrap_or(s.config.retry_max_attempts),
retry_backoff: input.retry_backoff.unwrap_or(s.config.retry_backoff),
retry_base_ms: input.retry_base_ms.unwrap_or(s.config.retry_base_ms),
registered_by_principal: principal.user_id,
};
let created = s.triggers.create_cron_trigger(app_id, req).await?;
Ok((StatusCode::CREATED, Json(created)))
}
/// v1.1.5 pubsub trigger. `topic_pattern` is validated to be exact /
/// `<prefix>.*` / `*`.
#[derive(Debug, Deserialize)]
pub struct CreatePubsubTriggerRequest {
pub script_id: ScriptId,
pub topic_pattern: String,
#[serde(default = "default_dispatch")]
pub dispatch_mode: TriggerDispatchMode,
#[serde(default)]
pub retry_max_attempts: Option<u32>,
#[serde(default)]
pub retry_backoff: Option<BackoffShape>,
#[serde(default)]
pub retry_base_ms: Option<u32>,
}
async fn create_pubsub_trigger(
State(s): State<TriggersState>,
Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>,
Json(input): Json<CreatePubsubTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
ensure_app_exists(&*s.apps, app_id).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppManageTriggers(app_id),
)
.await?;
// Validate the topic pattern before touching the script repo so a
// bad pattern fails fast with a clear 422.
picloud_shared::validate_topic_pattern(&input.topic_pattern)
.map_err(TriggersApiError::Invalid)?;
validate_trigger_target(&*s.scripts, app_id, input.script_id).await?;
let req = CreatePubsubTrigger {
script_id: input.script_id,
topic_pattern: input.topic_pattern,
dispatch_mode: input.dispatch_mode,
retry_max_attempts: input
.retry_max_attempts
.unwrap_or(s.config.retry_max_attempts),
retry_backoff: input.retry_backoff.unwrap_or(s.config.retry_backoff),
retry_base_ms: input.retry_base_ms.unwrap_or(s.config.retry_base_ms),
registered_by_principal: principal.user_id,
};
let created = s.triggers.create_pubsub_trigger(app_id, req).await?;
Ok((StatusCode::CREATED, Json(created)))
}
async fn create_files_trigger(
State(s): State<TriggersState>,
Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>,
Json(input): Json<CreateFilesTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
ensure_app_exists(&*s.apps, app_id).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppManageTriggers(app_id),
)
.await?;
if input.collection_glob.trim().is_empty() {
return Err(TriggersApiError::Invalid(
"collection_glob must not be empty".into(),
));
}
validate_trigger_target(&*s.scripts, app_id, input.script_id).await?;
let req = CreateFilesTrigger {
script_id: input.script_id,
collection_glob: input.collection_glob,
ops: input.ops,
dispatch_mode: input.dispatch_mode,
retry_max_attempts: input
.retry_max_attempts
.unwrap_or(s.config.retry_max_attempts),
retry_backoff: input.retry_backoff.unwrap_or(s.config.retry_backoff),
retry_base_ms: input.retry_base_ms.unwrap_or(s.config.retry_base_ms),
registered_by_principal: principal.user_id,
};
let created = s.triggers.create_files_trigger(app_id, req).await?;
Ok((StatusCode::CREATED, Json(created)))
}
async fn create_dl_trigger( async fn create_dl_trigger(
State(s): State<TriggersState>, State(s): State<TriggersState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
@@ -420,13 +598,15 @@ mod tests {
use super::*; use super::*;
use crate::app_repo::{AppLookup, AppRepository}; use crate::app_repo::{AppLookup, AppRepository};
use crate::trigger_repo::{ use crate::trigger_repo::{
DeadLetterTriggerMatch, DocsTriggerMatch, KvTriggerMatch, Trigger, TriggerDetails, CreateCronTrigger, CreateFilesTrigger, CreatePubsubTrigger, DeadLetterTriggerMatch,
TriggerRepo, TriggerRepoError, DocsTriggerMatch, FilesTriggerMatch, KvTriggerMatch, Trigger, TriggerDetails, TriggerRepo,
TriggerRepoError,
}; };
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use picloud_shared::{ use picloud_shared::{
AdminUserId, App, AppRole, DocsEventOp, KvEventOp, ScriptId, TriggerId, UserId, AdminUserId, App, AppRole, DocsEventOp, FilesEventOp, KvEventOp, ScriptId, TriggerId,
UserId,
}; };
use std::collections::HashMap; use std::collections::HashMap;
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -523,6 +703,90 @@ mod tests {
self.inner.lock().await.insert(id, trigger.clone()); self.inner.lock().await.insert(id, trigger.clone());
Ok(trigger) Ok(trigger)
} }
async fn create_cron_trigger(
&self,
app_id: AppId,
req: CreateCronTrigger,
) -> Result<Trigger, TriggerRepoError> {
let now = Utc::now();
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
script_id: req.script_id,
kind: crate::trigger_repo::TriggerKind::Cron,
enabled: true,
dispatch_mode: req.dispatch_mode,
retry_max_attempts: req.retry_max_attempts,
retry_backoff: req.retry_backoff,
retry_base_ms: req.retry_base_ms,
registered_by_principal: req.registered_by_principal,
created_at: now,
updated_at: now,
details: TriggerDetails::Cron {
schedule: req.schedule,
timezone: req.timezone,
last_fired_at: None,
},
};
self.inner.lock().await.insert(id, trigger.clone());
Ok(trigger)
}
async fn create_files_trigger(
&self,
app_id: AppId,
req: CreateFilesTrigger,
) -> Result<Trigger, TriggerRepoError> {
let now = Utc::now();
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
script_id: req.script_id,
kind: crate::trigger_repo::TriggerKind::Files,
enabled: true,
dispatch_mode: req.dispatch_mode,
retry_max_attempts: req.retry_max_attempts,
retry_backoff: req.retry_backoff,
retry_base_ms: req.retry_base_ms,
registered_by_principal: req.registered_by_principal,
created_at: now,
updated_at: now,
details: TriggerDetails::Files {
collection_glob: req.collection_glob,
ops: req.ops,
},
};
self.inner.lock().await.insert(id, trigger.clone());
Ok(trigger)
}
async fn create_pubsub_trigger(
&self,
app_id: AppId,
req: CreatePubsubTrigger,
) -> Result<Trigger, TriggerRepoError> {
let now = Utc::now();
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
script_id: req.script_id,
kind: crate::trigger_repo::TriggerKind::Pubsub,
enabled: true,
dispatch_mode: req.dispatch_mode,
retry_max_attempts: req.retry_max_attempts,
retry_backoff: req.retry_backoff,
retry_base_ms: req.retry_base_ms,
registered_by_principal: req.registered_by_principal,
created_at: now,
updated_at: now,
details: TriggerDetails::Pubsub {
topic_pattern: req.topic_pattern,
},
};
self.inner.lock().await.insert(id, trigger.clone());
Ok(trigger)
}
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> { async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
Ok(self Ok(self
.inner .inner
@@ -555,6 +819,14 @@ mod tests {
) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError> { ) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError> {
Ok(vec![]) Ok(vec![])
} }
async fn list_matching_files(
&self,
_app_id: AppId,
_collection: &str,
_op: FilesEventOp,
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError> {
Ok(vec![])
}
async fn list_matching_dead_letter( async fn list_matching_dead_letter(
&self, &self,
_app_id: AppId, _app_id: AppId,
@@ -1281,6 +1553,169 @@ mod tests {
); );
} }
// ----------------------------------------------------------------
// v1.1.4: cron trigger create.
// ----------------------------------------------------------------
fn cron_req(script_id: ScriptId, schedule: &str, timezone: &str) -> CreateCronTriggerRequest {
CreateCronTriggerRequest {
script_id,
schedule: schedule.into(),
timezone: timezone.into(),
dispatch_mode: TriggerDispatchMode::Async,
retry_max_attempts: None,
retry_backoff: None,
retry_base_ms: None,
}
}
#[tokio::test]
async fn cron_trigger_create_succeeds() {
let app_id = AppId::new();
let script_id = ScriptId::new();
let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id);
let (status, Json(trigger)) = create_cron_trigger(
State(state),
Extension(member_principal()),
Path(app_id),
Json(cron_req(
script_id,
"0 0 9 * * MON-FRI",
"America/Los_Angeles",
)),
)
.await
.unwrap();
assert_eq!(status, StatusCode::CREATED);
assert!(matches!(
trigger.kind,
crate::trigger_repo::TriggerKind::Cron
));
match trigger.details {
TriggerDetails::Cron {
schedule,
timezone,
last_fired_at,
} => {
assert_eq!(schedule, "0 0 9 * * MON-FRI");
assert_eq!(timezone, "America/Los_Angeles");
assert!(last_fired_at.is_none());
}
other => panic!("expected Cron details, got {other:?}"),
}
}
#[tokio::test]
async fn cron_trigger_rejects_invalid_schedule() {
let app_id = AppId::new();
let script_id = ScriptId::new();
let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id);
let res = create_cron_trigger(
State(state),
Extension(member_principal()),
Path(app_id),
// 5-field expression — not the 6-field format we accept.
Json(cron_req(script_id, "* * * * *", "UTC")),
)
.await;
let err = res.expect_err("invalid schedule should reject");
let msg = match err {
TriggersApiError::Invalid(m) => m,
other => panic!("expected Invalid, got {other:?}"),
};
assert!(msg.to_lowercase().contains("schedule"), "got {msg}");
}
#[tokio::test]
async fn cron_trigger_rejects_unknown_timezone() {
let app_id = AppId::new();
let script_id = ScriptId::new();
let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id);
let res = create_cron_trigger(
State(state),
Extension(member_principal()),
Path(app_id),
Json(cron_req(script_id, "0 * * * * *", "Mars/Phobos")),
)
.await;
let err = res.expect_err("unknown timezone should reject");
let msg = match err {
TriggersApiError::Invalid(m) => m,
other => panic!("expected Invalid, got {other:?}"),
};
assert!(msg.to_lowercase().contains("timezone"), "got {msg}");
}
#[tokio::test]
async fn cron_trigger_rejects_module_target() {
let app_id = AppId::new();
let script_id = ScriptId::new();
let state = TriggersState {
triggers: Arc::new(InMemoryTriggerRepo::default()),
apps: InMemoryAppRepo::with(app_id),
authz: Arc::new(AlwaysAllowAuthzRepo),
scripts: InMemoryScriptRepo::with_module(app_id, script_id),
config: TriggerConfig::conservative(),
};
let res = create_cron_trigger(
State(state),
Extension(member_principal()),
Path(app_id),
Json(cron_req(script_id, "0 * * * * *", "UTC")),
)
.await;
let err = res.expect_err("module script should be rejected as cron target");
let msg = match err {
TriggersApiError::Invalid(m) => m,
other => panic!("expected Invalid, got {other:?}"),
};
assert!(msg.to_lowercase().contains("module"), "got {msg}");
}
#[tokio::test]
async fn cron_trigger_rejects_cross_app_script() {
// v1.1.3 isolation gap regression: app A cannot target app B's
// script via a cron trigger.
let app_a = AppId::new();
let app_b = AppId::new();
let script_id = ScriptId::new();
let state = TriggersState {
triggers: Arc::new(InMemoryTriggerRepo::default()),
apps: InMemoryAppRepo::with(app_a),
authz: Arc::new(AlwaysAllowAuthzRepo),
scripts: InMemoryScriptRepo::with_endpoint(app_b, script_id),
config: TriggerConfig::conservative(),
};
let res = create_cron_trigger(
State(state),
Extension(member_principal()),
Path(app_a),
Json(cron_req(script_id, "0 * * * * *", "UTC")),
)
.await;
let err = res.expect_err("cross-app cron target should reject");
let msg = match err {
TriggersApiError::Invalid(m) => m,
other => panic!("expected Invalid, got {other:?}"),
};
assert!(msg.to_lowercase().contains("does not belong"), "got {msg}");
}
#[tokio::test]
async fn cron_trigger_member_without_role_is_forbidden() {
let app_id = AppId::new();
let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id);
let res = create_cron_trigger(
State(state),
Extension(member_principal()),
Path(app_id),
Json(cron_req(ScriptId::new(), "0 * * * * *", "UTC")),
)
.await;
let err = res.expect_err("member without role should be forbidden");
assert!(matches!(err, TriggersApiError::Forbidden));
}
#[tokio::test] #[tokio::test]
async fn kv_trigger_accepts_endpoint_target() { async fn kv_trigger_accepts_endpoint_target() {
let app_id = AppId::new(); let app_id = AppId::new();
@@ -1304,4 +1739,258 @@ mod tests {
.expect("endpoint target should succeed"); .expect("endpoint target should succeed");
assert_eq!(status, StatusCode::CREATED); assert_eq!(status, StatusCode::CREATED);
} }
// ----------------------------------------------------------------
// v1.1.5: files + pubsub trigger create (Layout-E reject coverage).
// ----------------------------------------------------------------
fn files_req(script_id: ScriptId, glob: &str) -> CreateFilesTriggerRequest {
CreateFilesTriggerRequest {
script_id,
collection_glob: glob.into(),
ops: vec![FilesEventOp::Create],
dispatch_mode: TriggerDispatchMode::Async,
retry_max_attempts: None,
retry_backoff: None,
retry_base_ms: None,
}
}
#[tokio::test]
async fn files_trigger_create_succeeds() {
let app_id = AppId::new();
let script_id = ScriptId::new();
let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id);
let (status, Json(trigger)) = create_files_trigger(
State(state),
Extension(member_principal()),
Path(app_id),
Json(files_req(script_id, "avatars")),
)
.await
.unwrap();
assert_eq!(status, StatusCode::CREATED);
assert!(matches!(
trigger.kind,
crate::trigger_repo::TriggerKind::Files
));
match trigger.details {
TriggerDetails::Files {
collection_glob,
ops,
} => {
assert_eq!(collection_glob, "avatars");
assert_eq!(ops, vec![FilesEventOp::Create]);
}
other => panic!("expected Files details, got {other:?}"),
}
}
#[tokio::test]
async fn files_trigger_empty_glob_rejected() {
let app_id = AppId::new();
let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id);
let res = create_files_trigger(
State(state),
Extension(member_principal()),
Path(app_id),
Json(files_req(ScriptId::new(), " ")),
)
.await;
assert!(matches!(
res.expect_err("empty glob"),
TriggersApiError::Invalid(_)
));
}
#[tokio::test]
async fn files_trigger_rejects_module_target() {
let app_id = AppId::new();
let script_id = ScriptId::new();
let state = TriggersState {
triggers: Arc::new(InMemoryTriggerRepo::default()),
apps: InMemoryAppRepo::with(app_id),
authz: Arc::new(AlwaysAllowAuthzRepo),
scripts: InMemoryScriptRepo::with_module(app_id, script_id),
config: TriggerConfig::conservative(),
};
let res = create_files_trigger(
State(state),
Extension(member_principal()),
Path(app_id),
Json(files_req(script_id, "avatars")),
)
.await;
let msg = match res.expect_err("module rejected") {
TriggersApiError::Invalid(m) => m,
other => panic!("expected Invalid, got {other:?}"),
};
assert!(msg.to_lowercase().contains("module"));
}
#[tokio::test]
async fn files_trigger_rejects_cross_app_script() {
let app_a = AppId::new();
let app_b = AppId::new();
let script_id = ScriptId::new();
let state = TriggersState {
triggers: Arc::new(InMemoryTriggerRepo::default()),
apps: InMemoryAppRepo::with(app_a),
authz: Arc::new(AlwaysAllowAuthzRepo),
scripts: InMemoryScriptRepo::with_endpoint(app_b, script_id),
config: TriggerConfig::conservative(),
};
let res = create_files_trigger(
State(state),
Extension(member_principal()),
Path(app_a),
Json(files_req(script_id, "avatars")),
)
.await;
let msg = match res.expect_err("cross-app rejected") {
TriggersApiError::Invalid(m) => m,
other => panic!("expected Invalid, got {other:?}"),
};
assert!(msg.to_lowercase().contains("does not belong"));
}
#[tokio::test]
async fn files_trigger_member_without_role_is_forbidden() {
let app_id = AppId::new();
let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id);
let res = create_files_trigger(
State(state),
Extension(member_principal()),
Path(app_id),
Json(files_req(ScriptId::new(), "avatars")),
)
.await;
assert!(matches!(
res.expect_err("forbidden"),
TriggersApiError::Forbidden
));
}
fn pubsub_req(script_id: ScriptId, pattern: &str) -> CreatePubsubTriggerRequest {
CreatePubsubTriggerRequest {
script_id,
topic_pattern: pattern.into(),
dispatch_mode: TriggerDispatchMode::Async,
retry_max_attempts: None,
retry_backoff: None,
retry_base_ms: None,
}
}
#[tokio::test]
async fn pubsub_trigger_create_succeeds() {
let app_id = AppId::new();
let script_id = ScriptId::new();
let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id);
let (status, Json(trigger)) = create_pubsub_trigger(
State(state),
Extension(member_principal()),
Path(app_id),
Json(pubsub_req(script_id, "user.*")),
)
.await
.unwrap();
assert_eq!(status, StatusCode::CREATED);
match trigger.details {
TriggerDetails::Pubsub { topic_pattern } => assert_eq!(topic_pattern, "user.*"),
other => panic!("expected Pubsub details, got {other:?}"),
}
}
#[tokio::test]
async fn pubsub_trigger_rejects_bad_pattern() {
let app_id = AppId::new();
let script_id = ScriptId::new();
let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id);
for bad in ["*.created", "a.*.b", "**"] {
let res = create_pubsub_trigger(
State(state.clone()),
Extension(member_principal()),
Path(app_id),
Json(pubsub_req(script_id, bad)),
)
.await;
let msg = match res.expect_err("bad pattern") {
TriggersApiError::Invalid(m) => m,
other => panic!("expected Invalid, got {other:?}"),
};
assert!(
msg.contains("unsupported pubsub topic pattern"),
"got {msg} for {bad}"
);
}
}
#[tokio::test]
async fn pubsub_trigger_rejects_module_target() {
let app_id = AppId::new();
let script_id = ScriptId::new();
let state = TriggersState {
triggers: Arc::new(InMemoryTriggerRepo::default()),
apps: InMemoryAppRepo::with(app_id),
authz: Arc::new(AlwaysAllowAuthzRepo),
scripts: InMemoryScriptRepo::with_module(app_id, script_id),
config: TriggerConfig::conservative(),
};
let res = create_pubsub_trigger(
State(state),
Extension(member_principal()),
Path(app_id),
Json(pubsub_req(script_id, "user.*")),
)
.await;
let msg = match res.expect_err("module rejected") {
TriggersApiError::Invalid(m) => m,
other => panic!("expected Invalid, got {other:?}"),
};
assert!(msg.to_lowercase().contains("module"));
}
#[tokio::test]
async fn pubsub_trigger_rejects_cross_app_script() {
let app_a = AppId::new();
let app_b = AppId::new();
let script_id = ScriptId::new();
let state = TriggersState {
triggers: Arc::new(InMemoryTriggerRepo::default()),
apps: InMemoryAppRepo::with(app_a),
authz: Arc::new(AlwaysAllowAuthzRepo),
scripts: InMemoryScriptRepo::with_endpoint(app_b, script_id),
config: TriggerConfig::conservative(),
};
let res = create_pubsub_trigger(
State(state),
Extension(member_principal()),
Path(app_a),
Json(pubsub_req(script_id, "user.*")),
)
.await;
let msg = match res.expect_err("cross-app rejected") {
TriggersApiError::Invalid(m) => m,
other => panic!("expected Invalid, got {other:?}"),
};
assert!(msg.to_lowercase().contains("does not belong"));
}
#[tokio::test]
async fn pubsub_trigger_member_without_role_is_forbidden() {
let app_id = AppId::new();
let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id);
let res = create_pubsub_trigger(
State(state),
Extension(member_principal()),
Path(app_id),
Json(pubsub_req(ScriptId::new(), "user.*")),
)
.await;
assert!(matches!(
res.expect_err("forbidden"),
TriggersApiError::Forbidden
));
}
} }

View File

@@ -3,6 +3,16 @@
## tables ## tables
table: abandoned_executions
id: uuid NOT NULL default=gen_random_uuid()
app_id: uuid NOT NULL
outbox_id: uuid NOT NULL
script_id: uuid NULL
inbox_id: uuid NOT NULL
status_code: integer NOT NULL
result_summary: text NULL
created_at: timestamp with time zone NOT NULL default=now()
table: admin_sessions table: admin_sessions
token_hash: text NOT NULL token_hash: text NOT NULL
user_id: uuid NOT NULL user_id: uuid NOT NULL
@@ -61,6 +71,48 @@ table: apps
created_at: timestamp with time zone NOT NULL default=now() created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now() updated_at: timestamp with time zone NOT NULL default=now()
table: cron_trigger_details
trigger_id: uuid NOT NULL
schedule: text NOT NULL
timezone: text NOT NULL default='UTC'::text
last_fired_at: timestamp with time zone NULL
table: dead_letter_trigger_details
trigger_id: uuid NOT NULL
source_filter: text NULL
trigger_id_filter: uuid NULL
script_id_filter: uuid NULL
table: dead_letters
id: uuid NOT NULL default=gen_random_uuid()
app_id: uuid NOT NULL
original_event_id: uuid NOT NULL
source: text NOT NULL
op: text NOT NULL
trigger_id: uuid NULL
script_id: uuid NULL
payload: jsonb NOT NULL
attempt_count: integer NOT NULL
first_attempt_at: timestamp with time zone NOT NULL
last_attempt_at: timestamp with time zone NOT NULL
last_error: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
resolved_at: timestamp with time zone NULL
resolution: text NULL
table: docs
app_id: uuid NOT NULL
collection: text NOT NULL
id: uuid NOT NULL
data: jsonb NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: docs_trigger_details
trigger_id: uuid NOT NULL
collection_glob: text NOT NULL
ops: ARRAY NOT NULL
table: execution_logs table: execution_logs
id: uuid NOT NULL default=gen_random_uuid() id: uuid NOT NULL default=gen_random_uuid()
script_id: uuid NOT NULL script_id: uuid NOT NULL
@@ -76,6 +128,56 @@ table: execution_logs
created_at: timestamp with time zone NOT NULL default=now() created_at: timestamp with time zone NOT NULL default=now()
app_id: uuid NOT NULL app_id: uuid NOT NULL
table: files
app_id: uuid NOT NULL
collection: text NOT NULL
id: uuid NOT NULL
name: text NOT NULL
content_type: text NOT NULL
size_bytes: bigint NOT NULL
checksum_sha256: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: files_trigger_details
trigger_id: uuid NOT NULL
collection_glob: text NOT NULL
ops: ARRAY NOT NULL
table: kv_entries
app_id: uuid NOT NULL
collection: text NOT NULL
key: text NOT NULL
value: jsonb NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: kv_trigger_details
trigger_id: uuid NOT NULL
collection_glob: text NOT NULL
ops: ARRAY NOT NULL
table: outbox
id: uuid NOT NULL default=gen_random_uuid()
app_id: uuid NOT NULL
source_kind: text NOT NULL
trigger_id: uuid NULL
script_id: uuid NULL
reply_to: uuid NULL
payload: jsonb NOT NULL
origin_principal: uuid NULL
trigger_depth: integer NOT NULL default=0
root_execution_id: uuid NULL
attempt_count: integer NOT NULL default=0
next_attempt_at: timestamp with time zone NOT NULL default=now()
claimed_at: timestamp with time zone NULL
claimed_by: text NULL
created_at: timestamp with time zone NOT NULL default=now()
table: pubsub_trigger_details
trigger_id: uuid NOT NULL
topic_pattern: text NOT NULL
table: routes table: routes
id: uuid NOT NULL default=gen_random_uuid() id: uuid NOT NULL default=gen_random_uuid()
script_id: uuid NOT NULL script_id: uuid NOT NULL
@@ -87,6 +189,13 @@ table: routes
method: text NULL method: text NULL
created_at: timestamp with time zone NOT NULL default=now() created_at: timestamp with time zone NOT NULL default=now()
app_id: uuid NOT NULL app_id: uuid NOT NULL
dispatch_mode: text NOT NULL default='sync'::text
table: script_imports
app_id: uuid NOT NULL
importer_script_id: uuid NOT NULL
imported_script_id: uuid NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
table: scripts table: scripts
id: uuid NOT NULL default=gen_random_uuid() id: uuid NOT NULL default=gen_random_uuid()
@@ -100,9 +209,28 @@ table: scripts
updated_at: timestamp with time zone NOT NULL default=now() updated_at: timestamp with time zone NOT NULL default=now()
sandbox: jsonb NOT NULL default='{}'::jsonb sandbox: jsonb NOT NULL default='{}'::jsonb
app_id: uuid NOT NULL app_id: uuid NOT NULL
kind: text NOT NULL default='endpoint'::text
table: triggers
id: uuid NOT NULL default=gen_random_uuid()
app_id: uuid NOT NULL
script_id: uuid NOT NULL
kind: text NOT NULL
enabled: boolean NOT NULL default=true
dispatch_mode: text NOT NULL default='async'::text
retry_max_attempts: integer NOT NULL
retry_backoff: text NOT NULL
retry_base_ms: integer NOT NULL
registered_by_principal: uuid NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
## indexes ## indexes
indexes on abandoned_executions:
abandoned_executions_pkey: public.abandoned_executions USING btree (id)
idx_abandoned_executions_gc: public.abandoned_executions USING btree (created_at)
indexes on admin_sessions: indexes on admin_sessions:
admin_sessions_expiry_idx: public.admin_sessions USING btree (expires_at) admin_sessions_expiry_idx: public.admin_sessions USING btree (expires_at)
admin_sessions_pkey: public.admin_sessions USING btree (token_hash) admin_sessions_pkey: public.admin_sessions USING btree (token_hash)
@@ -135,11 +263,53 @@ indexes on apps:
apps_pkey: public.apps USING btree (id) apps_pkey: public.apps USING btree (id)
apps_slug_key: public.apps USING btree (slug) apps_slug_key: public.apps USING btree (slug)
indexes on cron_trigger_details:
cron_trigger_details_pkey: public.cron_trigger_details USING btree (trigger_id)
idx_cron_triggers_due: public.cron_trigger_details USING btree (last_fired_at)
indexes on dead_letter_trigger_details:
dead_letter_trigger_details_pkey: public.dead_letter_trigger_details USING btree (trigger_id)
indexes on dead_letters:
dead_letters_pkey: public.dead_letters USING btree (id)
idx_dead_letters_app_unresolved: public.dead_letters USING btree (app_id) WHERE (resolved_at IS NULL)
idx_dead_letters_gc: public.dead_letters USING btree (created_at)
indexes on docs:
docs_pkey: public.docs USING btree (app_id, collection, id)
idx_docs_app_collection: public.docs USING btree (app_id, collection)
idx_docs_data_gin: public.docs USING gin (data jsonb_path_ops)
indexes on docs_trigger_details:
docs_trigger_details_pkey: public.docs_trigger_details USING btree (trigger_id)
indexes on execution_logs: indexes on execution_logs:
execution_logs_app_id_created_at_idx: public.execution_logs USING btree (app_id, created_at DESC) execution_logs_app_id_created_at_idx: public.execution_logs USING btree (app_id, created_at DESC)
execution_logs_pkey: public.execution_logs USING btree (id) execution_logs_pkey: public.execution_logs USING btree (id)
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC) execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
indexes on files:
files_pkey: public.files USING btree (app_id, collection, id)
idx_files_app_collection: public.files USING btree (app_id, collection)
indexes on files_trigger_details:
files_trigger_details_pkey: public.files_trigger_details USING btree (trigger_id)
indexes on kv_entries:
idx_kv_entries_app_collection: public.kv_entries USING btree (app_id, collection)
kv_entries_pkey: public.kv_entries USING btree (app_id, collection, key)
indexes on kv_trigger_details:
kv_trigger_details_pkey: public.kv_trigger_details USING btree (trigger_id)
indexes on outbox:
idx_outbox_app: public.outbox USING btree (app_id)
idx_outbox_due: public.outbox USING btree (next_attempt_at) WHERE (claimed_at IS NULL)
outbox_pkey: public.outbox USING btree (id)
indexes on pubsub_trigger_details:
pubsub_trigger_details_pkey: public.pubsub_trigger_details USING btree (trigger_id)
indexes on routes: indexes on routes:
routes_app_id_idx: public.routes USING btree (app_id) routes_app_id_idx: public.routes USING btree (app_id)
routes_lookup_idx: public.routes USING btree (host_kind, host) routes_lookup_idx: public.routes USING btree (host_kind, host)
@@ -147,13 +317,28 @@ indexes on routes:
routes_script_id_idx: public.routes USING btree (script_id) routes_script_id_idx: public.routes USING btree (script_id)
routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text)) routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text))
indexes on script_imports:
idx_script_imports_app: public.script_imports USING btree (app_id)
idx_script_imports_imported: public.script_imports USING btree (imported_script_id)
script_imports_pkey: public.script_imports USING btree (importer_script_id, imported_script_id)
indexes on scripts: indexes on scripts:
idx_scripts_app_kind: public.scripts USING btree (app_id, kind)
scripts_app_id_idx: public.scripts USING btree (app_id) scripts_app_id_idx: public.scripts USING btree (app_id)
scripts_name_uidx: public.scripts USING btree (app_id, lower(name)) scripts_name_uidx: public.scripts USING btree (app_id, lower(name))
scripts_pkey: public.scripts USING btree (id) scripts_pkey: public.scripts USING btree (id)
indexes on triggers:
idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE (enabled = true)
idx_triggers_app_pubsub_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (kind = 'pubsub'::text))
triggers_pkey: public.triggers USING btree (id)
## constraints ## constraints
constraints on abandoned_executions:
[FOREIGN KEY] abandoned_executions_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] abandoned_executions_pkey: PRIMARY KEY (id)
constraints on admin_sessions: constraints on admin_sessions:
[FOREIGN KEY] admin_sessions_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE [FOREIGN KEY] admin_sessions_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
[PRIMARY KEY] admin_sessions_pkey: PRIMARY KEY (token_hash) [PRIMARY KEY] admin_sessions_pkey: PRIMARY KEY (token_hash)
@@ -189,25 +374,89 @@ constraints on apps:
[PRIMARY KEY] apps_pkey: PRIMARY KEY (id) [PRIMARY KEY] apps_pkey: PRIMARY KEY (id)
[UNIQUE] apps_slug_key: UNIQUE (slug) [UNIQUE] apps_slug_key: UNIQUE (slug)
constraints on cron_trigger_details:
[FOREIGN KEY] cron_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
[PRIMARY KEY] cron_trigger_details_pkey: PRIMARY KEY (trigger_id)
constraints on dead_letter_trigger_details:
[FOREIGN KEY] dead_letter_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
[PRIMARY KEY] dead_letter_trigger_details_pkey: PRIMARY KEY (trigger_id)
constraints on dead_letters:
[CHECK] dead_letters_resolution_check: CHECK ((resolution = ANY (ARRAY['replayed'::text, 'ignored'::text, 'handled_by_script'::text, 'handler_failed'::text])))
[FOREIGN KEY] dead_letters_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] dead_letters_pkey: PRIMARY KEY (id)
constraints on docs:
[FOREIGN KEY] docs_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] docs_pkey: PRIMARY KEY (app_id, collection, id)
constraints on docs_trigger_details:
[FOREIGN KEY] docs_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
[PRIMARY KEY] docs_trigger_details_pkey: PRIMARY KEY (trigger_id)
constraints on execution_logs: constraints on execution_logs:
[CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text]))) [CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text])))
[FOREIGN KEY] execution_logs_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE [FOREIGN KEY] execution_logs_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE [FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id) [PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
constraints on files:
[FOREIGN KEY] files_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] files_pkey: PRIMARY KEY (app_id, collection, id)
constraints on files_trigger_details:
[FOREIGN KEY] files_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
[PRIMARY KEY] files_trigger_details_pkey: PRIMARY KEY (trigger_id)
constraints on kv_entries:
[FOREIGN KEY] kv_entries_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] kv_entries_pkey: PRIMARY KEY (app_id, collection, key)
constraints on kv_trigger_details:
[FOREIGN KEY] kv_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
[PRIMARY KEY] kv_trigger_details_pkey: PRIMARY KEY (trigger_id)
constraints on outbox:
[CHECK] outbox_source_kind_check: CHECK ((source_kind = ANY (ARRAY['http'::text, 'kv'::text, 'dead_letter'::text, 'docs'::text, 'cron'::text, 'files'::text, 'pubsub'::text])))
[FOREIGN KEY] outbox_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] outbox_pkey: PRIMARY KEY (id)
constraints on pubsub_trigger_details:
[FOREIGN KEY] pubsub_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
[PRIMARY KEY] pubsub_trigger_details_pkey: PRIMARY KEY (trigger_id)
constraints on routes: constraints on routes:
[CHECK] routes_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text]))) [CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
[CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text]))) [CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
[FOREIGN KEY] routes_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE [FOREIGN KEY] routes_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE [FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id) [PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
constraints on script_imports:
[FOREIGN KEY] script_imports_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] script_imports_imported_script_id_fkey: FOREIGN KEY (imported_script_id) REFERENCES scripts(id) ON DELETE CASCADE
[FOREIGN KEY] script_imports_importer_script_id_fkey: FOREIGN KEY (importer_script_id) REFERENCES scripts(id) ON DELETE CASCADE
[PRIMARY KEY] script_imports_pkey: PRIMARY KEY (importer_script_id, imported_script_id)
constraints on scripts: constraints on scripts:
[CHECK] scripts_kind_check: CHECK ((kind = ANY (ARRAY['endpoint'::text, 'module'::text])))
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048))) [CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
[CHECK] scripts_module_name_shape: CHECK (((kind <> 'module'::text) OR (name ~ '^[a-zA-Z_][a-zA-Z0-9_]{0,63}$'::text)))
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300))) [CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT [FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id) [PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
constraints on triggers:
[CHECK] triggers_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
[CHECK] triggers_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'dead_letter'::text, 'docs'::text, 'cron'::text, 'files'::text, 'pubsub'::text])))
[CHECK] triggers_retry_backoff_check: CHECK ((retry_backoff = ANY (ARRAY['exponential'::text, 'linear'::text, 'constant'::text])))
[FOREIGN KEY] triggers_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] triggers_registered_by_principal_fkey: FOREIGN KEY (registered_by_principal) REFERENCES admin_users(id) ON DELETE CASCADE
[FOREIGN KEY] triggers_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
[PRIMARY KEY] triggers_pkey: PRIMARY KEY (id)
## applied migrations ## applied migrations
0001: init 0001: init
0002: sandbox 0002: sandbox
@@ -215,3 +464,17 @@ constraints on scripts:
0004: admin auth 0004: admin auth
0005: apps 0005: apps
0006: users authz 0006: users authz
0007: kv
0008: triggers
0009: outbox
0010: dead letters
0011: abandoned executions
0012: routes dispatch mode
0013: docs
0014: docs triggers
0015: scripts kind
0016: script imports
0017: cron triggers
0018: files
0019: files triggers
0020: pubsub triggers

View File

@@ -25,22 +25,46 @@
//! //!
//! Review the resulting diff in the same PR as the new migration. //! Review the resulting diff in the same PR as the new migration.
//! //!
//! Like the orchestrator integration tests, this is `#[ignore]`'d by //! v1.1.5: this test is no longer `#[ignore]`'d. It runs whenever
//! default so plain `cargo test --workspace` stays green without //! `DATABASE_URL` is set (CI wires a `postgres:15` service) and **skips
//! infrastructure. //! cleanly** when it's absent, so plain `cargo test --workspace` stays
//! green on machines without Postgres. Unlike the previous
//! `#[sqlx::test]` form (which spun up an isolated throwaway database),
//! it now applies the migrations against the `DATABASE_URL` database
//! directly — migrations are forward-only and idempotent, and CI's
//! Postgres is fresh, so the structural dump is identical either way.
use std::fmt::Write as _; use std::fmt::Write as _;
use std::path::PathBuf; use std::path::PathBuf;
use sqlx::postgres::PgPoolOptions;
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
const SCHEMA: &str = "public"; const SCHEMA: &str = "public";
const SNAPSHOT_PATH: &str = "tests/expected_schema.txt"; const SNAPSHOT_PATH: &str = "tests/expected_schema.txt";
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[tokio::test]
#[sqlx::test(migrations = "./migrations")] async fn schema_after_replay_matches_snapshot() {
async fn schema_after_replay_matches_snapshot(pool: PgPool) { // Skip cleanly when DATABASE_URL is unset so `cargo test --workspace`
// stays green without Postgres. CI sets it (postgres:15 service).
let Ok(url) = std::env::var("DATABASE_URL") else {
eprintln!(
"schema_snapshot: DATABASE_URL unset — skipping. Set it (e.g. \
postgres://picloud:picloud@localhost:5432/picloud) to run this guardrail."
);
return;
};
let pool = PgPoolOptions::new()
.max_connections(1)
.connect(&url)
.await
.expect("connect to DATABASE_URL");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("apply migrations");
let actual = dump_schema(&pool).await; let actual = dump_schema(&pool).await;
let snapshot_file = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(SNAPSHOT_PATH); let snapshot_file = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(SNAPSHOT_PATH);

View File

@@ -11,19 +11,20 @@ use axum::{routing::get, Json, Router};
use picloud_executor_core::{Engine, Limits}; use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{ use picloud_manager_core::{
admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router, admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
attach_principal_if_present, auth_router, compile_routes, dead_letters_router, migrations, attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
require_authenticated, route_admin_router, triggers_router, AbandonedRepo, files_admin_router, migrations, require_authenticated, route_admin_router, triggers_router,
AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository,
ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository,
AppRepository, AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, Dispatcher, AppMembersState, AppRepository, AppsState, AuthState, AuthzRepo, DeadLetterRepo,
DocsServiceImpl, KvServiceImpl, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, DeadLettersState, Dispatcher, DocsServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl,
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository, FsFilesRepo, HttpConfig, HttpServiceImpl, KvServiceImpl, OutboxEventEmitter, OutboxRepo,
PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo, PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
PostgresAppRepository, PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo,
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo,
PostgresRouteRepository, PostgresScriptRepository, PostgresTriggerRepo, PrincipalResolver, PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresTriggerRepo,
RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling, ScriptRepository, PrincipalResolver, PubsubServiceImpl, RepoResolver, RouteAdminState, RouteRepository,
TriggerConfig, TriggerRepo, TriggersState, SandboxCeiling, ScriptRepository, TriggerConfig, TriggerRepo, TriggersState,
}; };
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
use picloud_orchestrator_core::{ use picloud_orchestrator_core::{
@@ -31,9 +32,9 @@ use picloud_orchestrator_core::{
LocalExecutorClient, LocalExecutorClient,
}; };
use picloud_shared::{ use picloud_shared::{
DeadLetterService, DocsService, ExecutionLogSink, InboxResolver, KvService, OutboxWriter, DeadLetterService, DocsService, ExecutionLogSink, FilesService, HttpService, InboxResolver,
ScriptValidator, ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION, SDK_VERSION, KvService, OutboxWriter, PubsubService, ScriptValidator, ServiceEventEmitter, Services,
WIRE_VERSION, API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION,
}; };
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool; use sqlx::PgPool;
@@ -143,9 +144,47 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
outbox_repo.clone(), outbox_repo.clone(),
authz.clone(), authz.clone(),
)); ));
let modules: Arc<dyn picloud_shared::ModuleSource> = let modules: Arc<dyn picloud_shared::ModuleSource> = Arc::new(
Arc::new(picloud_manager_core::PostgresModuleSource::new(pool)); picloud_manager_core::PostgresModuleSource::new(pool.clone()),
let services = Services::new(kv, docs, dl_service.clone(), events, modules); );
// v1.1.4 outbound HTTP. The reqwest client is built once here with
// the SSRF deny-list resolver. `PICLOUD_HTTP_ALLOW_PRIVATE=true`
// disables the deny-list entirely — dev/test only, so warn loudly.
let http_config = HttpConfig::from_env();
if http_config.allow_private {
tracing::warn!(
"PICLOUD_HTTP_ALLOW_PRIVATE is set — the outbound-HTTP SSRF deny-list is DISABLED. \
Scripts can reach loopback/private/link-local addresses. Do NOT use in production."
);
}
let http: Arc<dyn HttpService> = Arc::new(HttpServiceImpl::new(http_config, authz.clone()));
// v1.1.5 filesystem-backed blob storage. Metadata lives in Postgres;
// the bytes live on disk under `PICLOUD_FILES_ROOT` (default ./data).
let files_config = FilesConfig::from_env();
let files_max_size = files_config.max_file_size_bytes;
let files_repo = Arc::new(FsFilesRepo::new(pool.clone(), files_config));
let files: Arc<dyn FilesService> = Arc::new(FilesServiceImpl::new(
files_repo.clone(),
authz.clone(),
events.clone(),
files_max_size,
));
// v1.1.5 durable pub/sub. Publishes fan out to matching pubsub
// triggers at publish time (one outbox row each), delivered by the
// same dispatcher as every other async trigger.
let pubsub_repo = Arc::new(PostgresPubsubRepo::new(pool.clone()));
let pubsub: Arc<dyn PubsubService> =
Arc::new(PubsubServiceImpl::new(pubsub_repo, authz.clone()));
let services = Services::new(
kv,
docs,
dl_service.clone(),
events,
modules,
http,
files,
pubsub,
);
let engine = Arc::new(Engine::new(Limits::default(), services)); let engine = Arc::new(Engine::new(Limits::default(), services));
// Compile the routes table once at startup; admin writes refresh it. // Compile the routes table once at startup; admin writes refresh it.
@@ -241,6 +280,10 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
abandoned_repo.clone(), abandoned_repo.clone(),
trigger_config.abandoned_retention_days, trigger_config.abandoned_retention_days,
); );
// v1.1.4: cron scheduler. Polls cron_trigger_details on a tick and
// enqueues due triggers into the outbox; the dispatcher above
// delivers them like any other async trigger.
picloud_manager_core::spawn_cron_scheduler(pool, trigger_config.cron_tick_interval_ms);
let triggers_state = TriggersState { let triggers_state = TriggersState {
triggers: trigger_repo, triggers: trigger_repo,
apps: apps_repo.clone(), apps: apps_repo.clone(),
@@ -254,6 +297,11 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
apps: apps_repo.clone(), apps: apps_repo.clone(),
authz: authz.clone(), authz: authz.clone(),
}; };
let files_admin_state = FilesAdminState {
files: files_repo,
apps: apps_repo.clone(),
authz: authz.clone(),
};
let apps_state = AppsState { let apps_state = AppsState {
apps: apps_repo, apps: apps_repo,
domains: domains_repo, domains: domains_repo,
@@ -296,6 +344,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
.merge(app_members_router(app_members_state)) .merge(app_members_router(app_members_state))
.merge(api_keys_router(api_keys_state)) .merge(api_keys_router(api_keys_state))
.merge(triggers_router(triggers_state)) .merge(triggers_router(triggers_state))
.merge(files_admin_router(files_admin_state))
.merge(dead_letters_router(dead_letters_state)) .merge(dead_letters_router(dead_letters_state))
.layer(from_fn_with_state( .layer(from_fn_with_state(
auth_state.clone(), auth_state.clone(),

339
crates/shared/src/files.rs Normal file
View File

@@ -0,0 +1,339 @@
//! `FilesService` — the v1.1.5 filesystem-backed blob store contract.
//!
//! Lives in `picloud-shared` (not `executor-core`) so the Rhai bridge,
//! the manager-core filesystem+Postgres impl, and any in-memory test
//! impl can all depend on the same trait without dragging
//! `executor-core` into a Postgres or filesystem dependency.
//!
//! Implementations MUST derive every storage `app_id` from `cx.app_id`
//! — never from a script-passed argument. That is the cross-app
//! isolation boundary; see `docs/sdk-shape.md`.
//!
//! `FilesService` is collection-scoped: scripts get a handle via
//! `files::collection(name)` and call
//! `create`/`head`/`get`/`update`/`delete`/`list` on it. The blob bytes
//! never travel through Postgres or through trigger payloads — the row
//! is metadata + a SHA-256 checksum; the bytes live on the filesystem.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;
use crate::SdkCallCx;
/// POSIX-portable filename cap (255 bytes).
pub const MAX_FILE_NAME_BYTES: usize = 255;
/// RFC 6838 puts a reasonable media-type ceiling around 127 chars.
pub const MAX_CONTENT_TYPE_BYTES: usize = 127;
/// Payload for `create` — a brand-new blob. The id is server-generated
/// (a UUID); scripts never supply it.
#[derive(Debug, Clone)]
pub struct NewFile {
pub name: String,
pub content_type: String,
pub data: Vec<u8>,
}
/// Payload for `update` — replacement bytes plus optional metadata. If
/// `name` / `content_type` are `None` the prior values are kept.
#[derive(Debug, Clone)]
pub struct FileUpdate {
pub data: Vec<u8>,
pub name: Option<String>,
pub content_type: Option<String>,
}
/// File metadata as scripts and triggers see it. Serialized into
/// `ServiceEvent.payload` (the blob bytes are NOT included — files are
/// too big to ship through trigger payloads), and surfaced to Rhai by
/// `head` / `list`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileMeta {
pub id: Uuid,
pub collection: String,
pub name: String,
pub content_type: String,
pub size: u64,
/// Lowercase hex SHA-256 of the content.
pub checksum: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// One page of file metadata from `FilesService::list`. `next_cursor`
/// is `Some` when more pages exist, `None` when exhausted.
#[derive(Debug, Clone)]
pub struct FilesListPage {
pub files: Vec<FileMeta>,
pub next_cursor: Option<String>,
}
#[async_trait]
pub trait FilesService: Send + Sync {
/// Create a new blob; returns its server-generated id. Throws on a
/// missing required field, an over-limit blob, or an invalid
/// collection name.
async fn create(
&self,
cx: &SdkCallCx,
collection: &str,
new: NewFile,
) -> Result<Uuid, FilesError>;
/// Metadata only — no body read. `None` if the file is missing.
async fn head(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
) -> Result<Option<FileMeta>, FilesError>;
/// Full content. `None` if missing. Verifies the stored checksum
/// against the bytes on disk and returns `FilesError::Corrupted`
/// when they diverge.
async fn get(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
) -> Result<Option<Vec<u8>>, FilesError>;
/// Replace content (and optionally metadata). Throws `NotFound`
/// when the file doesn't exist.
async fn update(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
upd: FileUpdate,
) -> Result<(), FilesError>;
/// Delete by id; returns whether the file was present.
async fn delete(&self, cx: &SdkCallCx, collection: &str, id: &str) -> Result<bool, FilesError>;
/// Cursor-paginated metadata listing (same shape as KV's list).
async fn list(
&self,
cx: &SdkCallCx,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<FilesListPage, FilesError>;
}
/// Failure modes surfaced to the Rhai bridge. The bridge converts each
/// to a Rhai runtime error string; the discriminants exist so internal
/// callers (admin endpoints, tests) can react more precisely.
#[derive(Debug, Error)]
pub enum FilesError {
/// Empty collection name, or one containing a path separator / `..`
/// / NUL — rejected at the SDK boundary per `docs/sdk-shape.md`.
#[error("invalid collection name: {0}")]
InvalidCollection(String),
/// A required field on `create` was missing or empty. The string
/// names the field (`name` / `content_type` / `data`).
#[error("missing required field: {0}")]
MissingField(&'static str),
/// Blob exceeds the per-file size cap (default 100 MB,
/// `PICLOUD_FILES_MAX_FILE_SIZE_BYTES`).
#[error("file too large: {size} bytes exceeds limit of {limit} bytes")]
TooLarge { size: usize, limit: usize },
/// Filename exceeds `MAX_FILE_NAME_BYTES`.
#[error("file name too long: {0} bytes exceeds 255")]
NameTooLong(usize),
/// Content-type exceeds `MAX_CONTENT_TYPE_BYTES`.
#[error("content_type too long: {0} bytes exceeds 127")]
ContentTypeTooLong(usize),
/// `update` on a non-existent file.
#[error("file not found")]
NotFound,
/// The bytes on disk no longer match the stored checksum — the
/// filesystem corrupted or a backup was misconfigured. The operator
/// decides what to do with the metadata-vs-bytes mismatch; the repo
/// does NOT auto-delete.
#[error("file content corrupted (checksum mismatch)")]
Corrupted,
/// Caller principal lacked the required capability. Only raised when
/// `cx.principal.is_some()` — scripts running with `principal: None`
/// (public HTTP) operate under script-as-gate semantics and skip
/// the capability check.
#[error("forbidden")]
Forbidden,
/// Anything else — Postgres unavailable, filesystem I/O error, etc.
#[error("files backend error: {0}")]
Backend(String),
}
impl NewFile {
/// Validate required fields + length caps at the SDK boundary.
/// `data` must be non-empty (v1.1.5 treats an empty blob as a
/// missing `data` field — see HANDBACK §7).
///
/// # Errors
///
/// Returns the field-specific [`FilesError`] for the first failing
/// check.
pub fn validate(&self, max_size: usize) -> Result<(), FilesError> {
if self.name.trim().is_empty() {
return Err(FilesError::MissingField("name"));
}
if self.content_type.trim().is_empty() {
return Err(FilesError::MissingField("content_type"));
}
if self.data.is_empty() {
return Err(FilesError::MissingField("data"));
}
if self.name.len() > MAX_FILE_NAME_BYTES {
return Err(FilesError::NameTooLong(self.name.len()));
}
if self.content_type.len() > MAX_CONTENT_TYPE_BYTES {
return Err(FilesError::ContentTypeTooLong(self.content_type.len()));
}
if self.data.len() > max_size {
return Err(FilesError::TooLarge {
size: self.data.len(),
limit: max_size,
});
}
Ok(())
}
}
impl FileUpdate {
/// Validate the replacement bytes + any supplied metadata.
///
/// # Errors
///
/// Returns the field-specific [`FilesError`] for the first failing
/// check.
pub fn validate(&self, max_size: usize) -> Result<(), FilesError> {
if self.data.is_empty() {
return Err(FilesError::MissingField("data"));
}
if let Some(name) = &self.name {
if name.trim().is_empty() {
return Err(FilesError::MissingField("name"));
}
if name.len() > MAX_FILE_NAME_BYTES {
return Err(FilesError::NameTooLong(name.len()));
}
}
if let Some(ct) = &self.content_type {
if ct.trim().is_empty() {
return Err(FilesError::MissingField("content_type"));
}
if ct.len() > MAX_CONTENT_TYPE_BYTES {
return Err(FilesError::ContentTypeTooLong(ct.len()));
}
}
if self.data.len() > max_size {
return Err(FilesError::TooLarge {
size: self.data.len(),
limit: max_size,
});
}
Ok(())
}
}
/// Reject a collection name that is empty or could escape the per-app
/// files tree. UUID-shaped ids never produce traversal paths, but
/// collection names come from scripts so they're validated defensively
/// at both the SDK boundary and the repo.
///
/// # Errors
///
/// Returns [`FilesError::InvalidCollection`] when the name is empty or
/// contains `/`, `\`, `..`, or a NUL byte.
pub fn validate_collection(collection: &str) -> Result<(), FilesError> {
if collection.is_empty() {
return Err(FilesError::InvalidCollection("must not be empty".into()));
}
if collection.contains('/')
|| collection.contains('\\')
|| collection.contains("..")
|| collection.contains('\0')
{
return Err(FilesError::InvalidCollection(format!(
"collection {collection:?} must not contain '/', '\\', '..', or NUL"
)));
}
Ok(())
}
/// Stub used by the test harness so executor-core integration tests
/// (which don't touch files) can construct a `Services` bundle without
/// a filesystem or Postgres. Every call returns
/// `FilesError::Backend("...")` so accidental use surfaces clearly.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopFilesService;
#[async_trait]
impl FilesService for NoopFilesService {
async fn create(
&self,
_cx: &SdkCallCx,
_collection: &str,
_new: NewFile,
) -> Result<Uuid, FilesError> {
Err(FilesError::Backend("files is not wired in".into()))
}
async fn head(
&self,
_cx: &SdkCallCx,
_collection: &str,
_id: &str,
) -> Result<Option<FileMeta>, FilesError> {
Err(FilesError::Backend("files is not wired in".into()))
}
async fn get(
&self,
_cx: &SdkCallCx,
_collection: &str,
_id: &str,
) -> Result<Option<Vec<u8>>, FilesError> {
Err(FilesError::Backend("files is not wired in".into()))
}
async fn update(
&self,
_cx: &SdkCallCx,
_collection: &str,
_id: &str,
_upd: FileUpdate,
) -> Result<(), FilesError> {
Err(FilesError::Backend("files is not wired in".into()))
}
async fn delete(
&self,
_cx: &SdkCallCx,
_collection: &str,
_id: &str,
) -> Result<bool, FilesError> {
Err(FilesError::Backend("files is not wired in".into()))
}
async fn list(
&self,
_cx: &SdkCallCx,
_collection: &str,
_cursor: Option<&str>,
_limit: u32,
) -> Result<FilesListPage, FilesError> {
Err(FilesError::Backend("files is not wired in".into()))
}
}

137
crates/shared/src/http.rs Normal file
View File

@@ -0,0 +1,137 @@
//! `HttpService` — the v1.1.4 outbound-HTTP contract.
//!
//! Lives in `picloud-shared` (not `executor-core` or `manager-core`)
//! so the Rhai bridge and the manager-core reqwest-backed impl can both
//! depend on the same trait without dragging `executor-core` into
//! `manager-core`'s dep graph — mirrors [`crate::kv`].
//!
//! Unlike KV/docs, `http::*` has no app-scoped data, so there is no
//! cross-app isolation boundary to enforce here. `cx.app_id` is still
//! forwarded for audit-log attribution and (future, v1.2) per-app rate
//! limits. The load-bearing security mechanism is the SSRF deny-list
//! applied to the *resolved IP* — that lives in the manager-core impl,
//! not in this contract.
//!
//! Body encoding + per-method dispatch happen in the Rhai bridge before
//! the request reaches this trait: the service receives an already-
//! encoded body plus a `content_type`, so the impl stays a thin
//! transport layer.
use std::collections::BTreeMap;
use async_trait::async_trait;
use thiserror::Error;
use crate::SdkCallCx;
/// A fully-resolved outbound request. The bridge builds this from the
/// script-facing `(url, body, opts)` arguments; the service backend
/// turns it into a real network call.
#[derive(Debug, Clone)]
pub struct HttpRequest {
/// Uppercased HTTP method (`GET`, `POST`, …). The escape-hatch
/// `http::request(method, …)` lets scripts pass arbitrary methods,
/// so the impl validates this rather than the bridge.
pub method: String,
pub url: String,
/// Caller-supplied headers, merged into the request. Header names
/// are case-insensitive on the wire; stored verbatim here.
pub headers: BTreeMap<String, String>,
/// Already-encoded body. `None` means no body (GET/HEAD, or an
/// explicit `()` body).
pub body: Option<Vec<u8>>,
/// Content-Type the bridge chose for `body` (e.g.
/// `application/json`). Ignored when the caller set their own
/// `Content-Type` header. `None` when there is no body.
pub content_type: Option<String>,
/// Total request budget in ms (already clamped to the 60s ceiling
/// by the bridge).
pub timeout_ms: u32,
pub follow_redirects: bool,
/// Max redirects to follow (already clamped to 10 by the bridge).
pub max_redirects: u32,
/// Script id for the default `User-Agent` and audit attribution.
/// `None` when unavailable (the bridge always sets it from
/// `cx`-adjacent context, but the field stays optional so the
/// trait isn't coupled to how the id is sourced).
pub script_id: Option<String>,
}
/// The response shape the bridge turns into a Rhai map. JSON parsing of
/// `body_raw` happens in the bridge (it needs the Rhai value types), so
/// the service returns only the raw string + lowercased headers.
#[derive(Debug, Clone)]
pub struct HttpResponse {
pub status: u16,
/// Header names lowercased (per the documented response shape).
pub headers: BTreeMap<String, String>,
pub body_raw: String,
}
/// Failure modes surfaced to the Rhai bridge. The bridge prefixes each
/// `Display` string with `"http: "`. **None of these may leak the
/// resolved IP** — the SSRF reason is a CIDR-category label only.
#[derive(Debug, Error)]
pub enum HttpError {
/// Caller principal lacked `AppHttpRequest`. Only raised when
/// `cx.principal.is_some()`; public-HTTP scripts skip the check.
#[error("forbidden")]
Forbidden,
/// URL failed to parse, or carried no host.
#[error("invalid url: {0}")]
InvalidUrl(String),
/// Scheme other than http/https (file, ftp, gopher, …).
#[error("scheme not allowed: {0}")]
BlockedScheme(String),
/// Destination port is on the explicit block list (22, 25, 465, 587).
#[error("port not allowed: {0}")]
BlockedPort(u16),
/// Resolved IP hit the SSRF deny-list. `reason` is a CIDR-category
/// label (e.g. "loopback", "private", "link-local") — never the IP.
#[error("blocked by SSRF policy: {0}")]
Ssrf(String),
/// The request exceeded the wall-clock budget.
#[error("request timed out")]
Timeout,
/// Request or response body exceeded the configured size cap.
/// `which` is `"request"` or `"response"`.
#[error("{0} body exceeds size limit")]
BodyTooLarge(&'static str),
/// DNS / connect / TLS failure. The message is generic and MUST NOT
/// contain the resolved IP.
#[error("{0}")]
Network(String),
/// Anything else the impl wants to surface (still safe to show a
/// script).
#[error("{0}")]
Backend(String),
}
/// Stub used by the executor-core test harness so engine integration
/// tests (which don't make real network calls) can construct a
/// `Services` bundle. Every call errors so accidental use surfaces.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopHttpService;
#[async_trait]
impl HttpService for NoopHttpService {
async fn request(&self, _cx: &SdkCallCx, _req: HttpRequest) -> Result<HttpResponse, HttpError> {
Err(HttpError::Network("http is not wired in".into()))
}
}
/// Outbound-HTTP contract. A single generic `request` method funnels
/// every verb (`get`/`post`/…/`request`); the bridge maps the
/// script-facing surface onto it.
#[async_trait]
pub trait HttpService: Send + Sync {
async fn request(&self, cx: &SdkCallCx, req: HttpRequest) -> Result<HttpResponse, HttpError>;
}

View File

@@ -12,12 +12,15 @@ pub mod error;
pub mod events; pub mod events;
pub mod exec_summary; pub mod exec_summary;
pub mod execution_log; pub mod execution_log;
pub mod files;
pub mod http;
pub mod ids; pub mod ids;
pub mod inbox; pub mod inbox;
pub mod kv; pub mod kv;
pub mod log_sink; pub mod log_sink;
pub mod modules; pub mod modules;
pub mod outbox_writer; pub mod outbox_writer;
pub mod pubsub;
pub mod route; pub mod route;
pub mod sandbox; pub mod sandbox;
pub mod script; pub mod script;
@@ -35,6 +38,11 @@ pub use error::Error;
pub use events::{EmitError, NoopEventEmitter, ServiceEvent, ServiceEventEmitter}; pub use events::{EmitError, NoopEventEmitter, ServiceEvent, ServiceEventEmitter};
pub use exec_summary::ExecResponseSummary; pub use exec_summary::ExecResponseSummary;
pub use execution_log::{ExecutionLog, ExecutionStatus}; pub use execution_log::{ExecutionLog, ExecutionStatus};
pub use files::{
validate_collection as validate_files_collection, FileMeta, FileUpdate, FilesError,
FilesListPage, FilesService, NewFile, NoopFilesService,
};
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId, TriggerId}; pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId, TriggerId};
pub use inbox::{ pub use inbox::{
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver, InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
@@ -43,11 +51,16 @@ pub use kv::{KvError, KvListPage, KvService, NoopKvService};
pub use log_sink::{ExecutionLogSink, LogSinkError}; pub use log_sink::{ExecutionLogSink, LogSinkError};
pub use modules::{ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource}; pub use modules::{ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource};
pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError}; pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError};
pub use pubsub::{
topic_matches, validate_topic_pattern, NoopPubsubService, PubsubError, PubsubService,
};
pub use route::{DispatchMode, HostKind, PathKind, Route}; pub use route::{DispatchMode, HostKind, PathKind, Route};
pub use sandbox::ScriptSandbox; pub use sandbox::ScriptSandbox;
pub use script::{Script, ScriptKind}; pub use script::{Script, ScriptKind};
pub use sdk_cx::SdkCallCx; pub use sdk_cx::SdkCallCx;
pub use services::Services; pub use services::Services;
pub use trigger_event::{DeadLetterEventDetail, DocsEventOp, KvEventOp, TriggerEvent}; pub use trigger_event::{
DeadLetterEventDetail, DocsEventOp, FilesEventOp, KvEventOp, TriggerEvent,
};
pub use validator::{ScriptValidator, ValidatedScript, ValidationError}; pub use validator::{ScriptValidator, ValidatedScript, ValidationError};
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION}; pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};

161
crates/shared/src/pubsub.rs Normal file
View File

@@ -0,0 +1,161 @@
//! `PubsubService` — the v1.1.5 durable pub/sub contract.
//!
//! `pubsub::publish_durable(topic, message)` writes to the universal
//! outbox; the publish-time fan-out inserts one delivery row per
//! matching `pubsub` trigger, and each delivery retries / dead-letters
//! independently (the dispatcher already handles one-row-equals-one-
//! dispatch — no dispatcher changes for pub/sub).
//!
//! `publish_ephemeral` is committed as a v1.2 addition — the suffix
//! naming exists now so users learn "durable by default" from day one.
//!
//! Topic pattern matching runs in Rust (not SQL) so the trigger-select
//! query stays simple. The matcher + validator live here in
//! `picloud-shared` so the manager-core publish path, the admin trigger
//! endpoint, and tests all agree on the rules.
use async_trait::async_trait;
use thiserror::Error;
use crate::SdkCallCx;
#[async_trait]
pub trait PubsubService: Send + Sync {
/// Durable publish: writes the message to the outbox, fanned out to
/// every matching enabled `pubsub` trigger in `cx.app_id`. Succeeds
/// silently (zero rows written) when no trigger matches the topic.
async fn publish_durable(
&self,
cx: &SdkCallCx,
topic: &str,
message: serde_json::Value,
) -> Result<(), PubsubError>;
}
#[derive(Debug, Error)]
pub enum PubsubError {
/// Empty topic; rejected at the SDK boundary.
#[error("topic must not be empty")]
EmptyTopic,
/// Caller principal lacked the required capability. Only raised when
/// `cx.principal.is_some()` (script-as-gate; public HTTP skips it).
#[error("forbidden")]
Forbidden,
/// Serialization / validation failure on the message.
#[error("pubsub rejected: {0}")]
Rejected(String),
/// Anything else — Postgres unavailable, etc.
#[error("pubsub backend error: {0}")]
Unavailable(String),
}
/// Match a stored `topic_pattern` against a published `topic`.
///
/// - `"*"` matches every topic.
/// - `"<prefix>.*"` matches any topic starting with `"<prefix>."`.
/// - anything else is an exact match.
///
/// Mid-pattern wildcards (`*.created`, `a.*.b`) are NOT supported — they
/// are rejected at trigger creation by [`validate_topic_pattern`], so
/// the only patterns reaching this matcher are exact / prefix / `*`.
#[must_use]
pub fn topic_matches(pattern: &str, topic: &str) -> bool {
if pattern == "*" {
return true;
}
if let Some(prefix) = pattern.strip_suffix('*') {
// `prefix` retains the trailing '.', e.g. "user." for "user.*".
return topic.starts_with(prefix);
}
pattern == topic
}
/// Validate a subscription topic pattern. Accepts exactly: `"*"`
/// (universal), `"<prefix>.*"` (prefix wildcard, single trailing star),
/// or a literal with no `*` (exact). Everything else — mid-pattern
/// wildcards, multiple stars, a star not at the end — is rejected.
///
/// # Errors
///
/// Returns `Err(message)` with `"unsupported pubsub topic pattern: …"`
/// for any unsupported shape (or an empty pattern).
pub fn validate_topic_pattern(pattern: &str) -> Result<(), String> {
if pattern.is_empty() {
return Err("unsupported pubsub topic pattern: <empty>".to_string());
}
if pattern == "*" {
return Ok(());
}
let stars = pattern.matches('*').count();
if stars == 0 {
return Ok(()); // exact
}
if stars == 1 && pattern.ends_with(".*") {
return Ok(()); // prefix wildcard
}
Err(format!("unsupported pubsub topic pattern: {pattern}"))
}
/// Stub for the test harness so executor-core integration tests can
/// build a `Services` bundle without a database. Every call errors.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopPubsubService;
#[async_trait]
impl PubsubService for NoopPubsubService {
async fn publish_durable(
&self,
_cx: &SdkCallCx,
_topic: &str,
_message: serde_json::Value,
) -> Result<(), PubsubError> {
Err(PubsubError::Unavailable("pubsub is not wired in".into()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_match() {
assert!(topic_matches("user.created", "user.created"));
assert!(!topic_matches("user.created", "user.deleted"));
assert!(!topic_matches("user.created", "user.created.x"));
}
#[test]
fn prefix_wildcard() {
assert!(topic_matches("user.*", "user.created"));
assert!(topic_matches("user.*", "user.deleted"));
assert!(!topic_matches("user.*", "users.created"));
assert!(!topic_matches("user.*", "order.created"));
}
#[test]
fn universal() {
assert!(topic_matches("*", "anything"));
assert!(topic_matches("*", "a.b.c"));
}
#[test]
fn validation_accepts_supported_shapes() {
assert!(validate_topic_pattern("*").is_ok());
assert!(validate_topic_pattern("user.created").is_ok());
assert!(validate_topic_pattern("user.*").is_ok());
assert!(validate_topic_pattern("a.b.c").is_ok());
}
#[test]
fn validation_rejects_unsupported_shapes() {
for bad in ["*.created", "**", "a.*.b", "user.*x", "*user", ""] {
assert!(
validate_topic_pattern(bad).is_err(),
"expected {bad:?} to be rejected"
);
}
}
}

View File

@@ -12,7 +12,7 @@
//! the cx in is shared by both sides. Pure value type — no handles, no //! the cx in is shared by both sides. Pure value type — no handles, no
//! DB pool references, no allocations beyond what's in `Principal`. //! DB pool references, no allocations beyond what's in `Principal`.
use crate::{AppId, ExecutionId, Principal, RequestId, TriggerEvent}; use crate::{AppId, ExecutionId, Principal, RequestId, ScriptId, TriggerEvent};
/// Per-invocation context for every stateful SDK service call. /// Per-invocation context for every stateful SDK service call.
/// ///
@@ -27,6 +27,11 @@ pub struct SdkCallCx {
/// every `(app_id, …)` storage lookup the script makes. /// every `(app_id, …)` storage lookup the script makes.
pub app_id: AppId, pub app_id: AppId,
/// The script being executed. Used for audit-log attribution and
/// the default outbound-HTTP `User-Agent` (`picloud/<v>
/// (script:<id>)`). Added in v1.1.4 for the `http::*` SDK.
pub script_id: ScriptId,
/// Caller identity, when authenticated. `None` for unauthenticated /// Caller identity, when authenticated. `None` for unauthenticated
/// data-plane HTTP requests (the common case for public endpoints); /// data-plane HTTP requests (the common case for public endpoints);
/// `Some` when the call came in via the dashboard, an API key, or a /// `Some` when the call came in via the dashboard, an API key, or a

View File

@@ -20,8 +20,9 @@
use std::sync::Arc; use std::sync::Arc;
use crate::{ use crate::{
DeadLetterService, DocsService, KvService, ModuleSource, NoopDeadLetterService, DeadLetterService, DocsService, FilesService, HttpService, KvService, ModuleSource,
NoopDocsService, NoopEventEmitter, NoopKvService, NoopModuleSource, ServiceEventEmitter, NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopFilesService, NoopHttpService,
NoopKvService, NoopModuleSource, NoopPubsubService, PubsubService, ServiceEventEmitter,
}; };
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x /// SDK service bundle. See module docs for the lifecycle and the v1.1.x
@@ -53,6 +54,25 @@ pub struct Services {
/// `import`. Backed by Postgres in the picloud binary; in-memory /// `import`. Backed by Postgres in the picloud binary; in-memory
/// fakes in resolver tests. /// fakes in resolver tests.
pub modules: Arc<dyn ModuleSource>, pub modules: Arc<dyn ModuleSource>,
/// Outbound HTTP (v1.1.4). Scripts get `http::{get,post,…}`.
/// Backed by a reqwest client with the SSRF deny-list resolver in
/// the picloud binary; `NoopHttpService` in tests that don't make
/// network calls.
pub http: Arc<dyn HttpService>,
/// Filesystem-backed blob storage (v1.1.5). Scripts get
/// `files::collection(name).{create,head,get,update,delete,list}`.
/// Backed by a Postgres-metadata + on-disk-bytes repo in the
/// picloud binary; `NoopFilesService` in tests that don't touch
/// files.
pub files: Arc<dyn FilesService>,
/// Durable pub/sub (v1.1.5). Scripts get
/// `pubsub::publish_durable(topic, message)`. Backed by a
/// publish-time outbox fan-out in the picloud binary;
/// `NoopPubsubService` in tests that don't publish.
pub pubsub: Arc<dyn PubsubService>,
} }
impl Services { impl Services {
@@ -60,12 +80,16 @@ impl Services {
/// The picloud binary's `main` wires this up after the DB pool is /// The picloud binary's `main` wires this up after the DB pool is
/// open; tests build it from in-memory fakes. /// open; tests build it from in-memory fakes.
#[must_use] #[must_use]
#[allow(clippy::too_many_arguments)] // one Arc per stateful service; a builder would just move the noise
pub fn new( pub fn new(
kv: Arc<dyn KvService>, kv: Arc<dyn KvService>,
docs: Arc<dyn DocsService>, docs: Arc<dyn DocsService>,
dead_letters: Arc<dyn DeadLetterService>, dead_letters: Arc<dyn DeadLetterService>,
events: Arc<dyn ServiceEventEmitter>, events: Arc<dyn ServiceEventEmitter>,
modules: Arc<dyn ModuleSource>, modules: Arc<dyn ModuleSource>,
http: Arc<dyn HttpService>,
files: Arc<dyn FilesService>,
pubsub: Arc<dyn PubsubService>,
) -> Self { ) -> Self {
Self { Self {
kv, kv,
@@ -73,6 +97,9 @@ impl Services {
dead_letters, dead_letters,
events, events,
modules, modules,
http,
files,
pubsub,
} }
} }
@@ -89,6 +116,9 @@ impl Services {
Arc::new(NoopDeadLetterService), Arc::new(NoopDeadLetterService),
Arc::new(NoopEventEmitter), Arc::new(NoopEventEmitter),
Arc::new(NoopModuleSource), Arc::new(NoopModuleSource),
Arc::new(NoopHttpService),
Arc::new(NoopFilesService),
Arc::new(NoopPubsubService),
) )
} }
} }

View File

@@ -78,6 +78,39 @@ impl DocsEventOp {
} }
} }
/// Operations a files trigger can fire on. v1.1.5. Stored as a
/// lowercase string in `files_trigger_details.ops` (Postgres `text[]`).
/// CRUD verbs (`create`) mirror `DocsEventOp`, distinct from KV's
/// set/upsert flavour.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FilesEventOp {
Create,
Update,
Delete,
}
impl FilesEventOp {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Create => "create",
Self::Update => "update",
Self::Delete => "delete",
}
}
#[must_use]
pub fn from_wire(s: &str) -> Option<Self> {
match s {
"create" => Some(Self::Create),
"update" => Some(Self::Update),
"delete" => Some(Self::Delete),
_ => None,
}
}
}
/// Discriminated description of a triggering event. Lifted from the /// Discriminated description of a triggering event. Lifted from the
/// outbox row's payload at dispatch time. Each variant carries the /// outbox row's payload at dispatch time. Each variant carries the
/// fields the corresponding `ctx.event` shape exposes to the script. /// fields the corresponding `ctx.event` shape exposes to the script.
@@ -111,6 +144,48 @@ pub enum TriggerEvent {
prev_data: Option<serde_json::Value>, prev_data: Option<serde_json::Value>,
}, },
/// A cron schedule fired this handler. v1.1.4. Carries the
/// schedule + timezone the trigger was configured with, the
/// canonical cron moment (`scheduled_at`, the instant the
/// expression *meant*), and when the scheduler actually enqueued
/// the fire (`fired_at`). Surfaced to scripts as `ctx.event.cron`.
Cron {
schedule: String,
timezone: String,
scheduled_at: DateTime<Utc>,
fired_at: DateTime<Utc>,
},
/// A files create / update / delete fired this handler. v1.1.5.
/// Carries the affected file's **metadata only** — never the blob
/// bytes (files are too big to ship through trigger payloads). A
/// handler that wants the bytes calls
/// `files::collection(c).get(id)` itself. `prev` is the prior
/// metadata for update (and the deleted-row metadata for delete);
/// absent on create. Surfaced to scripts as `ctx.event.files`.
Files {
op: FilesEventOp,
collection: String,
/// UUID as string — Rhai sees it as a string.
id: String,
name: String,
content_type: String,
size: u64,
/// Lowercase hex SHA-256.
checksum: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
prev: Option<serde_json::Value>,
},
/// A durable pub/sub publish fired this handler. v1.1.5. Carries
/// the topic, the JSON-decoded message, and the publish instant.
/// Surfaced to scripts as `ctx.event.pubsub`.
Pubsub {
topic: String,
message: serde_json::Value,
published_at: DateTime<Utc>,
},
/// A dead-letter row fired this handler. The original event is /// A dead-letter row fired this handler. The original event is
/// nested verbatim plus the dead-letter metadata the design notes /// nested verbatim plus the dead-letter metadata the design notes
/// §4 require. /// §4 require.
@@ -135,6 +210,9 @@ impl TriggerEvent {
match self { match self {
Self::Kv { .. } => "kv", Self::Kv { .. } => "kv",
Self::Docs { .. } => "docs", Self::Docs { .. } => "docs",
Self::Cron { .. } => "cron",
Self::Files { .. } => "files",
Self::Pubsub { .. } => "pubsub",
Self::DeadLetter { .. } => "dead_letter", Self::DeadLetter { .. } => "dead_letter",
} }
} }

View File

@@ -33,7 +33,23 @@ pub const PRODUCT_VERSION: &str = env!("CARGO_PKG_VERSION");
/// app. Cross-app imports are unreachable (the `name` argument carries /// app. Cross-app imports are unreachable (the `name` argument carries
/// no `app_id`). Modules expose `fn`/`const` declarations only; /// no `app_id`). Modules expose `fn`/`const` declarations only;
/// top-level statements are rejected at create-time. /// top-level statements are rejected at create-time.
pub const SDK_VERSION: &str = "1.4"; ///
/// 1.5 additions (v1.1.4): `http::{get,post,put,patch,delete,head,
/// post_form,request}` for outbound HTTP from scripts (guarded by an
/// SSRF deny-list on the resolved IP); `ctx.event.cron` for cron-trigger
/// handlers (carries `schedule`, `timezone`, `scheduled_at`, `fired_at`).
/// The `Services` bundle gains `http: Arc<dyn HttpService>`.
///
/// 1.6 additions (v1.1.5):
/// `files::collection(name).{create,head,get,update,delete,list}` —
/// filesystem-backed blob storage (blobs in/out; metadata maps;
/// checksum-verified reads) with `ctx.event.files` for files-trigger
/// handlers (metadata only, never the bytes); and
/// `pubsub::publish_durable(topic, message)` — durable pub/sub with
/// publish-time fan-out and `ctx.event.pubsub` for pubsub-trigger
/// handlers. The `Services` bundle gains `files: Arc<dyn FilesService>`
/// and `pubsub: Arc<dyn PubsubService>`.
pub const SDK_VERSION: &str = "1.6";
/// HTTP API major version. Appears in URL paths as `/api/v{N}/...`. /// HTTP API major version. Appears in URL paths as `/api/v{N}/...`.
/// Bump (new integer + new URL prefix) when the request/response /// Bump (new integer + new URL prefix) when the request/response

View File

@@ -1,6 +1,6 @@
{ {
"name": "picloud-dashboard", "name": "picloud-dashboard",
"version": "0.9.0", "version": "0.11.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -211,6 +211,65 @@ export interface DeadLetterRow {
resolution: 'replayed' | 'ignored' | 'handled_by_script' | 'handler_failed' | null; resolution: 'replayed' | 'ignored' | 'handled_by_script' | 'handler_failed' | null;
} }
export type TriggerKind = 'kv' | 'docs' | 'dead_letter' | 'cron' | 'files' | 'pubsub';
export type TriggerDispatchMode = 'sync' | 'async';
/// Per-kind detail, tagged by `kind` to match the Rust serde shape.
export type TriggerDetails =
| { kind: 'kv'; collection_glob: string; ops: string[] }
| { kind: 'docs'; collection_glob: string; ops: string[] }
| { kind: 'dead_letter'; source_filter?: string; trigger_id_filter?: string; script_id_filter?: string }
| { kind: 'cron'; schedule: string; timezone: string; last_fired_at?: string | null }
| { kind: 'files'; collection_glob: string; ops: string[] }
| { kind: 'pubsub'; topic_pattern: string };
/// v1.1.5 file metadata as the admin files endpoint returns it.
export interface FileMeta {
id: string;
collection: string;
name: string;
content_type: string;
size: number;
checksum: string;
created_at: string;
updated_at: string;
}
export interface Trigger {
id: string;
app_id: string;
script_id: string;
kind: TriggerKind;
enabled: boolean;
dispatch_mode: TriggerDispatchMode;
retry_max_attempts: number;
retry_backoff: 'exponential' | 'linear' | 'constant';
retry_base_ms: number;
registered_by_principal: string;
created_at: string;
updated_at: string;
details: TriggerDetails;
}
export interface CreateCronTriggerInput {
script_id: string;
schedule: string;
timezone: string;
dispatch_mode?: TriggerDispatchMode;
retry_max_attempts?: number;
retry_backoff?: 'exponential' | 'linear' | 'constant';
retry_base_ms?: number;
}
export interface CreatePubsubTriggerInput {
script_id: string;
topic_pattern: string;
dispatch_mode?: TriggerDispatchMode;
retry_max_attempts?: number;
retry_backoff?: 'exponential' | 'linear' | 'constant';
retry_base_ms?: number;
}
export interface ExecutionResult { export interface ExecutionResult {
status: number; status: number;
headers: Record<string, string>; headers: Record<string, string>;
@@ -572,6 +631,45 @@ export const api = {
) )
}, },
triggers: {
list: (idOrSlug: string) =>
adminRequest<{ triggers: Trigger[] }>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers`
),
createCron: (idOrSlug: string, input: CreateCronTriggerInput) =>
adminRequest<Trigger>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/cron`,
{ method: 'POST', body: JSON.stringify(input) }
),
createPubsub: (idOrSlug: string, input: CreatePubsubTriggerInput) =>
adminRequest<Trigger>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/pubsub`,
{ method: 'POST', body: JSON.stringify(input) }
),
remove: (idOrSlug: string, triggerId: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/${triggerId}`,
{ method: 'DELETE' }
)
},
files: {
list: (idOrSlug: string, collection: string, opts: { cursor?: string; limit?: number } = {}) => {
const params = new URLSearchParams();
params.set('collection', collection);
if (opts.cursor) params.set('cursor', opts.cursor);
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
return adminRequest<{ files: FileMeta[]; next_cursor: string | null }>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/files?${params.toString()}`
);
},
remove: (idOrSlug: string, collection: string, fileId: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/files/${encodeURIComponent(collection)}/${fileId}`,
{ method: 'DELETE' }
)
},
execute: async ( execute: async (
id: string, id: string,
body: unknown, body: unknown,

View File

@@ -10,7 +10,8 @@
type AppDomain, type AppDomain,
type AppMemberDto, type AppMemberDto,
type AppRole, type AppRole,
type Script type Script,
type Trigger
} from '$lib/api'; } from '$lib/api';
import CodeEditor from '$lib/CodeEditor.svelte'; import CodeEditor from '$lib/CodeEditor.svelte';
import ConfirmModal from '$lib/ConfirmModal.svelte'; import ConfirmModal from '$lib/ConfirmModal.svelte';
@@ -24,7 +25,26 @@
const SAMPLE_SOURCE = const SAMPLE_SOURCE =
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}'; '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
type Tab = 'scripts' | 'domains' | 'members' | 'settings'; type Tab = 'scripts' | 'domains' | 'members' | 'settings' | 'triggers';
// Common IANA timezones offered in the cron form dropdown. Not
// exhaustive — the backend validates any IANA name via chrono-tz.
const COMMON_TIMEZONES = [
'UTC',
'America/Los_Angeles',
'America/Denver',
'America/Chicago',
'America/New_York',
'America/Sao_Paulo',
'Europe/London',
'Europe/Berlin',
'Europe/Paris',
'Europe/Moscow',
'Asia/Kolkata',
'Asia/Shanghai',
'Asia/Tokyo',
'Australia/Sydney'
];
let slug = $derived(page.params.slug ?? ''); let slug = $derived(page.params.slug ?? '');
let app = $state<App | null>(null); let app = $state<App | null>(null);
@@ -91,6 +111,89 @@
let removingDomain = $state(false); let removingDomain = $state(false);
let removeDomainError = $state<string | null>(null); let removeDomainError = $state<string | null>(null);
// Triggers tab (v1.1.4 — cron triggers). Admin-gated, like Members.
let triggers = $state<Trigger[]>([]);
let createCronScriptId = $state('');
let createCronSchedule = $state('0 0 9 * * MON-FRI');
let createCronTimezone = $state('UTC');
let creatingCron = $state(false);
let createCronError = $state<string | null>(null);
// Pub/Sub triggers (v1.1.5).
let createPubsubScriptId = $state('');
let createPubsubTopic = $state('');
let creatingPubsub = $state(false);
let createPubsubError = $state<string | null>(null);
let triggerToRemove = $state<Trigger | null>(null);
let removingTrigger = $state(false);
// Endpoint scripts only — modules can't be trigger targets.
const endpointScripts = $derived(scripts.filter((s) => s.kind === 'endpoint'));
async function loadTriggers(idOrSlug: string) {
try {
const r = await api.triggers.list(idOrSlug);
triggers = r.triggers;
} catch {
triggers = [];
}
}
async function submitCreateCron(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingCron = true;
createCronError = null;
try {
await api.triggers.createCron(app.id, {
script_id: createCronScriptId,
schedule: createCronSchedule.trim(),
timezone: createCronTimezone
});
createCronScriptId = '';
await loadTriggers(app.id);
} catch (err) {
createCronError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingCron = false;
}
}
async function submitCreatePubsub(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingPubsub = true;
createPubsubError = null;
try {
await api.triggers.createPubsub(app.id, {
script_id: createPubsubScriptId,
topic_pattern: createPubsubTopic.trim()
});
createPubsubScriptId = '';
createPubsubTopic = '';
await loadTriggers(app.id);
} catch (err) {
createPubsubError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingPubsub = false;
}
}
async function confirmRemoveTrigger() {
if (!app || !triggerToRemove) return;
removingTrigger = true;
try {
await api.triggers.remove(app.id, triggerToRemove.id);
triggerToRemove = null;
await loadTriggers(app.id);
} catch (err) {
createCronError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
removingTrigger = false;
}
}
// Members tab // Members tab
let eligibleUsers = $state<AdminDto[]>([]); let eligibleUsers = $state<AdminDto[]>([]);
let eligibleLoadError = $state<string | null>(null); let eligibleLoadError = $state<string | null>(null);
@@ -131,7 +234,7 @@
loadDeadLetterCount(app.id) loadDeadLetterCount(app.id)
]; ];
if (canAdmin) { if (canAdmin) {
loaders.push(loadMembers(app.id), loadEligibleUsers()); loaders.push(loadMembers(app.id), loadEligibleUsers(), loadTriggers(app.id));
} }
await Promise.all(loaders); await Promise.all(loaders);
} catch (e) { } catch (e) {
@@ -398,7 +501,10 @@
// backend still 403s the underlying calls, but no point showing an // backend still 403s the underlying calls, but no point showing an
// empty tab. // empty tab.
$effect(() => { $effect(() => {
if (!canAdmin && (activeTab === 'settings' || activeTab === 'members')) { if (
!canAdmin &&
(activeTab === 'settings' || activeTab === 'members' || activeTab === 'triggers')
) {
activeTab = 'scripts'; activeTab = 'scripts';
} }
}); });
@@ -440,11 +546,23 @@
class:active={activeTab === 'members'} class:active={activeTab === 'members'}
onclick={() => (activeTab = 'members')}>Members ({members.length})</button onclick={() => (activeTab = 'members')}>Members ({members.length})</button
> >
<button
type="button"
class:active={activeTab === 'triggers'}
onclick={() => (activeTab = 'triggers')}>Triggers ({triggers.length})</button
>
<button <button
type="button" type="button"
class:active={activeTab === 'settings'} class:active={activeTab === 'settings'}
onclick={() => (activeTab = 'settings')}>Settings</button onclick={() => (activeTab = 'settings')}>Settings</button
> >
<a
class="tab-link"
href="{base}/apps/{slug}/files"
title="Files — browse and delete stored blobs by collection"
>
Files
</a>
<a <a
class="tab-link" class="tab-link"
href="{base}/apps/{slug}/dead-letters" href="{base}/apps/{slug}/dead-letters"
@@ -698,6 +816,129 @@
</div> </div>
{/if} {/if}
</section> </section>
{:else if activeTab === 'triggers' && canAdmin}
<section>
<h2>Cron triggers</h2>
<p class="muted">
Run an endpoint script on a schedule. Schedules are 6-field cron
expressions (with seconds): <code>sec min hour day-of-month month day-of-week</code>.
The timezone disambiguates schedules like "every weekday at 9am".
</p>
<form class="create-form" onsubmit={submitCreateCron}>
<div class="row">
<label>
<span>Target script</span>
<select bind:value={createCronScriptId} required>
<option value="" disabled>Select an endpoint script…</option>
{#each endpointScripts as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</label>
<label>
<span>Schedule</span>
<input
bind:value={createCronSchedule}
required
placeholder="0 0 9 * * MON-FRI"
/>
</label>
<label>
<span>Timezone</span>
<select bind:value={createCronTimezone}>
{#each COMMON_TIMEZONES as tz (tz)}
<option value={tz}>{tz}</option>
{/each}
</select>
</label>
</div>
{#if endpointScripts.length === 0}
<p class="muted small">
This app has no endpoint scripts yet — create one first (modules
can't be trigger targets).
</p>
{/if}
{#if createCronError}
<div class="error">{createCronError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creatingCron || !createCronScriptId}>
{creatingCron ? 'Creating…' : 'Create cron trigger'}
</button>
</div>
</form>
<h2>Pub/Sub triggers</h2>
<p class="muted">
Subscribe an endpoint script to durable pub/sub messages. Topic
patterns are an exact topic (<code>user.created</code>), a prefix
wildcard (<code>user.*</code>), or <code>*</code> for every topic.
</p>
<form class="create-form" onsubmit={submitCreatePubsub}>
<div class="row">
<label>
<span>Target script</span>
<select bind:value={createPubsubScriptId} required>
<option value="" disabled>Select an endpoint script…</option>
{#each endpointScripts as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</label>
<label>
<span>Topic pattern</span>
<input bind:value={createPubsubTopic} required placeholder="user.*" />
</label>
</div>
{#if createPubsubError}
<div class="error">{createPubsubError}</div>
{/if}
<div class="actions">
<button
type="submit"
disabled={creatingPubsub || !createPubsubScriptId || !createPubsubTopic.trim()}
>
{creatingPubsub ? 'Creating…' : 'Create pub/sub trigger'}
</button>
</div>
</form>
{#if triggers.length === 0}
<p class="muted">No triggers in this app yet.</p>
{:else}
<ul class="list">
{#each triggers as t (t.id)}
<li class="domain-row">
<div>
<span class="kind-badge">{t.kind}</span>
{#if t.details.kind === 'cron'}
<code>{t.details.schedule}</code>
<span class="muted">{t.details.timezone}</span>
<span class="muted small">
last fired: {t.details.last_fired_at ?? 'never'}
</span>
{:else if t.details.kind === 'kv' || t.details.kind === 'docs' || t.details.kind === 'files'}
<code>{t.details.collection_glob}</code>
<span class="muted">{t.details.ops.join(', ') || 'any op'}</span>
{:else if t.details.kind === 'pubsub'}
<code>{t.details.topic_pattern}</code>
{/if}
<span class="muted small">{t.script_id}</span>
</div>
<button
type="button"
class="secondary danger"
onclick={() => (triggerToRemove = t)}
>
Delete
</button>
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'settings' && canAdmin} {:else if activeTab === 'settings' && canAdmin}
<section> <section>
<h2>Settings</h2> <h2>Settings</h2>
@@ -855,6 +1096,23 @@
{/if} {/if}
</ConfirmModal> </ConfirmModal>
{/if} {/if}
{#if triggerToRemove}
<ConfirmModal
title="Delete trigger"
variant="danger"
confirmLabel="Delete trigger"
busyLabel="Deleting…"
busy={removingTrigger}
onConfirm={confirmRemoveTrigger}
onCancel={() => (triggerToRemove = null)}
>
<p>
This {triggerToRemove.kind} trigger will stop firing. The target
script is not affected.
</p>
</ConfirmModal>
{/if}
{/if} {/if}
<style> <style>

View File

@@ -0,0 +1,229 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/state';
import { api, ApiError, type App, type FileMeta } from '$lib/api';
import ConfirmModal from '$lib/ConfirmModal.svelte';
let slug = $derived(page.params.slug ?? '');
let app = $state<App | null>(null);
let collection = $state('');
let activeCollection = $state('');
let files = $state<FileMeta[]>([]);
let nextCursor = $state<string | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
let fileToRemove = $state<FileMeta | null>(null);
let removing = $state(false);
async function loadApp() {
try {
app = await api.apps.get(slug);
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
}
$effect(() => {
void slug;
void loadApp();
});
async function loadFiles(cursor?: string) {
const c = collection.trim();
if (!c) {
error = 'Enter a collection name to list its files.';
return;
}
loading = true;
error = null;
try {
const res = await api.files.list(slug, c, { cursor, limit: 100 });
if (cursor) {
files = [...files, ...res.files];
} else {
files = res.files;
activeCollection = c;
}
nextCursor = res.next_cursor;
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
} finally {
loading = false;
}
}
async function confirmRemove() {
if (!fileToRemove) return;
removing = true;
try {
await api.files.remove(slug, fileToRemove.collection, fileToRemove.id);
files = files.filter((f) => f.id !== fileToRemove!.id);
fileToRemove = null;
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
} finally {
removing = false;
}
}
function fmtTime(iso: string): string {
return new Date(iso).toLocaleString();
}
function fmtSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
</script>
<svelte:head>
<title>Files · {slug} · PiCloud</title>
</svelte:head>
<div class="container">
<header>
<div>
<a href="{base}/apps/{slug}" class="back">&larr; back to {app?.name ?? slug}</a>
<h1>Files</h1>
<p class="subtitle">
Browse and delete stored blobs by collection. Uploads happen from scripts via
<code>files::collection(c).create(…)</code>.
</p>
</div>
</header>
<form
class="collection-form"
onsubmit={(e) => {
e.preventDefault();
void loadFiles();
}}
>
<label>
<span>Collection</span>
<input bind:value={collection} placeholder="avatars" required />
</label>
<button type="submit" disabled={loading || !collection.trim()}>
{loading ? 'Loading…' : 'List files'}
</button>
</form>
{#if error}
<div class="error">{error}</div>
{/if}
{#if activeCollection}
{#if files.length === 0 && !loading}
<p class="muted">No files in collection <code>{activeCollection}</code>.</p>
{:else}
<table>
<thead>
<tr>
<th>Name</th>
<th>Content type</th>
<th>Size</th>
<th>Created</th>
<th>ID</th>
<th></th>
</tr>
</thead>
<tbody>
{#each files as f (f.id)}
<tr>
<td>{f.name}</td>
<td><code>{f.content_type}</code></td>
<td>{fmtSize(f.size)}</td>
<td>{fmtTime(f.created_at)}</td>
<td class="mono small">{f.id}</td>
<td>
<button type="button" class="danger" onclick={() => (fileToRemove = f)}>
Delete
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{#if nextCursor}
<button type="button" class="secondary" onclick={() => loadFiles(nextCursor ?? undefined)}>
Load more
</button>
{/if}
{/if}
{/if}
</div>
{#if fileToRemove}
<ConfirmModal
title="Delete file"
variant="danger"
confirmLabel="Delete file"
onConfirm={confirmRemove}
onCancel={() => (fileToRemove = null)}
>
<p>
Delete <strong>{fileToRemove.name}</strong> ({fmtSize(fileToRemove.size)}) from collection
<code>{fileToRemove.collection}</code>? This removes both the metadata row and the bytes on
disk and cannot be undone.
</p>
{#if removing}<p class="muted">Deleting…</p>{/if}
</ConfirmModal>
{/if}
<style>
.container {
max-width: 60rem;
margin: 0 auto;
padding: 1.5rem;
}
header {
margin-bottom: 1rem;
}
.back {
font-size: 0.85rem;
}
.subtitle {
color: var(--muted, #666);
font-size: 0.9rem;
}
.collection-form {
display: flex;
align-items: flex-end;
gap: 0.75rem;
margin-bottom: 1rem;
}
.collection-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
text-align: left;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--border, #e2e2e2);
}
.mono {
font-family: monospace;
}
.small {
font-size: 0.78rem;
}
.muted {
color: var(--muted, #666);
}
.error {
color: #b00020;
margin: 0.5rem 0;
}
button.danger {
color: #b00020;
}
</style>