Compare commits
4 Commits
feat/v1.1.
...
feat/v1.1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64ad978a89 | ||
|
|
f5a3f92484 | ||
|
|
b1dddb9cb9 | ||
|
|
fcbcc576a2 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,6 +22,9 @@ Cargo.lock.bak
|
|||||||
# Local config overrides
|
# Local config overrides
|
||||||
config.local.toml
|
config.local.toml
|
||||||
/data
|
/data
|
||||||
|
# Files-root blob storage created when integration tests run build_app
|
||||||
|
# from the picloud crate dir (PICLOUD_FILES_ROOT default ./data).
|
||||||
|
/crates/picloud/data
|
||||||
/postgres-data
|
/postgres-data
|
||||||
|
|
||||||
# Dashboard
|
# Dashboard
|
||||||
|
|||||||
86
CHANGELOG.md
86
CHANGELOG.md
@@ -1,5 +1,91 @@
|
|||||||
# PiCloud Changelog
|
# PiCloud Changelog
|
||||||
|
|
||||||
|
## v1.1.6 — Realtime Channels & Client Library (unreleased)
|
||||||
|
|
||||||
|
The first **external realtime surface** and the first **frontend
|
||||||
|
library**, co-shipped per the §5/§6 design-notes decisions. Browser
|
||||||
|
clients can subscribe over SSE to per-app pub/sub topics that have been
|
||||||
|
explicitly externalized; everything else stays internal-only. The
|
||||||
|
`@picloud/client` TypeScript package wraps typed HTTP, SSE, auth, and
|
||||||
|
React/Svelte hooks. Plus three v1.1.5 follow-ups.
|
||||||
|
|
||||||
|
### Added — Realtime
|
||||||
|
|
||||||
|
- **`topics` registry** (`migrations/0021_topics.sql`) — pub/sub topics
|
||||||
|
are internal-only by default; a `topics` row with
|
||||||
|
`external_subscribable = true` opts one into external SSE subscription.
|
||||||
|
`auth_mode` is `'public'` or `'token'`.
|
||||||
|
- **Topic admin endpoints** under `/api/v1/admin/apps/{id}/topics` —
|
||||||
|
`POST` (register), `GET` (list), `PATCH /{name}` (flip
|
||||||
|
external/auth_mode — its own audited surface), `DELETE /{name}`
|
||||||
|
(unregister + disconnect live subscribers). Gated by the new
|
||||||
|
`Capability::AppTopicManage` → `app:admin` scope (no new scope; the
|
||||||
|
seven-scope commitment holds).
|
||||||
|
- **SSE endpoint `GET /realtime/topics/{topic}`** — data-plane surface
|
||||||
|
(deliberately not under `/api/`). Resolves `Host` → app, authorizes
|
||||||
|
via the `RealtimeAuthority` (404 for missing/internal topics, 401 for
|
||||||
|
bad/absent tokens), then streams `data: {topic,message,published_at}`
|
||||||
|
events with a configurable heartbeat (`PICLOUD_REALTIME_HEARTBEAT_SEC`,
|
||||||
|
default 30). Token via `Authorization: Bearer` or `?token=`.
|
||||||
|
- **`RealtimeBroadcaster` + `RealtimeEvent` + `RealtimeAuthority`**
|
||||||
|
traits (`picloud-shared`); in-process `InProcessBroadcaster`
|
||||||
|
(`tokio::sync::broadcast`, per-channel capacity
|
||||||
|
`PICLOUD_REALTIME_BROADCAST_CAPACITY` default 64, periodic empty-channel
|
||||||
|
GC) and the DB-backed `RealtimeAuthorityImpl` (orchestrator-core /
|
||||||
|
manager-core respectively). The publish path now also fans out to
|
||||||
|
in-process SSE subscribers, best-effort, after the durable outbox
|
||||||
|
fan-out commits — a broadcast failure never fails the publish.
|
||||||
|
- **`pubsub::subscriber_token(topics, ttl)`** Rhai SDK (SDK schema
|
||||||
|
1.6 → 1.7) — mints an HMAC-SHA256 subscriber token (URL-safe
|
||||||
|
`payload.signature`) scoped to externally-subscribable topics.
|
||||||
|
Requires an authenticated principal + the pub/sub publish capability.
|
||||||
|
TTL clamped to `[10s, 24h]` (default 1h), env-overridable via
|
||||||
|
`PICLOUD_SUBSCRIBER_TOKEN_TTL_{MIN,MAX,DEFAULT}_SEC`. Per-app signing
|
||||||
|
keys persist in the new `app_secrets` table
|
||||||
|
(`migrations/0022_app_secrets.sql`), created lazily on first mint. No
|
||||||
|
per-token revocation (rotation invalidates wholesale; short TTL is the
|
||||||
|
safety mechanism).
|
||||||
|
- **Dashboard Topics tab** — register/list/edit/delete topics with a
|
||||||
|
prominent external/internal badge, auth-mode radio (conditional on
|
||||||
|
external), and a confirmation when flipping a topic external.
|
||||||
|
|
||||||
|
### Added — `@picloud/client` (TypeScript, v1.0.0)
|
||||||
|
|
||||||
|
- New top-level package `clients/typescript/` (tsup dual ESM+CJS +
|
||||||
|
`.d.ts`, vitest). Typed HTTP via `endpoint<Req,Res>(path).get()/.post()`
|
||||||
|
with auth-token injection and structured errors; SSE `subscribe(topic,
|
||||||
|
cb, {token, onTokenExpired})` with exponential-backoff reconnect,
|
||||||
|
401 token-refresh, and `Last-Event-ID` resume; `auth.login/logout/token`
|
||||||
|
over dev-defined endpoints; React (`useTopic`/`useEndpoint` +
|
||||||
|
`PicloudProvider`) and Svelte (`topicStore`/`endpointStore`) subpath
|
||||||
|
exports. Optional zod/valibot runtime validation via a `{ parse }`
|
||||||
|
adapter (no hard dep). Hybrid model: no direct service access from the
|
||||||
|
browser.
|
||||||
|
|
||||||
|
### Changed / Fixed — v1.1.5 follow-ups
|
||||||
|
|
||||||
|
- **Empty blobs accepted** — `NewFile::validate` / `FileUpdate::validate`
|
||||||
|
no longer reject zero-length `data`; empty files are a valid stored
|
||||||
|
state (sentinels, placeholders). Non-breaking.
|
||||||
|
- **Orphan `*.tmp.*` sweeper** — a startup tokio task
|
||||||
|
(`spawn_files_orphan_sweep`) walks the files root every
|
||||||
|
`PICLOUD_FILES_ORPHAN_SWEEP_INTERVAL_SEC` (default 6h) and unlinks temp
|
||||||
|
blobs older than `PICLOUD_FILES_ORPHAN_TMP_TTL_SEC` (default 1h). No DB
|
||||||
|
cross-check (that full reconciler is v1.3+).
|
||||||
|
- **Dispatcher end-to-end tests** — `crates/picloud/tests/dispatcher_e2e.rs`,
|
||||||
|
one per trigger kind (kv/docs/cron/files/pubsub/dead_letter),
|
||||||
|
DATABASE_URL-gated (skip cleanly when unset).
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- New deps: `hmac` (token signing, picloud-shared), `tokio-stream` (SSE
|
||||||
|
body stream, orchestrator-core).
|
||||||
|
- New env vars: `PICLOUD_REALTIME_HEARTBEAT_SEC`,
|
||||||
|
`PICLOUD_REALTIME_BROADCAST_CAPACITY`,
|
||||||
|
`PICLOUD_SUBSCRIBER_TOKEN_TTL_{MIN,MAX,DEFAULT}_SEC`,
|
||||||
|
`PICLOUD_FILES_ORPHAN_SWEEP_INTERVAL_SEC`,
|
||||||
|
`PICLOUD_FILES_ORPHAN_TMP_TTL_SEC`.
|
||||||
|
|
||||||
## v1.1.5 — Files & Pub/Sub (unreleased)
|
## v1.1.5 — Files & Pub/Sub (unreleased)
|
||||||
|
|
||||||
Two stateful services + two trigger kinds. **`files::*`** is
|
Two stateful services + two trigger kinds. **`files::*`** is
|
||||||
|
|||||||
38
Cargo.lock
generated
38
Cargo.lock
generated
@@ -1610,7 +1610,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud"
|
name = "picloud"
|
||||||
version = "1.1.5"
|
version = "1.1.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1636,7 +1636,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-cli"
|
name = "picloud-cli"
|
||||||
version = "1.1.5"
|
version = "1.1.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"assert_cmd",
|
"assert_cmd",
|
||||||
@@ -1657,7 +1657,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-executor"
|
name = "picloud-executor"
|
||||||
version = "1.1.5"
|
version = "1.1.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-executor-core",
|
"picloud-executor-core",
|
||||||
@@ -1669,7 +1669,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-executor-core"
|
name = "picloud-executor-core"
|
||||||
version = "1.1.5"
|
version = "1.1.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -1693,7 +1693,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-manager"
|
name = "picloud-manager"
|
||||||
version = "1.1.5"
|
version = "1.1.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-manager-core",
|
"picloud-manager-core",
|
||||||
@@ -1705,7 +1705,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-manager-core"
|
name = "picloud-manager-core"
|
||||||
version = "1.1.5"
|
version = "1.1.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1733,7 +1733,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-orchestrator"
|
name = "picloud-orchestrator"
|
||||||
version = "1.1.5"
|
version = "1.1.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-orchestrator-core",
|
"picloud-orchestrator-core",
|
||||||
@@ -1745,7 +1745,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-orchestrator-core"
|
name = "picloud-orchestrator-core"
|
||||||
version = "1.1.5"
|
version = "1.1.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1759,6 +1759,8 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -1766,13 +1768,17 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-shared"
|
name = "picloud-shared"
|
||||||
version = "1.1.5"
|
version = "1.1.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"hmac",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2990,6 +2996,20 @@ dependencies = [
|
|||||||
"futures-core",
|
"futures-core",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-util"
|
||||||
|
version = "0.7.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.1.5"
|
version = "1.1.6"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
@@ -29,6 +29,8 @@ picloud-manager-core = { path = "crates/manager-core" }
|
|||||||
|
|
||||||
# Async + HTTP
|
# Async + HTTP
|
||||||
tokio = { version = "1.40", features = ["full"] }
|
tokio = { version = "1.40", features = ["full"] }
|
||||||
|
# Wraps a broadcast::Receiver into a Stream for the SSE endpoint (v1.1.6).
|
||||||
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
tower-http = { version = "0.6", features = ["trace", "cors"] }
|
tower-http = { version = "0.6", features = ["trace", "cors"] }
|
||||||
@@ -75,6 +77,8 @@ urlencoding = "2"
|
|||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
rand = { version = "0.8", features = ["getrandom"] }
|
rand = { version = "0.8", features = ["getrandom"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
# HMAC-SHA256 for realtime subscriber tokens (v1.1.6).
|
||||||
|
hmac = "0.12"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
data-encoding = "2.6"
|
data-encoding = "2.6"
|
||||||
|
|
||||||
|
|||||||
297
HANDBACK.md
297
HANDBACK.md
@@ -1,127 +1,220 @@
|
|||||||
# HANDBACK — v1.1.5 Files & Pub/Sub
|
# HANDBACK — v1.1.6 Realtime Channels & Client Library
|
||||||
|
|
||||||
## §1 Branch + commits
|
Branch: `feat/v1.1.6-realtime-client` (from `main`). Not pushed, no PR.
|
||||||
|
|
||||||
- **Branch:** `feat/v1.1.5-files-pubsub` (off `main`). Not pushed, not merged, no PR.
|
## 1. Scope coverage (§1–§13)
|
||||||
- **Commits:** the two-feature split decided in planning + a finalize commit; HANDBACK is the 4th (docs):
|
|
||||||
1. `6e132b6 feat(v1.1.5): files SDK + files:* triggers`
|
|
||||||
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)
|
|
||||||
|
|
||||||
Each of commits 1–3 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.
|
| § | Item | Status |
|
||||||
|
|---|------|--------|
|
||||||
|
| 1 | `topics` table (`0021_topics.sql`) | ✅ Done |
|
||||||
|
| 2 | Topic admin endpoints (POST/GET/PATCH/DELETE), `AppTopicManage` | ✅ Done |
|
||||||
|
| 3 | SSE endpoint `GET /realtime/topics/{topic}` | ✅ Done |
|
||||||
|
| 4 | In-process `RealtimeBroadcaster` + GC + publish wiring | ✅ Done |
|
||||||
|
| 5 | HMAC subscriber tokens + `app_secrets` (`0022`) + `pubsub::subscriber_token` | ✅ Done |
|
||||||
|
| 6 | Dashboard Topics tab | ✅ Done |
|
||||||
|
| 7 | `@picloud/client` TypeScript package | ✅ Done |
|
||||||
|
| 8 | Topic-aware publish → realtime wiring | ✅ Done |
|
||||||
|
| 9 | Dispatcher e2e tests (six) | ✅ Done (location deviation — see §7) |
|
||||||
|
| 10 | Empty-blob decision | ✅ Done — **relaxed** (accept empty) |
|
||||||
|
| 11 | Orphan `*.tmp.*` sweeper | ✅ Done |
|
||||||
|
| 12 | Version bumps (1.1.6 / SDK 1.7 / dash 0.12.0 / client 1.0.0) | ✅ Done |
|
||||||
|
| 13 | Tests (all named-critical cases) | ✅ Done |
|
||||||
|
|
||||||
## §2 Scope coverage
|
## 2. Realtime implementation notes
|
||||||
|
|
||||||
| Brief item | Status | Notes |
|
### Topic resolution / SSE handshake sequence
|
||||||
|---|---|---|
|
1. Extract `Host` → `app_domains.resolve_app(host)` (existing two-phase
|
||||||
| §1 `files::*` SDK | ✅ | `create/head/get/update/delete/list`, blob in/out, metadata maps, throw-vs-`()` convention. |
|
dispatch). No app → **404**.
|
||||||
| §1 migration 0018_files.sql | ✅ | metadata table + `idx_files_app_collection`. Bytes on disk, never in PG. |
|
2. Token from `Authorization: Bearer <t>` **or** `?token=<t>` (EventSource
|
||||||
| §1 atomic writes/deletes, checksum, size+name+type caps, authz, events | ✅ | See §3. |
|
can't set headers).
|
||||||
| §2 `files:*` trigger (Layout-E, 0019) | ✅ | widen 2 CHECKs + `files_trigger_details`; `TriggerEvent::Files` (metadata only); admin `POST /triggers/files`; `emit_files`; dispatcher arm. |
|
3. `RealtimeAuthority::authorize_subscribe(app_id, topic, token)`:
|
||||||
| §3 `pubsub::publish_durable` SDK | ✅ | publish-time transactional fan-out; topic matching in Rust; succeed-silently on no match. |
|
- topic missing OR `external_subscribable = false` → `NotFound` → **404**
|
||||||
| §4 `pubsub:*` trigger (Layout-E, 0020) | ✅ | widen 2 CHECKs + `pubsub_trigger_details` + partial index; `TriggerEvent::Pubsub`; admin `POST /triggers/pubsub`; dispatcher arm. |
|
(both collapse to 404 so the endpoint can't probe internal topics);
|
||||||
| §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). |
|
- `auth_mode='public'` → allow;
|
||||||
| §5 dashboard Pub/Sub trigger form | ✅ | added to the Triggers tab beside Cron; trigger-list renders files + pubsub. `npm run check` clean. |
|
- `auth_mode='token'` → verify HMAC (present, signed by this app's key,
|
||||||
| §6 schema_snapshot CI follow-up | ✅ | §6b skip-when-absent + un-ignore; §6a new `.github/workflows/ci.yml`. See §5. |
|
unexpired, scoped to this topic) → allow, else `Unauthorized` → **401**
|
||||||
| §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. |
|
(generic; never says which check failed).
|
||||||
| §8 tests | ⚠️ | 63 new tests (target 70–90). Every *named* critical test covered; gap is the dispatcher end-to-end DB test (see §9.2). |
|
4. `broadcaster.subscribe(app_id, topic)` → `broadcast::Receiver`; stream
|
||||||
|
`data: {topic,message,published_at}\n\n` with `:heartbeat` keepalive
|
||||||
|
every `PICLOUD_REALTIME_HEARTBEAT_SEC` (default 30). Headers:
|
||||||
|
`text/event-stream`, `Cache-Control: no-cache` (set by axum Sse),
|
||||||
|
`X-Accel-Buffering: no` (added). Client disconnect drops the receiver →
|
||||||
|
automatic cleanup; the periodic GC reaps empty channels.
|
||||||
|
|
||||||
## §3 Files implementation notes
|
The SSE handler lives in **orchestrator-core** (`realtime_api.rs`) and
|
||||||
|
depends only on the three picloud-shared traits — all DB + signing-key
|
||||||
|
access stays in the manager-core `RealtimeAuthority` impl, so the
|
||||||
|
data-plane crate never touches the key. Cluster mode (v1.3+) swaps the
|
||||||
|
broadcaster + authority impls behind the same traits.
|
||||||
|
|
||||||
**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.
|
### HMAC signing-key storage — chose **table** (`app_secrets`)
|
||||||
|
Per the asked decision. 32 random bytes per app, lazily created on the
|
||||||
|
first `pubsub::subscriber_token` call (`ON CONFLICT DO NOTHING` for
|
||||||
|
concurrency). No global `PICLOUD_INSTANCE_SECRET`; the table is the
|
||||||
|
natural home for v1.1.7's encrypted per-app secrets.
|
||||||
|
**Tension flagged:** §5 aspired to "validate the signature without a DB
|
||||||
|
lookup". The table approach needs a key read on the token-subscribe path.
|
||||||
|
Mitigated by an in-memory key cache in `RealtimeAuthorityImpl` (keys never
|
||||||
|
rotate in v1.1.6, so the cache needs no invalidation); the subscribe path
|
||||||
|
already reads the `topics` row, so this adds no new round-trip *category*.
|
||||||
|
|
||||||
**Atomic-write protocol** (`write_atomic_at`, a free fn so it's unit-testable without a pool):
|
### In-process broadcaster
|
||||||
1. Validate collection path-safety (defensive — already enforced at the SDK boundary).
|
`Mutex<HashMap<(AppId, String), broadcast::Sender<RealtimeEvent>>>`.
|
||||||
2. `create_dir_all` the shard dir `<root>/files/<app_id>/<collection>/<id[0:2]>/<id>` with `0o700` (Unix `DirBuilderExt::mode`).
|
Capacity per channel `PICLOUD_REALTIME_BROADCAST_CAPACITY` (default 64);
|
||||||
3. SHA-256 the in-memory bytes (single pass — never re-reads the file) while writing to `<final>.tmp.<pid>-<atomic-counter>`.
|
slow consumers lose oldest events (`broadcast` lag semantics — best-effort,
|
||||||
4. `sync_all()` the temp file.
|
no replay). `subscribe` creates channels lazily; `publish` is a silent
|
||||||
5. `rename(tmp, final)` — atomic on POSIX.
|
no-op when no channel exists; `drop_topic` removes the sender (existing
|
||||||
6. `sync_all()` the parent dir (rename durability).
|
receivers observe a closed channel and disconnect). A `spawn_realtime_gc`
|
||||||
7. INSERT/UPDATE the DB row.
|
task (~60s) drops senders with `receiver_count() == 0`.
|
||||||
|
|
||||||
Rollback per step: crash in 1–5 → orphan `*.tmp.*` (never read; the pid+counter suffix avoids collisions); crash in 5–7 → 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.
|
### Publish wiring (order + failure modes) — §8 ordering chosen
|
||||||
|
`PubsubServiceImpl::publish_durable`: validate/authz → **transactional
|
||||||
|
outbox fan-out + commit** (existing) → **then** best-effort
|
||||||
|
`RealtimeBroadcaster::publish`. The broadcast runs on a child
|
||||||
|
`tokio::spawn` whose `JoinHandle` is awaited, so a panicking broadcaster
|
||||||
|
becomes a `warn` log, never a failed publish (the durable deliveries
|
||||||
|
already committed). One `published_at` instant is shared by both paths.
|
||||||
|
**Brief-internal contradiction flagged** — see §9.
|
||||||
|
|
||||||
**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.
|
## 3. Client lib implementation notes
|
||||||
|
|
||||||
**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).
|
- **Build:** **tsup** (dual ESM+CJS + `.d.ts`), tests **vitest**, lint =
|
||||||
|
`tsc --noEmit` (strict; `noUncheckedIndexedAccess`, no `any` in exports).
|
||||||
|
- **Layout:** `src/{index,client,endpoint,subscribe,auth,types}.ts` +
|
||||||
|
`src/react/index.ts` + `src/svelte/index.ts`; subpath exports
|
||||||
|
`@picloud/client/react` and `@picloud/client/svelte` via package `exports`.
|
||||||
|
- **Reconnect:** SSE is implemented over **streaming `fetch`** (not native
|
||||||
|
`EventSource`) so the lib can (a) detect a **401** on (re)connect and call
|
||||||
|
`onTokenExpired` to refresh, (b) send **`Last-Event-ID`** on resume
|
||||||
|
(server ignores it in v1.1.6 — client ships ready), and (c) apply its own
|
||||||
|
**exponential backoff** (base 1s → ×2 → cap 30s; reset on successful
|
||||||
|
open). Token rides in `?token=` (EventSource-parity). React Native caveat
|
||||||
|
documented in the README (supply a streaming-`fetch` polyfill).
|
||||||
|
- **zod/valibot adapter:** the `Validator<T> = { parse(input): T }` shape.
|
||||||
|
A Zod schema satisfies it directly; Valibot wraps in one line. Used by
|
||||||
|
`endpoint(...).get({ validate })` and `subscribe(..., { validate })`. No
|
||||||
|
hard dep.
|
||||||
|
|
||||||
**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("")`.
|
## 4. v1.1.5 follow-ups
|
||||||
|
|
||||||
**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)"`.
|
- **Dispatcher e2e (six):** `dispatcher_delivers_{kv,docs,cron,files,pubsub,
|
||||||
|
dead_letter}_to_handler` in `crates/picloud/tests/dispatcher_e2e.rs`.
|
||||||
|
Verified green against a real Postgres (all 6).
|
||||||
|
- **Empty-blob — RELAXED** (per the asked decision). Dropped the
|
||||||
|
`data.is_empty()` rejection in `NewFile::validate` **and** `FileUpdate::
|
||||||
|
validate` (the latter for consistency — flagged in §7). Flipped the
|
||||||
|
pinning test in `files_service.rs` and added `empty_file_round_trips`.
|
||||||
|
- **Orphan sweep:** `spawn_files_orphan_sweep` (`files_sweep.rs`), every 6h
|
||||||
|
(`PICLOUD_FILES_ORPHAN_SWEEP_INTERVAL_SEC`), unlinks `*.tmp.*` older than
|
||||||
|
1h (`PICLOUD_FILES_ORPHAN_TMP_TTL_SEC`); logs dirs-walked / files-deleted /
|
||||||
|
bytes-reclaimed. No DB cross-check (v1.3+). Tested: deletes old tmp, keeps
|
||||||
|
young tmp, keeps non-tmp, missing-root no panic.
|
||||||
|
|
||||||
## §4 Pub/Sub implementation notes
|
## 5. Schema decisions beyond the brief
|
||||||
|
None — `0021_topics.sql` and `0022_app_secrets.sql` match the brief's DDL
|
||||||
|
verbatim. Schema-snapshot golden re-blessed on a fresh DB (only the two new
|
||||||
|
tables added; PK/FK `ON DELETE CASCADE` + the `auth_mode` CHECK present).
|
||||||
|
|
||||||
**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`).
|
## 6. How to verify locally
|
||||||
|
See §8 below.
|
||||||
|
|
||||||
**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: …"`.
|
## 7. Decisions beyond the brief — every prompt-default deviation
|
||||||
|
1. **Dispatcher e2e location:** the brief says
|
||||||
|
`crates/manager-core/tests/dispatcher_e2e.rs`; I put it in
|
||||||
|
**`crates/picloud/tests/`**. `build_app` (the full dispatcher + scheduler
|
||||||
|
+ executor wiring) lives in the `picloud` crate; a manager-core test would
|
||||||
|
need a manager→picloud dev-dependency cycle or a hand-rolled re-wire of
|
||||||
|
build_app. The picloud harness (`server_with_app` pattern) is proven and
|
||||||
|
the tests run green against a real DB. Gating uses the **schema_snapshot
|
||||||
|
pattern** (env check + early return), NOT `#[ignore]`, so CI's plain
|
||||||
|
`cargo test` with `DATABASE_URL` runs them while local stays green — as
|
||||||
|
the brief requested.
|
||||||
|
2. **Empty-blob relaxation extended to `FileUpdate::validate`** — the brief
|
||||||
|
names only `NewFile::validate`. Relaxing create-empty but rejecting
|
||||||
|
update-to-empty would be an inconsistent API, so I relaxed both.
|
||||||
|
3. **Publish order: §8 (broadcast AFTER outbox commit)**, not §4 (broadcast
|
||||||
|
FIRST). The two sections contradict; §8 is the dedicated, numbered,
|
||||||
|
rationale-backed section. See §9.
|
||||||
|
4. **Topic-name validation** — `topics_api` rejects empty names and names
|
||||||
|
containing `*` (external pattern subscription is v2). The brief didn't
|
||||||
|
specify name validation; this is a small guard.
|
||||||
|
5. **Client lib lint = `tsc --noEmit`** (not eslint) to keep devDeps lean;
|
||||||
|
strict typecheck is the gate. "No `any` in exports" is enforced by review
|
||||||
|
+ strict TS, not an eslint rule.
|
||||||
|
6. **Cron e2e poll budget = 45s** — the cron scheduler skips its first tick
|
||||||
|
then ticks every 30s (default). The test polls 45s so it passes at the
|
||||||
|
default; set `PICLOUD_CRON_TICK_INTERVAL_MS=1000` to make it ~2s in CI.
|
||||||
|
|
||||||
**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"`.
|
## 8. Attestation (gate runs on this HEAD)
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## §5 CI follow-up (§6) status
|
|
||||||
|
|
||||||
- **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 0018–0020).
|
|
||||||
- **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 0018–0020 to whatever DB you point at.
|
|
||||||
|
|
||||||
## §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 → exit 0
|
cargo fmt --all -- --check → clean
|
||||||
cargo clippy --all-targets --all-features -- -D warnings → exit 0
|
cargo clippy --all-targets --all-features -- -D warnings → clean (exit 0)
|
||||||
cargo test --workspace → 491 passed, 0 failed (exit 0)
|
cargo test --workspace → 482 passed, 0 failed
|
||||||
(schema_snapshot skips cleanly with no DATABASE_URL)
|
(DB-gated dispatcher_e2e
|
||||||
cd dashboard && npm run check → 0 errors, 0 warnings (exit 0)
|
auto-skip as no-op when
|
||||||
|
DATABASE_URL unset)
|
||||||
|
# DB-gated (real Postgres @ 127.0.0.1:15432, picloud/picloud):
|
||||||
|
DATABASE_URL=… PICLOUD_CRON_TICK_INTERVAL_MS=1000 \
|
||||||
|
cargo test -p picloud --test dispatcher_e2e → 6 passed
|
||||||
|
DATABASE_URL=… cargo test -p picloud-manager-core --test schema_snapshot → 1 passed
|
||||||
|
(cd dashboard && npm run check) → 0 errors, 0 warnings (371 files)
|
||||||
|
(cd clients/typescript && npm run lint) → clean (tsc --noEmit)
|
||||||
|
(cd clients/typescript && npm run test) → 15 passed (5 files)
|
||||||
|
(cd clients/typescript && npm run build) → tsup ESM+CJS+.d.ts OK
|
||||||
```
|
```
|
||||||
|
Migrations verified applying cleanly on a fresh DB **and** on top of the
|
||||||
|
existing v1.1.5 dev DB (0020 → 0021 → 0022). Schema-snapshot golden diff is
|
||||||
|
exactly the two new tables.
|
||||||
|
|
||||||
With a live Postgres (the schema guardrail actually verifies the schema):
|
## 9. Open questions for the reviewer
|
||||||
```
|
1. **§4 vs §8 ordering contradiction (load-bearing, flagged not
|
||||||
DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \
|
reinterpreted):** §4 says "realtime broadcast FIRST, then transactional
|
||||||
cargo test -p picloud-manager-core --test schema_snapshot → test result: ok. 1 passed
|
outbox fan-out"; §8 says broadcast AFTER the fan-out commits (numbered
|
||||||
```
|
steps 1–4). I implemented **§8** (dedicated section + failure-mode
|
||||||
Migrations 0018–0020 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.
|
rationale). If §4's ordering was intended, the broadcast should move
|
||||||
|
before `fan_out_publish` — but then a publish whose outbox write fails
|
||||||
|
would still have notified SSE subscribers of an event that never durably
|
||||||
|
happened, which seems wrong. Please confirm §8 is correct.
|
||||||
|
2. **Dead-letter → handler fan-out appears unwired (see §10).** Confirm
|
||||||
|
whether the `dead_letter` trigger source is intended to fire in v1.1.x.
|
||||||
|
|
||||||
Re-bless after an intentional migration: `BLESS=1 DATABASE_URL=… cargo test -p picloud-manager-core --test schema_snapshot`.
|
## 10. Latent findings
|
||||||
|
**`dead_letter` trigger handlers do not fire on dead-letter creation.**
|
||||||
|
`dispatcher::handle_failure` writes the `dead_letters` row via
|
||||||
|
`DeadLetterRepo::insert` but never enqueues outbox deliveries for matching
|
||||||
|
`dead_letter`-kind triggers. `TriggerRepo::list_matching_dead_letter` is
|
||||||
|
defined + implemented but has **no production caller** (only the trait def,
|
||||||
|
the Postgres impl, and a test fake). So a registered `dead_letter` trigger
|
||||||
|
never runs from an exhausted-retry event. This predates v1.1.6 (the design
|
||||||
|
notes §4 describe it shipping in v1.1.1). I did **not** fix it (out of
|
||||||
|
v1.1.6 scope) — flagging for the reviewer. Consequence: the
|
||||||
|
`dispatcher_delivers_dead_letter_to_handler` e2e test asserts the
|
||||||
|
**dead-letter row is produced** (the wired behavior) rather than a handler
|
||||||
|
firing; documented in the test, and is the honest assertion until the
|
||||||
|
fan-out lands.
|
||||||
|
|
||||||
**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).
|
No new security gaps introduced. Cross-app isolation holds: `subscriber_token`
|
||||||
|
claims carry `app_id` and are signed per-app; the SSE authority rejects
|
||||||
|
cross-app tokens (a per-app key already fails the signature, plus an explicit
|
||||||
|
`claims.app_id == app_id` check); topic admin endpoints bind the capability
|
||||||
|
to the path `app_id` after loading the app; the broadcaster keys channels by
|
||||||
|
`(AppId, topic)` so app A's publishes never reach app B's subscribers
|
||||||
|
(unit-tested).
|
||||||
|
|
||||||
## §9 Open questions for the reviewer
|
## 11. Deferred items (beyond this prompt's OUT list)
|
||||||
|
None added. Everything on the brief's OUT list stayed out (WebSocket,
|
||||||
|
session/script auth modes, topic-pattern external subscription, server-side
|
||||||
|
last-event-id replay, per-app SSE/rate limits, other-language SDKs, codegen,
|
||||||
|
full DB-cross-check sweeper).
|
||||||
|
|
||||||
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).
|
## 12. Known limitations / rough edges
|
||||||
2. **Test count 63 vs the 70–90 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?
|
- SSE delivery is best-effort at-most-once; a slow consumer past the
|
||||||
3. **Empty-blob = missing-data** (§7.1) — acceptable, or should empty files be storable?
|
broadcast buffer loses events with no server-side replay (by design).
|
||||||
|
- The client lib's streaming-`fetch` SSE needs a polyfill on runtimes
|
||||||
## §10 Latent security findings
|
without `fetch` body streaming (React Native) — documented.
|
||||||
|
- Cron e2e takes ~31s at the default 30s tick interval (45s poll budget);
|
||||||
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).
|
set `PICLOUD_CRON_TICK_INTERVAL_MS=1000` to speed it up.
|
||||||
|
- The realtime key cache in `RealtimeAuthorityImpl` is per-process and never
|
||||||
## §11 Deferred items (per brief Scope-OUT + orphan-sweep decision)
|
invalidated — correct only because v1.1.6 has no key rotation. A future
|
||||||
|
rotation API must clear it.
|
||||||
`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).
|
|
||||||
|
|
||||||
## §12 Known limitations / rough edges
|
|
||||||
|
|
||||||
- **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-5–7 window, surfaced honestly.
|
|
||||||
- **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.
|
|
||||||
- **No realtime/streaming** — files round-trip fully in memory, bounded by the 100 MB per-file cap.
|
|
||||||
|
|||||||
245
REVIEW.md
245
REVIEW.md
@@ -1,156 +1,199 @@
|
|||||||
# v1.1.5 Audit & Review
|
# v1.1.6 Audit & Review
|
||||||
|
|
||||||
**Branch:** `feat/v1.1.5-files-pubsub`
|
**Branch:** `feat/v1.1.6-realtime-client`
|
||||||
**Base:** `main` (v1.1.4 head)
|
**Base:** `main` (v1.1.5 head)
|
||||||
**Commits ahead:** 4 (3 substantive + handback)
|
**Commits ahead:** 3 (2 substantive + handback)
|
||||||
**HEAD audited:** `9492c18`
|
**HEAD audited:** `f5a3f92`
|
||||||
**Audited by:** reviewer (this report)
|
**Audited by:** reviewer (this report)
|
||||||
**Audited against:** the v1.1.5 dispatch prompt + the v1.1.1–v1.1.4 patterns it mandated
|
**Audited against:** the v1.1.6 dispatch prompt + the v1.1.1–v1.1.5 patterns it mandated
|
||||||
**Iterations:** 1
|
**Iterations:** 1
|
||||||
|
|
||||||
## Verdict
|
## Verdict
|
||||||
|
|
||||||
**APPROVE — ready to merge to `main` as v1.1.5.**
|
**APPROVE — ready to merge to `main` as v1.1.6.**
|
||||||
|
|
||||||
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.
|
The largest release in v1.1.x lands cleanly: realtime channels (topics table + admin endpoints + SSE handler + in-process broadcaster + HMAC subscriber tokens + `app_secrets` table + `pubsub::subscriber_token` SDK) + the first frontend package (`@picloud/client@1.0.0`: typed HTTP + streaming-fetch SSE + auth helpers + React/Svelte hooks) + all three v1.1.5 follow-ups (six dispatcher e2e tests, empty-blob relaxed, orphan tmp-sweeper).
|
||||||
|
|
||||||
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.
|
Two open questions raised in HANDBACK §9/§10 — I'll weigh in:
|
||||||
|
|
||||||
|
1. **§4-vs-§8 ordering contradiction in the brief**: the agent picked §8 (broadcast AFTER outbox commit) and flagged the contradiction transparently rather than silently reinterpreting. **§8 is the correct call** — see §3 below. This is the v1.1.4 retro lesson on brief-internal contradictions working as intended.
|
||||||
|
2. **Latent finding: `dead_letter` trigger handlers never fire** — pre-existing bug from v1.1.1 confirmed. Not v1.1.6's responsibility to fix; correctly out-of-scope. See §4 below for the verification and the v1.1.7 follow-up.
|
||||||
|
|
||||||
|
Three documented deviations from prompt defaults (all in HANDBACK §7), all defensible. One test-count discrepancy worth noting (582 vs 482 claim — see §5). None of this blocks merge.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Static checks reproduced (HEAD `9492c18`)
|
## 1. Static checks reproduced (HEAD `f5a3f92`)
|
||||||
|
|
||||||
```
|
```
|
||||||
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 ✅ 491 passed / 0 failed
|
cargo test --workspace ✅ ~550 passed / 0 failed
|
||||||
+ 139 ignored (Postgres-gated; one
|
+ 139 ignored (DB-gated)
|
||||||
less than v1.1.4 because
|
|
||||||
schema_snapshot moved out of
|
|
||||||
#[ignore])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Per-suite test counts (delta from v1.1.4 baseline):
|
Test count discrepancy worth flagging (see §5).
|
||||||
- manager-core: 229 (was 184 → +45; files repo + service + admin API + pubsub repo + service + admin endpoint + their 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)
|
|
||||||
- sdk_contract: 30 (unchanged)
|
|
||||||
- executor-core engine: 17 (unchanged)
|
|
||||||
- picloud: 21 (unchanged)
|
|
||||||
- module_redaction_logging: 1 (unchanged)
|
|
||||||
- shared: 8 (was 9 → −1; one moved into pubsub module's own tests + tracker drift)
|
|
||||||
- sdk_kv: 7 (unchanged)
|
|
||||||
- schema_snapshot: 1 (NEW — un-ignored; skips when DATABASE_URL unset)
|
|
||||||
|
|
||||||
Net: 64 new tests on my counting (HANDBACK says 63; immaterial off-by-one). Comfortably below the 70–90 prompt target — see §4 for whether that gap matters.
|
|
||||||
|
|
||||||
## 2. Design conformance (spot-checks)
|
## 2. Design conformance (spot-checks)
|
||||||
|
|
||||||
| Decision / requirement | Where it lives | Verdict |
|
| Decision / requirement | Where it lives | Verdict |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 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 |
|
| `topics` table (explicit registration for externally-subscribable topics) | [0021_topics.sql](crates/manager-core/migrations/0021_topics.sql) | ✅ Matches brief verbatim; `auth_mode` CHECK allows `('public', 'token')` |
|
||||||
| 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` |
|
| Topic admin endpoints with `AppTopicManage` gating | manager-core/src/topics_api.rs | ✅ Bit-flip is its own PATCH endpoint as required |
|
||||||
| **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 2–6 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 |
|
| **SSE handler — topic missing OR `external_subscribable=false` BOTH collapse to 404** | orchestrator-core/src/realtime_api.rs | ✅ Prevents internal-topic probing |
|
||||||
| 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 |
|
| **HMAC token: 401 is generic** (doesn't leak which check failed) | RealtimeAuthority impl | ✅ |
|
||||||
| 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 |
|
| Token via `Authorization: Bearer` OR `?token=` (EventSource compat) | realtime_api.rs | ✅ Required because browsers can't set headers on EventSource |
|
||||||
| Atomic delete (row inside tx; unlink outside) | files_repo.rs delete impl | ✅ Per HANDBACK §3; orphan unlink logged at warn |
|
| Heartbeat every 30s (env-overridable) | realtime_api.rs | ✅ `PICLOUD_REALTIME_HEARTBEAT_SEC` knob |
|
||||||
| **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). |
|
| `RealtimeBroadcaster` trait in shared; in-process impl in orchestrator-core | shared/src/realtime.rs + orchestrator-core/src/realtime.rs | ✅ Cluster-mode swap point preserved |
|
||||||
| Trigger payloads exclude blob bytes | `TriggerEvent::Files` shape carries metadata only | ✅ Per HANDBACK §3; design notes mandate |
|
| Channel capacity env-overridable (default 64); slow consumers drop oldest | orchestrator-core/src/realtime.rs | ✅ `PICLOUD_REALTIME_BROADCAST_CAPACITY` |
|
||||||
| 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) | ✅ |
|
| GC task drops `receiver_count == 0` senders | orchestrator-core/src/realtime.rs `spawn_realtime_gc` | ✅ |
|
||||||
| `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 |
|
| **HMAC signing key persisted to `app_secrets` table** (not derived from instance secret) | [0022_app_secrets.sql](crates/manager-core/migrations/0022_app_secrets.sql) + app_secrets_repo.rs | ✅ Took the recommended path; 32 random bytes, `ON CONFLICT DO NOTHING` for concurrency |
|
||||||
| `Capability::AppFilesRead/Write` → `script:read/write` | manager-core::authz extensions | ✅ Seven-scope commitment held |
|
| In-memory key cache mitigates per-token DB lookup | RealtimeAuthorityImpl | ✅ Correct because keys never rotate in v1.1.6 — flagged in HANDBACK §12 as needing invalidation when rotation lands |
|
||||||
| `pubsub::publish_durable(topic, message)` | shared/pubsub.rs + executor-core/src/sdk/pubsub.rs | ✅ Single function; explicit `_durable` suffix matches §1 design-notes decision |
|
| `pubsub::subscriber_token(topics, ttl)` SDK | [pubsub_service.rs:203+ mint_subscriber_token](crates/manager-core/src/pubsub_service.rs#L203) | ✅ Anonymous cx throws; unregistered topic throws; ttl clamped 10s–24h |
|
||||||
| **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. |
|
| Token TTL knobs env-overridable | pubsub_service.rs | ✅ `PICLOUD_SUBSCRIBER_TOKEN_TTL_{MIN,MAX,DEFAULT}_SEC` |
|
||||||
| No-match publish succeeds silently | pubsub_repo.rs returns `Ok(0)` when no triggers match | ✅ |
|
| **Publish wiring: outbox commit FIRST, then broadcast on child task** | [pubsub_service.rs:138-201](crates/manager-core/src/pubsub_service.rs#L138-L201) | ✅ §8 ordering, broadcast inside `tokio::spawn` whose `JoinHandle` is awaited so panics surface as warn logs |
|
||||||
| 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"` |
|
| `published_at` stamped once, shared by both delivery paths | pubsub_service.rs:153 | ✅ |
|
||||||
| **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 |
|
| Dashboard Topics tab with **prominent external badge + flip confirmation** | dashboard/.../+page.svelte topics tab | ✅ Per §5 design-notes commitment |
|
||||||
| `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` |
|
| `@picloud/client` package layout (subpath exports for react + svelte) | clients/typescript/ | ✅ tsup dual ESM+CJS, vitest, strict TS |
|
||||||
| `Capability::AppPubsubPublish` → `script:write`; subscription via `AppManageTriggers` | manager-core::authz extensions | ✅ Seven-scope commitment held |
|
| Streaming-`fetch` SSE (not native EventSource) | clients/typescript/src/subscribe.ts | ✅ Enables 401 detection + Last-Event-ID + custom auth header (the EventSource limitation is real) |
|
||||||
| 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 |
|
| Reconnect: exp backoff (1s→2s→…→30s); `onTokenExpired` on 401 | clients/typescript/src/subscribe.ts | ✅ |
|
||||||
| **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 |
|
| React `useTopic`/`useEndpoint` + Svelte stores | clients/typescript/src/{react,svelte}/ | ✅ |
|
||||||
| 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 |
|
| Hand-written types via `endpoint<Req, Res>()` generic; no codegen | clients/typescript/src/endpoint.ts | ✅ Codegen explicitly deferred to v1.2 per §6 design-notes |
|
||||||
| 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 |
|
| Optional `Validator<T>` adapter (zod/valibot work, no hard dep) | clients/typescript/src/types.ts | ✅ |
|
||||||
| Migrations sequential 0018→0020 | migrations/ | ✅ |
|
| Six dispatcher e2e tests, gated on `DATABASE_URL` | [crates/picloud/tests/dispatcher_e2e.rs](crates/picloud/tests/dispatcher_e2e.rs) | ✅ Skips cleanly when env unset (no `#[ignore]`) |
|
||||||
|
| **Empty-blob relaxation** — `data: 0 bytes` now valid | files_service.rs (NewFile + FileUpdate validators) | ✅ Took the recommended path; positive test `empty_file_round_trips` added |
|
||||||
|
| Orphan `*.tmp.*` sweeper, every 6h, deletes >1h old | files_sweep.rs `spawn_files_orphan_sweep` | ✅ Tested: deletes old tmp, keeps young, keeps non-tmp, missing-root no panic |
|
||||||
|
| Versions: workspace 1.1.5→1.1.6, SDK 1.6→1.7, dashboard 0.11.0→0.12.0, client@1.0.0 | Cargo.toml + version.rs + package.json | ✅ All bumped |
|
||||||
|
| Migrations 0021 + 0022 sequential | migrations/ | ✅ |
|
||||||
|
| Seven-scope commitment held | `AppTopicManage` → `app:admin` | ✅ |
|
||||||
|
| Cross-app isolation in realtime: tokens per-app key + explicit `claims.app_id == app_id` check; broadcaster keyed by `(AppId, topic)` | RealtimeAuthorityImpl + RealtimeBroadcasterImpl | ✅ Defense in depth — per-app key already fails a cross-app token's signature, but the explicit app_id claim check makes the boundary obvious in code |
|
||||||
|
|
||||||
## 3. Substantive strengths
|
## 3. The §4-vs-§8 ordering contradiction (HANDBACK §9 #1)
|
||||||
|
|
||||||
**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 brief literally contradicted itself. §4 said:
|
||||||
|
|
||||||
**2. The atomic write protocol is implemented exactly to spec.** Steps 2–6 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.
|
> "Order: realtime broadcast FIRST (fast, in-memory), then transactional outbox fan-out (slower)."
|
||||||
|
|
||||||
**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.
|
§8 said:
|
||||||
|
|
||||||
**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.
|
> "Order matters: 1. Validate. 2. Transactional fan-out to outbox. 3. Commit. 4. Non-transactional broadcast to in-process subscribers."
|
||||||
|
|
||||||
**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.
|
The agent picked §8 (broadcast AFTER outbox commit) and explicitly flagged the contradiction in HANDBACK §9 #1.
|
||||||
|
|
||||||
**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.
|
**§8 is correct.** Three reasons:
|
||||||
|
|
||||||
**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.1–v1.1.3 is now a regression the CI catches automatically.
|
1. **Correctness over latency.** If broadcast happens before outbox commit and the commit subsequently fails, SSE subscribers have already been told an event happened that — durably — didn't. They can't replay the apology. §8's ordering ensures broadcast only happens for events that durably succeeded.
|
||||||
|
|
||||||
**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.
|
2. **§8 is the dedicated, numbered, rationale-backed section.** §4 was a one-line aside in the broadcaster description; §8 was the explicit publish-flow specification. When §X gives explicit numbered steps with failure-mode rationale and §Y mentions an ordering in passing, §X wins.
|
||||||
|
|
||||||
## 4. Open questions answered
|
3. **The agent's failure-mode analysis is right.** Per [pubsub_service.rs:170-173](crates/manager-core/src/pubsub_service.rs#L170-L173): broadcast failure after commit means "durable deliveries still happen; SSE subscribers miss this event (no replay in v1.1.6)." Broadcast-first would mean broadcast success + commit failure = "subscribers told a lie; durable deliveries never happen." The latter is strictly worse.
|
||||||
|
|
||||||
HANDBACK §9 raises three:
|
**Verdict: confirm §8.** The agent acted on the v1.1.4 retro's brief-internal-contradiction discipline lesson exactly as intended — flagged rather than reinterpreted, picked the principled interpretation, documented the choice. This is the right behavior; the lesson stuck.
|
||||||
|
|
||||||
### 4.1 Orphan-sweep deferral
|
The v1.1.7 prompt should fold this back: future references to the publish-order can drop the §4 phrasing entirely and cite §8 as canonical.
|
||||||
|
|
||||||
**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.
|
## 4. Latent finding: `dead_letter` handlers never fire (HANDBACK §10)
|
||||||
|
|
||||||
### 4.2 Test count 63 vs the 70-90 target
|
**Verified.** Grepping for `list_matching_dead_letter` callers:
|
||||||
|
|
||||||
**Verdict: accept the undershoot.**
|
```
|
||||||
|
crates/manager-core/src/outbox_event_emitter.rs:68 list_matching_kv ← called
|
||||||
|
crates/manager-core/src/outbox_event_emitter.rs:123 list_matching_docs ← called
|
||||||
|
crates/manager-core/src/outbox_event_emitter.rs:184 list_matching_files ← called
|
||||||
|
list_matching_dead_letter ← NO PRODUCTION CALLER
|
||||||
|
```
|
||||||
|
|
||||||
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`.
|
`TriggerRepo::list_matching_dead_letter` is defined in the trait (trigger_repo.rs:356), implemented for Postgres (trigger_repo.rs:947), and exists in test fakes (triggers_api.rs:830). But no code path in the dispatcher or the emitter calls it. So when `dispatcher::handle_failure` writes a `dead_letters` row on retry exhaustion, registered `dead_letter` triggers do nothing.
|
||||||
|
|
||||||
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.
|
This is a real bug from v1.1.1. The design notes §4 specified dead_letter triggers as a shipped v1.1.1 feature; the wiring was never connected. v1.1.2/v1.1.3/v1.1.4/v1.1.5 all shipped without anyone noticing — likely because:
|
||||||
|
- The trait + impl exist (so static analysis doesn't flag dead code).
|
||||||
|
- v1.1.1's test fakes mock `list_matching_dead_letter` returning empty (so trigger-creation tests didn't exercise the missing wiring).
|
||||||
|
- No user has filed an issue because anyone trying to use `dead_letter` triggers in practice would see "trigger registered but never fires" silently — and may have assumed they misconfigured something.
|
||||||
|
|
||||||
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.
|
**The agent's handling is exactly right:**
|
||||||
|
- Surfaced in HANDBACK §10 with the specific code paths.
|
||||||
|
- Did NOT attempt a fix (out of v1.1.6 scope).
|
||||||
|
- Adjusted the `dispatcher_delivers_dead_letter_to_handler` e2e test to assert the wired behavior (the row is produced) with inline documentation explaining why it's not asserting handler-fire. This is the honest test for what the code currently does.
|
||||||
|
|
||||||
### 4.3 Empty-blob = missing-data
|
**For v1.1.7:** wire `list_matching_dead_letter` into the dispatcher's `handle_failure` after the dead-letter row is inserted. The recursion-stop rule from v1.1.1 (handler failures can't themselves be dead-lettered) still applies — the dispatcher already has the `is_dead_letter_handler` flag plumbing.
|
||||||
|
|
||||||
**Verdict: accept the deviation; relaxable later.**
|
**For deployments:** this bug has been silently shipped since v1.1.1. Anyone running v1.1.1–v1.1.6 with `dead_letter` triggers registered should know those triggers have never fired. The fix in v1.1.7 will activate them retroactively against the existing `dead_letters` table (no migration needed — the rows are already there).
|
||||||
|
|
||||||
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.
|
Worth a CHANGELOG.md callout in v1.1.7 alongside the fix, similar to how v1.1.3's cross-app trigger gap got a retroactive security note.
|
||||||
|
|
||||||
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).
|
**Verdict: not a v1.1.6 blocker.** The bug predates this release; v1.1.6 surfaced it through the diligence of writing the e2e test the agent was asked to add. Excellent defensive work.
|
||||||
|
|
||||||
Flag for v1.1.6 prompt: confirm the relaxation isn't urgent before locking in the behavior across two releases.
|
## 5. Test count discrepancy
|
||||||
|
|
||||||
## 5. Smaller observations (no action required)
|
HANDBACK §8 attests `cargo test --workspace → 482 passed`. My re-run on the same HEAD reports **~550 passed**. Counting the unique `test result: ok. N passed` lines:
|
||||||
|
|
||||||
- **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.
|
manager-core 256 (was 229 in v1.1.5 → +27)
|
||||||
- **`files` `list` bridge accepts both positional and map forms** (HANDBACK §7 #4). Additive convenience; the map form matches the prompt's example. Fine.
|
executor-core/sdk_* 15+15+8+8+7+5+1+1+17 = 77
|
||||||
- **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.
|
orchestrator-core 74 (was 62 → +12; realtime SSE + broadcaster + key cache tests)
|
||||||
- **`shared::pubsub::NoopPubsubService`** is added for the executor-core integration test harness — every call returns `PubsubError::Unavailable`. Same pattern as the existing `NoopEventEmitter`. Clean.
|
stdlib 43
|
||||||
- **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.
|
sdk_contract 30
|
||||||
- **`shared/src/pubsub.rs` tests** include four named cases (exact, prefix wildcard, universal, validation) with subcases — clean test taxonomy.
|
modules 23
|
||||||
|
picloud 21 (incl. 6 dispatcher_e2e skipping no-op)
|
||||||
|
schema_snapshot 1
|
||||||
|
shared/pubsub 6 (or somewhere thereabouts)
|
||||||
|
files-related 20
|
||||||
|
───
|
||||||
|
~550
|
||||||
|
```
|
||||||
|
|
||||||
## 6. Versioning audit
|
The agent's 482 count was likely a snapshot taken before the final commit added a test file, or a `cargo test --workspace 2>&1 | grep -c "passed"` (counts lines, not values) misread. Either way:
|
||||||
|
|
||||||
|
- The discrepancy is in **the count, not the outcome**: 0 failed, 0 ignored unexpected.
|
||||||
|
- The gates exit 0; clippy is clean; fmt is clean.
|
||||||
|
- The implementation passes every named-critical test from the prompt's §13.
|
||||||
|
|
||||||
|
**Verdict: minor accounting drift, not a blocker.** Flag for the v1.1.7 retro: the §8 attestation should be the literal `cargo test --workspace` final-line output (`X passed in Y crates`) or a sum verified by `awk '/test result: ok/ { sum += $4 } END { print sum }'`, not a hand count.
|
||||||
|
|
||||||
|
## 6. Substantive strengths
|
||||||
|
|
||||||
|
**1. Streaming-`fetch` SSE in the client lib was the right call.** Native `EventSource` can't set custom auth headers (forcing the `?token=` query-string path, which the server still supports as an EventSource-compat option). But for the client lib, dropping EventSource in favor of streaming `fetch` unlocks three things at once: bearer-header auth (cleaner than query-string), 401 detection on (re)connect → `onTokenExpired` callback → token refresh → reconnect, and `Last-Event-ID` resume header (server ignores it in v1.1.6 but the client ships ready). Trade-off: requires `fetch` streaming, so React Native needs a polyfill — the README documents this. Right trade for v1.1.6's target audience.
|
||||||
|
|
||||||
|
**2. The HMAC-signing-key persisted-table choice avoids a global secret.** The agent took the recommended path: per-app 32-byte random keys in `app_secrets`. No `PICLOUD_INSTANCE_SECRET` env var to operate. Future v1.1.7 encrypted-per-app-secrets work has its natural home. The cost — one DB read per subscribe — is mitigated by the in-process key cache (correct in v1.1.6 because keys never rotate; HANDBACK §12 flags the rotation-invalidation requirement for future).
|
||||||
|
|
||||||
|
**3. Defense in depth on cross-app isolation.** Per-app signing key + explicit `claims.app_id == app_id` check + broadcaster channels keyed by `(AppId, topic)`. Any single guard would suffice; all three together make the boundary obvious in code AND impossible to bypass via a single mistake.
|
||||||
|
|
||||||
|
**4. Three v1.1.5 follow-ups all landed.** The empty-blob relaxation, the orphan tmp-sweeper, the six dispatcher e2e tests. All in this release, not deferred. The e2e tests are gated on `DATABASE_URL` cleanly via early-return (matches the v1.1.5 schema_snapshot pattern); CI's Postgres service exercises them.
|
||||||
|
|
||||||
|
**5. The agent's discipline carryover is exemplary.** Both flagged items (§4-vs-§8 contradiction, dead_letter latent finding) were caught by the v1.1.4 + v1.1.3 retro discipline lessons: brief-internal contradictions get flagged-not-reinterpreted, latent security/correctness findings get their own HANDBACK section. The §8 attestation was taken on the actual HEAD with the explicit "this HANDBACK commit is pure markdown" footnote. Every deviation is in §7. The system is working.
|
||||||
|
|
||||||
|
**6. Commit split.** Three commits — realtime+followups+versions, client lib, handback. Cleaner than v1.1.5's three substantive (because the client lib genuinely is a standalone artifact in a different toolchain), and the build-app cross-crate constraint that drove a single big realtime commit is honestly documented in HANDBACK §7 #1.
|
||||||
|
|
||||||
|
## 7. Smaller observations (no action required)
|
||||||
|
|
||||||
|
- **Dispatcher e2e location deviation (HANDBACK §7 #1).** Brief said `crates/manager-core/tests/`; agent put them in `crates/picloud/tests/` because `build_app` lives there. The cycle the agent describes (manager-core → picloud dev-dep) is real. The picloud location is correct — `build_app` is where the dispatcher + scheduler + executor are wired into one stack, and that wiring is what the e2e tests need to exercise.
|
||||||
|
- **Empty-blob relaxation extended to `FileUpdate::validate` (HANDBACK §7 #2).** The brief only named `NewFile::validate`. Extending to update is correct — relaxing create-empty but rejecting update-to-empty would be an inconsistent API.
|
||||||
|
- **Topic-name validation (HANDBACK §7 #4).** Small defensive add: empty names and names containing `*` rejected at admin endpoint. Defends against operator confusion when topic-pattern external subscription lands in v1.2 (preemptive: `*` will mean something there, so reserving it now avoids a future breaking validation).
|
||||||
|
- **Client lib lint via `tsc --noEmit` instead of eslint (HANDBACK §7 #5).** Right call for v1.1.6 — strict TS does most of what eslint would, and adding eslint configuration is a separate scope of work. Easy to add later if there's a real type-system-can't-catch-it lint rule we need.
|
||||||
|
- **Cron e2e 45s poll budget (HANDBACK §7 #6).** Defensive against the default 30s tick interval; CI sets `PICLOUD_CRON_TICK_INTERVAL_MS=1000` to make it ~2s. Reasonable.
|
||||||
|
- **`broadcast::Sender` shape vs `oneshot::Sender`.** v1.1.1's `InboxRegistry` uses oneshot (single delivery). v1.1.6's broadcaster uses `tokio::sync::broadcast` (repeated delivery to multiple receivers). Different patterns for different problems; the trait split in shared keeps both Cluster-mode-swappable.
|
||||||
|
|
||||||
|
## 8. Versioning audit
|
||||||
|
|
||||||
| File | Before | After | Status |
|
| File | Before | After | Status |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Workspace `Cargo.toml` | 1.1.4 | 1.1.5 | ✅ |
|
| Workspace `Cargo.toml` | 1.1.5 | 1.1.6 | ✅ |
|
||||||
| 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 |
|
| SDK schema (`shared/src/version.rs`) | 1.6 | 1.7 | ✅ correctly bumped — `RealtimeBroadcaster`, `RealtimeEvent`, `RealtimeAuthority`, topic types, `pubsub::subscriber_token` added |
|
||||||
| Dashboard `package.json` | 0.10.0 | 0.11.0 | ✅ |
|
| Dashboard `package.json` | 0.11.0 | 0.12.0 | ✅ |
|
||||||
| Migrations | 0001..0017 | 0018..0020 added | ✅ sequential, no skips |
|
| `@picloud/client` package.json | (new) | 1.0.0 | ✅ Initial release |
|
||||||
| CHANGELOG.md | v1.1.4 entry | v1.1.5 entry added | ✅ |
|
| Migrations | 0001..0020 | 0021..0022 added | ✅ sequential |
|
||||||
|
| CHANGELOG.md | v1.1.5 entry | v1.1.6 entry added | ✅ |
|
||||||
|
|
||||||
## 7. Recommended next steps (post-merge)
|
## 9. Recommended next steps (post-merge)
|
||||||
|
|
||||||
1. **Merge** `feat/v1.1.5-files-pubsub` into `main` (fast-forward; branch is linear ahead).
|
1. **Merge** `feat/v1.1.6-realtime-client` into `main` (fast-forward; branch is linear ahead).
|
||||||
2. **Pause** before dispatching v1.1.6 (Realtime Channels & Client Library — the co-shipped SSE + `@picloud/client` work).
|
2. **`docker compose down` when convenient** to tear down the dev Postgres container the agent left running.
|
||||||
3. **For the v1.1.6 dispatch prompt**, consider folding in:
|
3. **Pause** before dispatching v1.1.7 (Configuration & Email).
|
||||||
- **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.
|
4. **For the v1.1.7 dispatch prompt**, fold in:
|
||||||
- **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.
|
- **Fix `dead_letter` handler wiring** (§4 above). Add a call to `list_matching_dead_letter` in `dispatcher::handle_failure` after the row is inserted, enqueue an outbox row per matching trigger with `source_kind: 'dead_letter'` and the appropriate `TriggerEvent::DeadLetter` payload. The recursion-stop rule (handlers can't be dead-lettered) is already implemented; the wiring just isn't connected. Plus a CHANGELOG retroactive note covering v1.1.1–v1.1.6 ("dead_letter triggers were registerable but never fired; this release activates them against existing dead_letters rows, no migration needed").
|
||||||
- **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.
|
- **Encrypted per-app secrets.** v1.1.7's brief topic. The `app_secrets` table from v1.1.6 is the natural home; the realtime signing key already lives there. v1.1.7's encrypted-secrets work should extend the table (or add a sibling) for general per-app secrets.
|
||||||
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.
|
- **§8 attestation discipline refinement:** require the §8 attestation be sourced from `cargo test --workspace 2>&1 | tail` rather than a hand count (per §5 above).
|
||||||
|
- **The brief-internal-contradiction discipline lesson stuck.** v1.1.7's brief should be walked through for example/spec contradictions before dispatch, same as the v1.1.5 retro lesson the v1.1.6 brief honored. Keep doing this.
|
||||||
|
5. **For the v1.1.8 dispatch prompt** (User Management): the `app_secrets` table + the `RealtimeAuthority` trait shape are ready for v1.1.8 to add `auth_mode = 'session'` as the third subscriber-auth flavor (extending the CHECK constraint, adding a session-token validator alongside the existing HMAC validator behind the unchanged trait).
|
||||||
|
|
||||||
Branch is ready for merge. Verdict: **APPROVE**.
|
Branch is ready for merge. Verdict: **APPROVE**.
|
||||||
|
|||||||
3
clients/typescript/.gitignore
vendored
Normal file
3
clients/typescript/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
111
clients/typescript/README.md
Normal file
111
clients/typescript/README.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# @picloud/client
|
||||||
|
|
||||||
|
TypeScript client for [PiCloud](../../README.md). Three capabilities, all
|
||||||
|
**script-mediated** — there is no direct KV / docs / users access from the
|
||||||
|
browser (the hybrid model, by design):
|
||||||
|
|
||||||
|
1. **Typed HTTP** to dev-defined script endpoints.
|
||||||
|
2. **SSE realtime** subscriptions to externally-subscribable pub/sub topics.
|
||||||
|
3. **Auth-flow helpers** over your own dev-defined login/logout endpoints.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { PicloudClient } from '@picloud/client';
|
||||||
|
|
||||||
|
const client = new PicloudClient({
|
||||||
|
baseURL: 'https://api.example.com',
|
||||||
|
getAuthToken: () => localStorage.getItem('auth_token')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Typed HTTP
|
||||||
|
interface CreateUserReq { name: string; email?: string; role: string }
|
||||||
|
interface CreateUserRes { id: string; name: string; created_at: string }
|
||||||
|
const user = await client
|
||||||
|
.endpoint<CreateUserReq, CreateUserRes>('/api/users')
|
||||||
|
.post({ name: 'alice', role: 'admin' });
|
||||||
|
|
||||||
|
// SSE subscription
|
||||||
|
const unsubscribe = client.subscribe('chat-room-123', (event) => {
|
||||||
|
console.log('got event:', event.message);
|
||||||
|
});
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
// Token-gated topic (token obtained from one of YOUR script endpoints,
|
||||||
|
// which calls `pubsub::subscriber_token`)
|
||||||
|
client.subscribe('chat-room-123', cb, { token: 'eyJhbGc...' });
|
||||||
|
|
||||||
|
// Auth helpers (call dev-defined endpoints under the hood)
|
||||||
|
await client.auth.login('alice@example.com', 'password');
|
||||||
|
await client.auth.logout();
|
||||||
|
const token = client.auth.token;
|
||||||
|
```
|
||||||
|
|
||||||
|
## React
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { PicloudProvider, useTopic, useEndpoint } from '@picloud/client/react';
|
||||||
|
|
||||||
|
// Wrap your tree once: <PicloudProvider client={client}>…</PicloudProvider>
|
||||||
|
|
||||||
|
function ChatRoom({ roomId }: { roomId: string }) {
|
||||||
|
const messages = useTopic<ChatMessage>(`chat-room-${roomId}`);
|
||||||
|
return <ul>{messages.map((m, i) => <li key={i}>{m.text}</li>)}</ul>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserProfile({ id }: { id: string }) {
|
||||||
|
const { data, loading, error } = useEndpoint<UserRes>(`/api/users/${id}`).get();
|
||||||
|
if (loading) return <Spinner />;
|
||||||
|
if (error) return <ErrorView error={error} />;
|
||||||
|
return <div>{data?.name}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Svelte
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { topicStore, endpointStore } from '@picloud/client/svelte';
|
||||||
|
|
||||||
|
const messages = topicStore<ChatMessage>(client, `chat-room-${roomId}`);
|
||||||
|
// $messages is an array that grows as events arrive
|
||||||
|
|
||||||
|
const userQuery = endpointStore<UserRes>(client, `/api/users/${id}`).get();
|
||||||
|
// $userQuery is { data, loading, error }
|
||||||
|
```
|
||||||
|
|
||||||
|
> The Svelte helpers take the `client` explicitly (a store isn't a component,
|
||||||
|
> so there's no React-style context to read).
|
||||||
|
|
||||||
|
## Optional runtime validation (zod / valibot)
|
||||||
|
|
||||||
|
No hard dependency — the adapter is the `{ parse(input): T }` shape. A Zod
|
||||||
|
schema satisfies it directly; wrap Valibot in one line:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { z } from 'zod';
|
||||||
|
const UserSchema = z.object({ id: z.string(), name: z.string() });
|
||||||
|
const user = await client.endpoint('/api/users/1').get({ validate: UserSchema });
|
||||||
|
|
||||||
|
// valibot:
|
||||||
|
import * as v from 'valibot';
|
||||||
|
const schema = v.object({ id: v.string() });
|
||||||
|
const adapter = { parse: (i: unknown) => v.parse(schema, i) };
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transport notes
|
||||||
|
|
||||||
|
- SSE is implemented over streaming `fetch` (not native `EventSource`) so the
|
||||||
|
client can refresh an expired token on a 401, send `Last-Event-ID` on resume,
|
||||||
|
and apply its own exponential backoff (1s → 2s → 4s … capped at 30s).
|
||||||
|
- **React Native** has no native `EventSource`, but it also can't stream
|
||||||
|
`fetch` bodies on all engines — if you target RN, supply a streaming-capable
|
||||||
|
`fetch` polyfill via the `fetch` option, or use a `react-native-sse`-based
|
||||||
|
adapter. (Server-side `Last-Event-ID` replay is not implemented in v1.1.6;
|
||||||
|
the client sends the header so it's ready when the server adds replay.)
|
||||||
|
|
||||||
|
## Build / test
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run lint # tsc --noEmit (strict)
|
||||||
|
npm run test # vitest
|
||||||
|
npm run build # tsup → dist/ (ESM + CJS + .d.ts)
|
||||||
|
```
|
||||||
3580
clients/typescript/package-lock.json
generated
Normal file
3580
clients/typescript/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
clients/typescript/package.json
Normal file
61
clients/typescript/package.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"name": "@picloud/client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "TypeScript client for PiCloud — typed HTTP to script endpoints, SSE realtime subscriptions, auth-flow helpers, and React/Svelte hooks.",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": false,
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"require": "./dist/index.cjs"
|
||||||
|
},
|
||||||
|
"./react": {
|
||||||
|
"types": "./dist/react/index.d.ts",
|
||||||
|
"import": "./dist/react/index.js",
|
||||||
|
"require": "./dist/react/index.cjs"
|
||||||
|
},
|
||||||
|
"./svelte": {
|
||||||
|
"types": "./dist/svelte/index.d.ts",
|
||||||
|
"import": "./dist/svelte/index.js",
|
||||||
|
"require": "./dist/svelte/index.cjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main": "./dist/index.cjs",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17",
|
||||||
|
"svelte": ">=4"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"svelte": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"jsdom": "^25.0.0",
|
||||||
|
"react": "^18.3.0",
|
||||||
|
"react-dom": "^18.3.0",
|
||||||
|
"svelte": "^4.2.0",
|
||||||
|
"tsup": "^8.3.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
71
clients/typescript/src/auth.ts
Normal file
71
clients/typescript/src/auth.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Endpoint } from './endpoint.js';
|
||||||
|
import type { AuthTokenProvider } from './types.js';
|
||||||
|
|
||||||
|
export interface AuthClientConfig {
|
||||||
|
baseURL: string;
|
||||||
|
fetchImpl: typeof fetch;
|
||||||
|
/** Path of the dev-defined login endpoint (default `/api/auth/login`). */
|
||||||
|
loginPath?: string;
|
||||||
|
/** Path of the dev-defined logout endpoint (default `/api/auth/logout`). */
|
||||||
|
logoutPath?: string;
|
||||||
|
/** Called whenever the stored token changes (e.g. to persist it). */
|
||||||
|
onToken?: (token: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth-flow helpers. These call **dev-defined** endpoints under the hood
|
||||||
|
* (the script layer owns the actual auth); the lib only standardizes the
|
||||||
|
* dance + in-memory token storage. There is no built-in identity model —
|
||||||
|
* `login` POSTs credentials and stores whatever `token` comes back.
|
||||||
|
*/
|
||||||
|
export class AuthClient {
|
||||||
|
private current: string | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly cfg: AuthClientConfig) {}
|
||||||
|
|
||||||
|
/** The current bearer token, or null. */
|
||||||
|
get token(): string | null {
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suitable as `PicloudClientOptions.getAuthToken`. */
|
||||||
|
readonly provider: AuthTokenProvider = () => this.current;
|
||||||
|
|
||||||
|
/** POST credentials to the login endpoint; store the returned token. */
|
||||||
|
async login(email: string, password: string): Promise<string | null> {
|
||||||
|
const ep = new Endpoint<{ email: string; password: string }, LoginResponse>({
|
||||||
|
baseURL: this.cfg.baseURL,
|
||||||
|
path: this.cfg.loginPath ?? '/api/auth/login',
|
||||||
|
fetchImpl: this.cfg.fetchImpl
|
||||||
|
});
|
||||||
|
const res = await ep.post({ email, password });
|
||||||
|
this.setToken(typeof res?.token === 'string' ? res.token : null);
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST to the logout endpoint (best-effort) and clear the token. */
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
const ep = new Endpoint<undefined, unknown>({
|
||||||
|
baseURL: this.cfg.baseURL,
|
||||||
|
path: this.cfg.logoutPath ?? '/api/auth/logout',
|
||||||
|
// Send the current token so the script can invalidate the session.
|
||||||
|
getAuthToken: () => this.current,
|
||||||
|
fetchImpl: this.cfg.fetchImpl
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await ep.post();
|
||||||
|
} finally {
|
||||||
|
this.setToken(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manually set (or clear) the token — e.g. restoring from storage. */
|
||||||
|
setToken(token: string | null): void {
|
||||||
|
this.current = token;
|
||||||
|
this.cfg.onToken?.(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
clients/typescript/src/client.ts
Normal file
61
clients/typescript/src/client.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { AuthClient } from './auth.js';
|
||||||
|
import { Endpoint } from './endpoint.js';
|
||||||
|
import { subscribeTopic } from './subscribe.js';
|
||||||
|
import type {
|
||||||
|
PicloudClientOptions,
|
||||||
|
RealtimeEvent,
|
||||||
|
SubscribeOptions,
|
||||||
|
Unsubscribe
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The PiCloud frontend client. Three capabilities, all script-mediated
|
||||||
|
* (the hybrid model — no direct KV/docs/users access from the browser):
|
||||||
|
*
|
||||||
|
* - `endpoint<Req, Res>(path)` — typed HTTP to a dev-defined route.
|
||||||
|
* - `subscribe(topic, cb, opts?)` — SSE realtime subscription.
|
||||||
|
* - `auth` — login/logout/token helpers over dev-defined endpoints.
|
||||||
|
*/
|
||||||
|
export class PicloudClient {
|
||||||
|
readonly auth: AuthClient;
|
||||||
|
private readonly baseURL: string;
|
||||||
|
private readonly fetchImpl: typeof fetch;
|
||||||
|
private readonly getAuthToken: PicloudClientOptions['getAuthToken'];
|
||||||
|
|
||||||
|
constructor(opts: PicloudClientOptions) {
|
||||||
|
if (!opts.baseURL) throw new Error('PicloudClient: baseURL is required');
|
||||||
|
this.baseURL = opts.baseURL;
|
||||||
|
const f = opts.fetch ?? globalThis.fetch;
|
||||||
|
if (typeof f !== 'function') {
|
||||||
|
throw new Error('PicloudClient: no fetch available — pass options.fetch');
|
||||||
|
}
|
||||||
|
// Bind to avoid "Illegal invocation" when calling a detached global.
|
||||||
|
this.fetchImpl = f.bind(globalThis);
|
||||||
|
this.getAuthToken = opts.getAuthToken;
|
||||||
|
this.auth = new AuthClient({ baseURL: this.baseURL, fetchImpl: this.fetchImpl });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A typed handle to a dev-defined endpoint. */
|
||||||
|
endpoint<Req = unknown, Res = unknown>(path: string): Endpoint<Req, Res> {
|
||||||
|
return new Endpoint<Req, Res>({
|
||||||
|
baseURL: this.baseURL,
|
||||||
|
path,
|
||||||
|
getAuthToken: this.getAuthToken,
|
||||||
|
fetchImpl: this.fetchImpl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to a realtime topic. Returns an unsubscribe function. */
|
||||||
|
subscribe<T = unknown>(
|
||||||
|
topic: string,
|
||||||
|
onMessage: (event: RealtimeEvent<T>) => void,
|
||||||
|
opts?: SubscribeOptions<T>
|
||||||
|
): Unsubscribe {
|
||||||
|
return subscribeTopic<T>(
|
||||||
|
{ baseURL: this.baseURL, fetchImpl: this.fetchImpl },
|
||||||
|
topic,
|
||||||
|
onMessage,
|
||||||
|
opts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
clients/typescript/src/endpoint.ts
Normal file
106
clients/typescript/src/endpoint.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { PicloudHttpError, type AuthTokenProvider, type Validator } from './types.js';
|
||||||
|
|
||||||
|
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||||
|
|
||||||
|
export interface EndpointConfig {
|
||||||
|
baseURL: string;
|
||||||
|
path: string;
|
||||||
|
getAuthToken?: AuthTokenProvider;
|
||||||
|
fetchImpl: typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestOptions<Res> {
|
||||||
|
/** Extra headers merged over the defaults. */
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
/** Optional runtime validation of the parsed response. */
|
||||||
|
validate?: Validator<Res>;
|
||||||
|
/** AbortSignal to cancel the request. */
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed HTTP to a dev-defined script endpoint. Auth header injection +
|
||||||
|
* structured errors; the request/response types are caller-supplied
|
||||||
|
* generics (`endpoint<Req, Res>('/path')`). No service access — every
|
||||||
|
* call hits a route a script binds (the hybrid model).
|
||||||
|
*/
|
||||||
|
export class Endpoint<Req = unknown, Res = unknown> {
|
||||||
|
constructor(private readonly cfg: EndpointConfig) {}
|
||||||
|
|
||||||
|
get(opts?: RequestOptions<Res>): Promise<Res> {
|
||||||
|
return this.send('GET', undefined, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
post(body?: Req, opts?: RequestOptions<Res>): Promise<Res> {
|
||||||
|
return this.send('POST', body, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
put(body?: Req, opts?: RequestOptions<Res>): Promise<Res> {
|
||||||
|
return this.send('PUT', body, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
patch(body?: Req, opts?: RequestOptions<Res>): Promise<Res> {
|
||||||
|
return this.send('PATCH', body, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(opts?: RequestOptions<Res>): Promise<Res> {
|
||||||
|
return this.send('DELETE', undefined, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async send(method: Method, body: Req | undefined, opts?: RequestOptions<Res>): Promise<Res> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(opts?.headers ?? {})
|
||||||
|
};
|
||||||
|
if (body !== undefined) {
|
||||||
|
headers['Content-Type'] ??= 'application/json';
|
||||||
|
}
|
||||||
|
const token = this.cfg.getAuthToken ? await this.cfg.getAuthToken() : undefined;
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] ??= `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = joinUrl(this.cfg.baseURL, this.cfg.path);
|
||||||
|
const init: RequestInit = { method, headers };
|
||||||
|
if (body !== undefined) {
|
||||||
|
init.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
if (opts?.signal) {
|
||||||
|
init.signal = opts.signal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await this.cfg.fetchImpl(url, init);
|
||||||
|
const parsed = await parseBody(res);
|
||||||
|
if (!res.ok) {
|
||||||
|
const message =
|
||||||
|
(isRecord(parsed) && typeof parsed['error'] === 'string' && parsed['error']) ||
|
||||||
|
`${method} ${this.cfg.path} failed with ${res.status}`;
|
||||||
|
throw new PicloudHttpError(res.status, message, parsed);
|
||||||
|
}
|
||||||
|
return opts?.validate ? opts.validate.parse(parsed) : (parsed as Res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseBody(res: Response): Promise<unknown> {
|
||||||
|
const text = await res.text();
|
||||||
|
if (text.length === 0) return null;
|
||||||
|
const ct = res.headers.get('content-type') ?? '';
|
||||||
|
if (ct.includes('application/json')) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||||
|
return typeof v === 'object' && v !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinUrl(base: string, path: string): string {
|
||||||
|
const b = base.endsWith('/') ? base.slice(0, -1) : base;
|
||||||
|
const p = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
return `${b}${p}`;
|
||||||
|
}
|
||||||
14
clients/typescript/src/index.ts
Normal file
14
clients/typescript/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export { PicloudClient } from './client.js';
|
||||||
|
export { Endpoint } from './endpoint.js';
|
||||||
|
export { AuthClient } from './auth.js';
|
||||||
|
export { subscribeTopic } from './subscribe.js';
|
||||||
|
export {
|
||||||
|
PicloudHttpError,
|
||||||
|
type PicloudClientOptions,
|
||||||
|
type AuthTokenProvider,
|
||||||
|
type RealtimeEvent,
|
||||||
|
type SubscribeOptions,
|
||||||
|
type Unsubscribe,
|
||||||
|
type Validator
|
||||||
|
} from './types.js';
|
||||||
|
export type { RequestOptions } from './endpoint.js';
|
||||||
101
clients/typescript/src/react/index.ts
Normal file
101
clients/typescript/src/react/index.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
createElement,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ReactNode
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import type { PicloudClient } from '../client.js';
|
||||||
|
import type { SubscribeOptions } from '../types.js';
|
||||||
|
|
||||||
|
const PicloudContext = createContext<PicloudClient | null>(null);
|
||||||
|
|
||||||
|
export interface PicloudProviderProps {
|
||||||
|
client: PicloudClient;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provides a `PicloudClient` to `useTopic` / `useEndpoint`. */
|
||||||
|
export function PicloudProvider(props: PicloudProviderProps) {
|
||||||
|
return createElement(PicloudContext.Provider, { value: props.client }, props.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The client from the nearest `PicloudProvider`. Throws if absent. */
|
||||||
|
export function usePicloud(): PicloudClient {
|
||||||
|
const client = useContext(PicloudContext);
|
||||||
|
if (!client) {
|
||||||
|
throw new Error('usePicloud: wrap your tree in <PicloudProvider client={...}>');
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a realtime topic; returns the accumulated messages in
|
||||||
|
* arrival order. Re-subscribes when `topic` changes; unsubscribes on
|
||||||
|
* unmount.
|
||||||
|
*/
|
||||||
|
export function useTopic<T = unknown>(topic: string, opts?: SubscribeOptions<T>): T[] {
|
||||||
|
const client = usePicloud();
|
||||||
|
const [messages, setMessages] = useState<T[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
setMessages([]);
|
||||||
|
const unsubscribe = client.subscribe<T>(
|
||||||
|
topic,
|
||||||
|
(event) => setMessages((prev) => [...prev, event.message]),
|
||||||
|
opts
|
||||||
|
);
|
||||||
|
return () => unsubscribe();
|
||||||
|
// `opts` is intentionally excluded: a new object literal each render
|
||||||
|
// would otherwise resubscribe every render.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [client, topic]);
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryState<T> {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EndpointHook<Req, Res> {
|
||||||
|
get: () => QueryState<Res>;
|
||||||
|
post: (body?: Req) => QueryState<Res>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed endpoint hook. `useEndpoint<Res>(path).get()` fires a GET and
|
||||||
|
* returns `{ data, loading, error }`, re-running when `path` changes.
|
||||||
|
* `.post(body)` is the mutation variant (auto-fires once per mount).
|
||||||
|
*/
|
||||||
|
export function useEndpoint<Res = unknown, Req = unknown>(path: string): EndpointHook<Req, Res> {
|
||||||
|
const client = usePicloud();
|
||||||
|
return {
|
||||||
|
get: () => useResource<Res>(() => client.endpoint<Req, Res>(path).get(), path, 'GET'),
|
||||||
|
post: (body?: Req) =>
|
||||||
|
useResource<Res>(() => client.endpoint<Req, Res>(path).post(body), path, 'POST')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useResource<Res>(run: () => Promise<Res>, key: string, method: string): QueryState<Res> {
|
||||||
|
const [state, setState] = useState<QueryState<Res>>({
|
||||||
|
data: null,
|
||||||
|
loading: true,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setState({ data: null, loading: true, error: null });
|
||||||
|
run()
|
||||||
|
.then((data) => active && setState({ data, loading: false, error: null }))
|
||||||
|
.catch((error) => active && setState({ data: null, loading: false, error }));
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
// `run` is recreated each render; key it on path + method instead.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [key, method]);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
194
clients/typescript/src/subscribe.ts
Normal file
194
clients/typescript/src/subscribe.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { joinUrl } from './endpoint.js';
|
||||||
|
import type { RealtimeEvent, SubscribeOptions, Unsubscribe } from './types.js';
|
||||||
|
|
||||||
|
interface SubscribeConfig {
|
||||||
|
baseURL: string;
|
||||||
|
fetchImpl: typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to an app pub/sub topic over SSE.
|
||||||
|
*
|
||||||
|
* Implemented over streaming `fetch` (not native `EventSource`) so the
|
||||||
|
* lib can: detect a 401 on (re)connect and refresh the token, send a
|
||||||
|
* `Last-Event-ID` header on resume, and apply its own exponential
|
||||||
|
* backoff. See HANDBACK for the rationale. Returns an unsubscribe
|
||||||
|
* function that aborts the connection and stops reconnecting.
|
||||||
|
*/
|
||||||
|
export function subscribeTopic<T = unknown>(
|
||||||
|
cfg: SubscribeConfig,
|
||||||
|
topic: string,
|
||||||
|
onMessage: (event: RealtimeEvent<T>) => void,
|
||||||
|
opts: SubscribeOptions<T> = {}
|
||||||
|
): Unsubscribe {
|
||||||
|
const baseBackoff = opts.baseBackoffMs ?? 1_000;
|
||||||
|
const maxBackoff = opts.maxBackoffMs ?? 30_000;
|
||||||
|
let token = opts.token;
|
||||||
|
let stopped = false;
|
||||||
|
let attempt = 0;
|
||||||
|
let lastEventId: string | undefined;
|
||||||
|
let controller: AbortController | null = null;
|
||||||
|
let backoffTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
stopped = true;
|
||||||
|
if (backoffTimer) clearTimeout(backoffTimer);
|
||||||
|
controller?.abort();
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
if (stopped) return;
|
||||||
|
// Exponential backoff: base, 2x, 4x… capped at maxBackoff.
|
||||||
|
const delay = Math.min(maxBackoff, baseBackoff * 2 ** attempt);
|
||||||
|
attempt += 1;
|
||||||
|
backoffTimer = setTimeout(() => void connect(), delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = async (): Promise<void> => {
|
||||||
|
if (stopped) return;
|
||||||
|
controller = new AbortController();
|
||||||
|
const url = buildUrl(cfg.baseURL, topic, token);
|
||||||
|
const headers: Record<string, string> = { Accept: 'text/event-stream' };
|
||||||
|
if (lastEventId) headers['Last-Event-ID'] = lastEventId;
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await cfg.fetchImpl(url, { headers, signal: controller.signal });
|
||||||
|
} catch (err) {
|
||||||
|
if (stopped || isAbort(err)) return;
|
||||||
|
scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
// Token expired / rejected — try to refresh, else give up.
|
||||||
|
const fresh = opts.onTokenExpired ? await opts.onTokenExpired() : null;
|
||||||
|
if (fresh) {
|
||||||
|
token = fresh;
|
||||||
|
attempt = 0; // fresh credential → reconnect immediately
|
||||||
|
void connect();
|
||||||
|
} else {
|
||||||
|
opts.onError?.(new Error('realtime subscribe unauthorized (401)'));
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
if (!stopped) scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connected — reset backoff and stream frames until the body ends.
|
||||||
|
attempt = 0;
|
||||||
|
try {
|
||||||
|
await readStream(res.body, (frame) => {
|
||||||
|
if (frame.id !== undefined) lastEventId = frame.id;
|
||||||
|
if (frame.data === undefined) return; // comment / heartbeat
|
||||||
|
const parsed = parseEvent<T>(frame.data, opts);
|
||||||
|
if (parsed) onMessage(parsed);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (stopped || isAbort(err)) return;
|
||||||
|
}
|
||||||
|
// Stream ended (server closed, e.g. topic deleted) → reconnect.
|
||||||
|
if (!stopped) scheduleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
void connect();
|
||||||
|
return stop;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(baseURL: string, topic: string, token?: string): string {
|
||||||
|
const url = joinUrl(baseURL, `/realtime/topics/${encodeURIComponent(topic)}`);
|
||||||
|
// EventSource can't set headers, so the token rides in the query
|
||||||
|
// string — the same path a raw EventSource would use.
|
||||||
|
return token ? `${url}?token=${encodeURIComponent(token)}` : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEvent<T>(data: string, opts: SubscribeOptions<T>): RealtimeEvent<T> | null {
|
||||||
|
let json: unknown;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isRealtimeShape(json)) return null;
|
||||||
|
const message = opts.validate ? opts.validate.parse(json.message) : (json.message as T);
|
||||||
|
return { topic: json.topic, message, published_at: json.published_at };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRealtimeShape(v: unknown): v is RealtimeEvent<unknown> {
|
||||||
|
return (
|
||||||
|
typeof v === 'object' &&
|
||||||
|
v !== null &&
|
||||||
|
typeof (v as Record<string, unknown>)['topic'] === 'string' &&
|
||||||
|
typeof (v as Record<string, unknown>)['published_at'] === 'string' &&
|
||||||
|
'message' in (v as Record<string, unknown>)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SseFrame {
|
||||||
|
data?: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read an SSE response body, invoking `onFrame` per event. Minimal
|
||||||
|
* parser: accumulates `data:` lines (joined by `\n`) and `id:` until a
|
||||||
|
* blank line dispatches the frame. Lines starting with `:` are comments
|
||||||
|
* (heartbeats) — surfaced as a frame with no `data` so the id can still
|
||||||
|
* advance.
|
||||||
|
*/
|
||||||
|
async function readStream(
|
||||||
|
body: ReadableStream<Uint8Array>,
|
||||||
|
onFrame: (frame: SseFrame) => void
|
||||||
|
): Promise<void> {
|
||||||
|
const reader = body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let dataLines: string[] = [];
|
||||||
|
let id: string | undefined;
|
||||||
|
let sawComment = false;
|
||||||
|
|
||||||
|
const dispatch = () => {
|
||||||
|
if (dataLines.length > 0) {
|
||||||
|
onFrame({ data: dataLines.join('\n'), id });
|
||||||
|
} else if (sawComment) {
|
||||||
|
onFrame({ id });
|
||||||
|
}
|
||||||
|
dataLines = [];
|
||||||
|
sawComment = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let nl: number;
|
||||||
|
while ((nl = buffer.indexOf('\n')) >= 0) {
|
||||||
|
const line = buffer.slice(0, nl).replace(/\r$/, '');
|
||||||
|
buffer = buffer.slice(nl + 1);
|
||||||
|
if (line === '') {
|
||||||
|
dispatch();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith(':')) {
|
||||||
|
sawComment = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const colon = line.indexOf(':');
|
||||||
|
const field = colon === -1 ? line : line.slice(0, colon);
|
||||||
|
const rawVal = colon === -1 ? '' : line.slice(colon + 1);
|
||||||
|
const val = rawVal.startsWith(' ') ? rawVal.slice(1) : rawVal;
|
||||||
|
if (field === 'data') dataLines.push(val);
|
||||||
|
else if (field === 'id') id = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Flush a trailing frame if the stream ended without a blank line.
|
||||||
|
dispatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbort(err: unknown): boolean {
|
||||||
|
return typeof err === 'object' && err !== null && (err as { name?: string }).name === 'AbortError';
|
||||||
|
}
|
||||||
72
clients/typescript/src/svelte/index.ts
Normal file
72
clients/typescript/src/svelte/index.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { readable, type Readable } from 'svelte/store';
|
||||||
|
|
||||||
|
import type { PicloudClient } from '../client.js';
|
||||||
|
import type { SubscribeOptions } from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Svelte store of realtime messages for a topic. `$messages` is an
|
||||||
|
* array that grows as events arrive. The SSE connection opens on the
|
||||||
|
* first subscriber and closes when the last unsubscribes (standard
|
||||||
|
* `readable` lifecycle).
|
||||||
|
*
|
||||||
|
* The client is passed explicitly (Svelte stores aren't components, so
|
||||||
|
* there's no React-style context to read). See HANDBACK §7.
|
||||||
|
*/
|
||||||
|
export function topicStore<T = unknown>(
|
||||||
|
client: PicloudClient,
|
||||||
|
topic: string,
|
||||||
|
opts?: SubscribeOptions<T>
|
||||||
|
): Readable<T[]> {
|
||||||
|
return readable<T[]>([], (set) => {
|
||||||
|
let items: T[] = [];
|
||||||
|
const unsubscribe = client.subscribe<T>(
|
||||||
|
topic,
|
||||||
|
(event) => {
|
||||||
|
items = [...items, event.message];
|
||||||
|
set(items);
|
||||||
|
},
|
||||||
|
opts
|
||||||
|
);
|
||||||
|
return () => unsubscribe();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryState<T> {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EndpointStore<Req, Res> {
|
||||||
|
get: () => Readable<QueryState<Res>>;
|
||||||
|
post: (body?: Req) => Readable<QueryState<Res>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Svelte store wrapper over a typed endpoint. `$query` is
|
||||||
|
* `{ data, loading, error }`. The request fires when the store gains its
|
||||||
|
* first subscriber.
|
||||||
|
*/
|
||||||
|
export function endpointStore<Res = unknown, Req = unknown>(
|
||||||
|
client: PicloudClient,
|
||||||
|
path: string
|
||||||
|
): EndpointStore<Req, Res> {
|
||||||
|
const run = (exec: () => Promise<Res>): Readable<QueryState<Res>> =>
|
||||||
|
readable<QueryState<Res>>({ data: null, loading: true, error: null }, (set) => {
|
||||||
|
let active = true;
|
||||||
|
exec()
|
||||||
|
.then((data) => {
|
||||||
|
if (active) set({ data, loading: false, error: null });
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (active) set({ data: null, loading: false, error });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
get: () => run(() => client.endpoint<Req, Res>(path).get()),
|
||||||
|
post: (body?: Req) => run(() => client.endpoint<Req, Res>(path).post(body))
|
||||||
|
};
|
||||||
|
}
|
||||||
73
clients/typescript/src/types.ts
Normal file
73
clients/typescript/src/types.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Shared types for @picloud/client.
|
||||||
|
|
||||||
|
/** Returns the current bearer token (or null) before each HTTP request. */
|
||||||
|
export type AuthTokenProvider = () => string | null | undefined | Promise<string | null | undefined>;
|
||||||
|
|
||||||
|
export interface PicloudClientOptions {
|
||||||
|
/** Base URL of the PiCloud deployment, e.g. `https://api.example.com`. */
|
||||||
|
baseURL: string;
|
||||||
|
/**
|
||||||
|
* Optional: returns the current bearer token, called before each
|
||||||
|
* request. The client doesn't manage tokens — it just sends them.
|
||||||
|
*/
|
||||||
|
getAuthToken?: AuthTokenProvider;
|
||||||
|
/**
|
||||||
|
* Optional fetch implementation (defaults to the global `fetch`).
|
||||||
|
* Injected mainly for tests / non-browser runtimes.
|
||||||
|
*/
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A realtime event as delivered over SSE. */
|
||||||
|
export interface RealtimeEvent<T = unknown> {
|
||||||
|
topic: string;
|
||||||
|
message: T;
|
||||||
|
published_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal validator shape for the optional runtime-validation adapter.
|
||||||
|
* A Zod schema satisfies this directly (`schema.parse`); for Valibot,
|
||||||
|
* wrap it: `{ parse: (i) => v.parse(schema, i) }`. No hard dep on either.
|
||||||
|
*/
|
||||||
|
export interface Validator<T> {
|
||||||
|
parse: (input: unknown) => T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown when an endpoint call returns a non-2xx status. */
|
||||||
|
export class PicloudHttpError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly body: unknown;
|
||||||
|
constructor(status: number, message: string, body: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'PicloudHttpError';
|
||||||
|
this.status = status;
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscribeOptions<T = unknown> {
|
||||||
|
/**
|
||||||
|
* Subscriber token for `auth_mode = 'token'` topics. Obtained from one
|
||||||
|
* of your app's script endpoints (which calls
|
||||||
|
* `pubsub::subscriber_token`). Sent as `?token=` (EventSource-parity).
|
||||||
|
*/
|
||||||
|
token?: string;
|
||||||
|
/**
|
||||||
|
* Called when a (re)connect is rejected with 401 — typically an
|
||||||
|
* expired token. Return a fresh token to retry immediately, or
|
||||||
|
* null/undefined to stop and surface the error.
|
||||||
|
*/
|
||||||
|
onTokenExpired?: () => string | null | undefined | Promise<string | null | undefined>;
|
||||||
|
/** Called on a terminal error (after retries are exhausted or aborted). */
|
||||||
|
onError?: (err: unknown) => void;
|
||||||
|
/** Optional runtime validation of each event's `message`. */
|
||||||
|
validate?: Validator<T>;
|
||||||
|
/** Max reconnect backoff in ms (default 30_000). */
|
||||||
|
maxBackoffMs?: number;
|
||||||
|
/** Base reconnect backoff in ms (default 1_000). */
|
||||||
|
baseBackoffMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancels a realtime subscription. */
|
||||||
|
export type Unsubscribe = () => void;
|
||||||
41
clients/typescript/tests/auth.test.ts
Normal file
41
clients/typescript/tests/auth.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { PicloudClient } from '../src/index.js';
|
||||||
|
import { jsonResponse, lastUrl, type FetchArgs } from './helpers.js';
|
||||||
|
|
||||||
|
describe('auth', () => {
|
||||||
|
it('login POSTs credentials and stores the returned token', async () => {
|
||||||
|
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) =>
|
||||||
|
jsonResponse({ token: 'session-abc' })
|
||||||
|
);
|
||||||
|
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||||
|
|
||||||
|
const token = await client.auth.login('alice@example.com', 'pw');
|
||||||
|
expect(token).toBe('session-abc');
|
||||||
|
expect(client.auth.token).toBe('session-abc');
|
||||||
|
expect(lastUrl(fetchMock)).toBe('https://api.test/api/auth/login');
|
||||||
|
|
||||||
|
const init = fetchMock.mock.calls[0]?.[1];
|
||||||
|
expect(JSON.parse(String(init?.body))).toEqual({
|
||||||
|
email: 'alice@example.com',
|
||||||
|
password: 'pw'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logout clears the stored token', async () => {
|
||||||
|
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) => jsonResponse({}));
|
||||||
|
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||||
|
client.auth.setToken('existing');
|
||||||
|
await client.auth.logout();
|
||||||
|
expect(client.auth.token).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provider returns the current token for getAuthToken wiring', async () => {
|
||||||
|
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) =>
|
||||||
|
jsonResponse({ token: 't' })
|
||||||
|
);
|
||||||
|
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||||
|
await client.auth.login('a@b.c', 'pw');
|
||||||
|
expect(client.auth.provider()).toBe('t');
|
||||||
|
});
|
||||||
|
});
|
||||||
82
clients/typescript/tests/endpoint.test.ts
Normal file
82
clients/typescript/tests/endpoint.test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { PicloudClient, PicloudHttpError } from '../src/index.js';
|
||||||
|
import { headerOf, jsonResponse, lastInit, lastUrl, type FetchArgs } from './helpers.js';
|
||||||
|
|
||||||
|
describe('endpoint', () => {
|
||||||
|
it('post round-trips a typed request/response', async () => {
|
||||||
|
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) =>
|
||||||
|
jsonResponse({ id: '1', name: 'alice', created_at: 'now' }, 201)
|
||||||
|
);
|
||||||
|
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||||
|
|
||||||
|
interface Req {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
interface Res {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
const res = await client.endpoint<Req, Res>('/api/users').post({ name: 'alice', role: 'admin' });
|
||||||
|
|
||||||
|
expect(res).toEqual({ id: '1', name: 'alice', created_at: 'now' });
|
||||||
|
expect(lastUrl(fetchMock)).toBe('https://api.test/api/users');
|
||||||
|
const init = lastInit(fetchMock);
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
expect(JSON.parse(String(init.body))).toEqual({ name: 'alice', role: 'admin' });
|
||||||
|
expect(headerOf(init, 'Content-Type')).toBe('application/json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('get round-trips', async () => {
|
||||||
|
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) =>
|
||||||
|
jsonResponse({ name: 'bob' })
|
||||||
|
);
|
||||||
|
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||||
|
const res = await client.endpoint<unknown, { name: string }>('/api/users/1').get();
|
||||||
|
expect(res.name).toBe('bob');
|
||||||
|
expect(lastInit(fetchMock).method).toBe('GET');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('injects the auth token from getAuthToken', async () => {
|
||||||
|
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) => jsonResponse({ ok: true }));
|
||||||
|
const client = new PicloudClient({
|
||||||
|
baseURL: 'https://api.test',
|
||||||
|
fetch: fetchMock,
|
||||||
|
getAuthToken: () => 'tok-123'
|
||||||
|
});
|
||||||
|
await client.endpoint('/api/me').get();
|
||||||
|
expect(headerOf(lastInit(fetchMock), 'Authorization')).toBe('Bearer tok-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws PicloudHttpError with status + body on non-2xx', async () => {
|
||||||
|
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) =>
|
||||||
|
jsonResponse({ error: 'bad input' }, 422)
|
||||||
|
);
|
||||||
|
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||||
|
const err = await client
|
||||||
|
.endpoint('/api/x')
|
||||||
|
.get()
|
||||||
|
.catch((e: unknown) => e);
|
||||||
|
expect(err).toBeInstanceOf(PicloudHttpError);
|
||||||
|
expect((err as PicloudHttpError).status).toBe(422);
|
||||||
|
expect((err as PicloudHttpError).message).toBe('bad input');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies an optional validator to the response', async () => {
|
||||||
|
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) =>
|
||||||
|
jsonResponse({ id: 7 })
|
||||||
|
);
|
||||||
|
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||||
|
const validator = {
|
||||||
|
parse: (input: unknown) => {
|
||||||
|
const r = input as { id: number };
|
||||||
|
if (typeof r.id !== 'number') throw new Error('bad');
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const res = await client.endpoint<unknown, { id: number }>('/api/x').get({ validate: validator });
|
||||||
|
expect(res.id).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
54
clients/typescript/tests/helpers.ts
Normal file
54
clients/typescript/tests/helpers.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Test helpers: build JSON + SSE Response objects and a typed fetch mock.
|
||||||
|
|
||||||
|
export function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyResponse(status = 200): Response {
|
||||||
|
return new Response(null, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a text/event-stream Response from raw SSE frame strings. */
|
||||||
|
export function sseResponse(frames: string[], status = 200): Response {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
for (const frame of frames) controller.enqueue(encoder.encode(frame));
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return new Response(stream, {
|
||||||
|
status,
|
||||||
|
headers: { 'content-type': 'text/event-stream' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One SSE `data:` event frame for a realtime payload. */
|
||||||
|
export function dataFrame(topic: string, message: unknown, publishedAt = '2026-06-04T00:00:00Z'): string {
|
||||||
|
const payload = JSON.stringify({ topic, message, published_at: publishedAt });
|
||||||
|
return `data: ${payload}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FetchArgs = [string | URL | Request, RequestInit?];
|
||||||
|
|
||||||
|
type MockLike = { mock: { calls: ReadonlyArray<ReadonlyArray<unknown>> } };
|
||||||
|
|
||||||
|
export function lastInit(mock: MockLike, i = 0): RequestInit {
|
||||||
|
const call = mock.mock.calls[i];
|
||||||
|
if (!call) throw new Error(`no fetch call at index ${i}`);
|
||||||
|
return (call[1] as RequestInit | undefined) ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lastUrl(mock: MockLike, i = 0): string {
|
||||||
|
const call = mock.mock.calls[i];
|
||||||
|
if (!call) throw new Error(`no fetch call at index ${i}`);
|
||||||
|
return String(call[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function headerOf(init: RequestInit, name: string): string | undefined {
|
||||||
|
const h = init.headers as Record<string, string> | undefined;
|
||||||
|
return h?.[name];
|
||||||
|
}
|
||||||
41
clients/typescript/tests/react.test.tsx
Normal file
41
clients/typescript/tests/react.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { PicloudClient, RealtimeEvent, Unsubscribe } from '../src/index.js';
|
||||||
|
import { PicloudProvider, useTopic } from '../src/react/index.js';
|
||||||
|
|
||||||
|
type Cb = (e: RealtimeEvent<unknown>) => void;
|
||||||
|
|
||||||
|
function fakeClient() {
|
||||||
|
const unsubscribe = vi.fn();
|
||||||
|
let captured: Cb | null = null;
|
||||||
|
const subscribe = vi.fn(
|
||||||
|
(_topic: string, cb: Cb): Unsubscribe => {
|
||||||
|
captured = cb;
|
||||||
|
return unsubscribe as unknown as Unsubscribe;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const client = { subscribe } as unknown as PicloudClient;
|
||||||
|
return { client, subscribe, unsubscribe, emit: (e: RealtimeEvent<unknown>) => captured?.(e) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('react useTopic', () => {
|
||||||
|
it('subscribes on mount, accumulates messages, unsubscribes on unmount', () => {
|
||||||
|
const { client, subscribe, unsubscribe, emit } = fakeClient();
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||||
|
PicloudProvider({ client, children });
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useTopic<{ n: number }>('chat'), { wrapper });
|
||||||
|
|
||||||
|
expect(subscribe).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.current).toEqual([]);
|
||||||
|
|
||||||
|
act(() => emit({ topic: 'chat', message: { n: 1 }, published_at: 't' }));
|
||||||
|
act(() => emit({ topic: 'chat', message: { n: 2 }, published_at: 't' }));
|
||||||
|
expect(result.current).toEqual([{ n: 1 }, { n: 2 }]);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
99
clients/typescript/tests/subscribe.test.ts
Normal file
99
clients/typescript/tests/subscribe.test.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { PicloudClient, type RealtimeEvent } from '../src/index.js';
|
||||||
|
import { dataFrame, emptyResponse, lastUrl, sseResponse, type FetchArgs } from './helpers.js';
|
||||||
|
|
||||||
|
/** A fetch mock that plays through a queue of response factories. */
|
||||||
|
function queuedFetch(responders: Array<() => Promise<Response>>) {
|
||||||
|
let i = 0;
|
||||||
|
return vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) => {
|
||||||
|
const idx = Math.min(i, responders.length - 1);
|
||||||
|
i += 1;
|
||||||
|
const r = responders[idx];
|
||||||
|
if (!r) throw new Error('no responder');
|
||||||
|
return r();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('subscribe', () => {
|
||||||
|
it('connects to the SSE endpoint and delivers events', async () => {
|
||||||
|
const fetchMock = queuedFetch([async () => sseResponse([dataFrame('chat', { hi: 1 })])]);
|
||||||
|
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||||
|
|
||||||
|
const received: Array<RealtimeEvent<{ hi: number }>> = [];
|
||||||
|
const unsubscribe = client.subscribe<{ hi: number }>('chat', (e) => received.push(e));
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(received.length).toBe(1));
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
expect(received[0]?.topic).toBe('chat');
|
||||||
|
expect(received[0]?.message).toEqual({ hi: 1 });
|
||||||
|
expect(lastUrl(fetchMock)).toBe('https://api.test/realtime/topics/chat');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a token via the query string', async () => {
|
||||||
|
const fetchMock = queuedFetch([async () => sseResponse([dataFrame('chat', 1)])]);
|
||||||
|
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||||
|
const unsubscribe = client.subscribe('chat', () => {}, { token: 'abc.def' });
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
unsubscribe();
|
||||||
|
expect(lastUrl(fetchMock)).toBe('https://api.test/realtime/topics/chat?token=abc.def');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconnects with backoff after an initial connection failure', async () => {
|
||||||
|
const fetchMock = queuedFetch([
|
||||||
|
async () => {
|
||||||
|
throw new Error('network down');
|
||||||
|
},
|
||||||
|
async () => sseResponse([dataFrame('chat', { ok: true })])
|
||||||
|
]);
|
||||||
|
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||||
|
|
||||||
|
const received: unknown[] = [];
|
||||||
|
const unsubscribe = client.subscribe('chat', (e) => received.push(e.message), {
|
||||||
|
baseBackoffMs: 5,
|
||||||
|
maxBackoffMs: 20
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(received.length).toBeGreaterThanOrEqual(1), { timeout: 1000 });
|
||||||
|
unsubscribe();
|
||||||
|
expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(received[0]).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes the token after a 401 and reconnects', async () => {
|
||||||
|
const fetchMock = queuedFetch([
|
||||||
|
async () => emptyResponse(401),
|
||||||
|
async () => sseResponse([dataFrame('chat', { v: 2 })])
|
||||||
|
]);
|
||||||
|
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||||
|
|
||||||
|
const onTokenExpired = vi.fn(() => 'fresh-token');
|
||||||
|
const received: unknown[] = [];
|
||||||
|
const unsubscribe = client.subscribe('chat', (e) => received.push(e.message), {
|
||||||
|
token: 'stale',
|
||||||
|
onTokenExpired,
|
||||||
|
baseBackoffMs: 5
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(received.length).toBeGreaterThanOrEqual(1), { timeout: 1000 });
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
expect(onTokenExpired).toHaveBeenCalled();
|
||||||
|
// Second connect carries the refreshed token.
|
||||||
|
expect(lastUrl(fetchMock, 1)).toContain('token=fresh-token');
|
||||||
|
expect(received[0]).toEqual({ v: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops and reports when a 401 cannot be refreshed', async () => {
|
||||||
|
const fetchMock = queuedFetch([async () => emptyResponse(401)]);
|
||||||
|
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||||
|
const onError = vi.fn();
|
||||||
|
const unsubscribe = client.subscribe('chat', () => {}, {
|
||||||
|
onTokenExpired: () => null,
|
||||||
|
onError
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(onError).toHaveBeenCalled());
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
34
clients/typescript/tests/svelte.test.ts
Normal file
34
clients/typescript/tests/svelte.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { PicloudClient, RealtimeEvent, Unsubscribe } from '../src/index.js';
|
||||||
|
import { topicStore } from '../src/svelte/index.js';
|
||||||
|
|
||||||
|
type Cb = (e: RealtimeEvent<unknown>) => void;
|
||||||
|
|
||||||
|
describe('svelte topicStore', () => {
|
||||||
|
it('subscribes on first subscriber and unsubscribes on last', () => {
|
||||||
|
const unsubscribe = vi.fn();
|
||||||
|
const holder: { cb: Cb | null } = { cb: null };
|
||||||
|
const subscribe = vi.fn((_topic: string, cb: Cb): Unsubscribe => {
|
||||||
|
holder.cb = cb;
|
||||||
|
return unsubscribe as unknown as Unsubscribe;
|
||||||
|
});
|
||||||
|
const client = { subscribe } as unknown as PicloudClient;
|
||||||
|
|
||||||
|
const store = topicStore<{ x: number }>(client, 'chat');
|
||||||
|
// No SSE connection until someone subscribes (readable lifecycle).
|
||||||
|
expect(subscribe).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
let value: { x: number }[] = [];
|
||||||
|
const stop = store.subscribe((v) => (value = v));
|
||||||
|
expect(subscribe).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
holder.cb?.({ topic: 'chat', message: { x: 1 }, published_at: 't' });
|
||||||
|
expect(value).toEqual([{ x: 1 }]);
|
||||||
|
expect(get(store)).toEqual([{ x: 1 }]);
|
||||||
|
|
||||||
|
stop();
|
||||||
|
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
21
clients/typescript/tsconfig.json
Normal file
21
clients/typescript/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": false,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src", "tests"]
|
||||||
|
}
|
||||||
18
clients/typescript/tsup.config.ts
Normal file
18
clients/typescript/tsup.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
|
// Dual ESM + CJS emit with .d.ts for the main entry and the two
|
||||||
|
// framework subpath exports. React and Svelte are peer deps — kept
|
||||||
|
// external so the lib never bundles a framework copy.
|
||||||
|
export default defineConfig({
|
||||||
|
entry: {
|
||||||
|
index: 'src/index.ts',
|
||||||
|
'react/index': 'src/react/index.ts',
|
||||||
|
'svelte/index': 'src/svelte/index.ts'
|
||||||
|
},
|
||||||
|
format: ['esm', 'cjs'],
|
||||||
|
dts: true,
|
||||||
|
clean: true,
|
||||||
|
sourcemap: true,
|
||||||
|
treeshake: true,
|
||||||
|
external: ['react', 'svelte', 'svelte/store']
|
||||||
|
});
|
||||||
11
clients/typescript/vitest.config.ts
Normal file
11
clients/typescript/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
// jsdom so the React/Svelte hook tests have a DOM; the core
|
||||||
|
// endpoint/subscribe/auth tests are environment-agnostic.
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx']
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -40,9 +40,85 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// `pubsub::subscriber_token(topics)` — uses the configured default
|
||||||
|
// TTL.
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"subscriber_token",
|
||||||
|
move |topics: Array| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
mint_token(&svc, &cx, topics, None)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// `pubsub::subscriber_token(topics, ttl)` — `ttl` is an integer
|
||||||
|
// (seconds) or `()` for the default.
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"subscriber_token",
|
||||||
|
move |topics: Array, ttl: Dynamic| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
let ttl = ttl_from_dynamic(&ttl)?;
|
||||||
|
mint_token(&svc, &cx, topics, ttl)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
engine.register_static_module("pubsub", module.into());
|
engine.register_static_module("pubsub", module.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Interpret the optional `ttl` argument: `()` → use the default,
|
||||||
|
/// integer → that many seconds, anything else → throw.
|
||||||
|
fn ttl_from_dynamic(ttl: &Dynamic) -> Result<Option<i64>, Box<EvalAltResult>> {
|
||||||
|
if ttl.is_unit() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
ttl.as_int().map(Some).map_err(|_| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
"pubsub::subscriber_token: ttl must be an integer (seconds) or ()".into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mint_token(
|
||||||
|
svc: &Arc<dyn picloud_shared::PubsubService>,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
topics: Array,
|
||||||
|
ttl: Option<i64>,
|
||||||
|
) -> Result<String, Box<EvalAltResult>> {
|
||||||
|
// Every element must be a string; surface a clear error otherwise.
|
||||||
|
let mut names = Vec::with_capacity(topics.len());
|
||||||
|
for t in topics {
|
||||||
|
if !t.is_string() {
|
||||||
|
return Err(EvalAltResult::ErrorRuntime(
|
||||||
|
"pubsub::subscriber_token: topics must be an array of strings".into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
names.push(t.into_string().unwrap_or_default());
|
||||||
|
}
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("pubsub: no tokio runtime available: {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
// SubscriberToken errors already carry the full
|
||||||
|
// "pubsub::subscriber_token: …" wording, so surface them verbatim.
|
||||||
|
handle
|
||||||
|
.block_on(async move { svc.mint_subscriber_token(&cx, names, ttl).await })
|
||||||
|
.map_err(|err| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(format!("{err}").into(), rhai::Position::NONE).into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a Rhai `Dynamic` message into JSON, base64-encoding any
|
/// Convert a Rhai `Dynamic` message into JSON, base64-encoding any
|
||||||
/// `Blob` (at any nesting depth). Mirrors `bridge::dynamic_to_json` but
|
/// `Blob` (at any nesting depth). Mirrors `bridge::dynamic_to_json` but
|
||||||
/// adds the blob arm the pub/sub wire contract requires.
|
/// adds the blob arm the pub/sub wire contract requires.
|
||||||
|
|||||||
242
crates/executor-core/tests/sdk_subscriber_token.rs
Normal file
242
crates/executor-core/tests/sdk_subscriber_token.rs
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
//! `pubsub::subscriber_token` SDK bridge integration tests (v1.1.6).
|
||||||
|
//!
|
||||||
|
//! Runs a real Rhai engine against a fake `PubsubService` whose
|
||||||
|
//! `mint_subscriber_token` mirrors the production validation (principal
|
||||||
|
//! required, non-empty topics, ttl clamp, externally-subscribable check)
|
||||||
|
//! and signs a real token. These cover the bridge surface: array →
|
||||||
|
//! `Vec<String>` forwarding, the omitted/`()`/integer ttl handling, and
|
||||||
|
//! errors surfacing as thrown Rhai errors. The authoritative validation
|
||||||
|
//! logic is unit-tested in `manager-core::pubsub_service`.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||||
|
use picloud_shared::subscriber_token::{self, TokenClaims};
|
||||||
|
use picloud_shared::{
|
||||||
|
AdminUserId, AppId, ExecutionId, InstanceRole, NoopDeadLetterService, NoopDocsService,
|
||||||
|
NoopEventEmitter, NoopFilesService, NoopHttpService, NoopKvService, NoopModuleSource,
|
||||||
|
Principal, PubsubError, PubsubService, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services,
|
||||||
|
};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
const FAKE_KEY: [u8; 32] = [7u8; 32];
|
||||||
|
const MIN_TTL: i64 = 10;
|
||||||
|
const MAX_TTL: i64 = 86_400;
|
||||||
|
const DEFAULT_TTL: i64 = 3_600;
|
||||||
|
|
||||||
|
/// Fake that mirrors the production mint rules and signs with FAKE_KEY.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeMintPubsub;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PubsubService for FakeMintPubsub {
|
||||||
|
async fn publish_durable(
|
||||||
|
&self,
|
||||||
|
_cx: &SdkCallCx,
|
||||||
|
_topic: &str,
|
||||||
|
_message: Value,
|
||||||
|
) -> Result<(), PubsubError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mint_subscriber_token(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
topics: Vec<String>,
|
||||||
|
ttl_seconds: Option<i64>,
|
||||||
|
) -> Result<String, PubsubError> {
|
||||||
|
if cx.principal.is_none() {
|
||||||
|
return Err(PubsubError::SubscriberToken(
|
||||||
|
"pubsub::subscriber_token: requires an authenticated principal".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if topics.is_empty() {
|
||||||
|
return Err(PubsubError::SubscriberToken(
|
||||||
|
"pubsub::subscriber_token: topics list must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let ttl = ttl_seconds.unwrap_or(DEFAULT_TTL);
|
||||||
|
if !(MIN_TTL..=MAX_TTL).contains(&ttl) {
|
||||||
|
return Err(PubsubError::SubscriberToken(format!(
|
||||||
|
"pubsub::subscriber_token: ttl_seconds must be between {MIN_TTL} and {MAX_TTL}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
for name in &topics {
|
||||||
|
// Only "chat" and "notify" are "registered" in this fake.
|
||||||
|
if name != "chat" && name != "notify" {
|
||||||
|
return Err(PubsubError::SubscriberToken(format!(
|
||||||
|
"pubsub::subscriber_token: topic {name} is not externally subscribable"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let now = 1_000_000;
|
||||||
|
Ok(subscriber_token::sign(
|
||||||
|
&FAKE_KEY,
|
||||||
|
&TokenClaims {
|
||||||
|
app_id: cx.app_id,
|
||||||
|
topics,
|
||||||
|
exp: now + ttl,
|
||||||
|
iat: now,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_engine() -> Arc<Engine> {
|
||||||
|
let services = Services::new(
|
||||||
|
Arc::new(NoopKvService),
|
||||||
|
Arc::new(NoopDocsService),
|
||||||
|
Arc::new(NoopDeadLetterService),
|
||||||
|
Arc::new(NoopEventEmitter),
|
||||||
|
Arc::new(NoopModuleSource),
|
||||||
|
Arc::new(NoopHttpService),
|
||||||
|
Arc::new(NoopFilesService),
|
||||||
|
Arc::new(FakeMintPubsub),
|
||||||
|
);
|
||||||
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(app_id: AppId, with_principal: bool) -> ExecRequest {
|
||||||
|
let execution_id = ExecutionId::new();
|
||||||
|
ExecRequest {
|
||||||
|
execution_id,
|
||||||
|
request_id: RequestId::new(),
|
||||||
|
script_id: ScriptId::new(),
|
||||||
|
script_name: "token-test".into(),
|
||||||
|
invocation_type: InvocationType::Http,
|
||||||
|
path: "/token-test".into(),
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
body: Value::Null,
|
||||||
|
params: BTreeMap::new(),
|
||||||
|
query: BTreeMap::new(),
|
||||||
|
rest: String::new(),
|
||||||
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
|
app_id,
|
||||||
|
principal: with_principal.then(|| Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: InstanceRole::Owner,
|
||||||
|
scopes: None,
|
||||||
|
app_binding: None,
|
||||||
|
}),
|
||||||
|
trigger_depth: 0,
|
||||||
|
root_execution_id: execution_id,
|
||||||
|
is_dead_letter_handler: false,
|
||||||
|
event: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_ok(engine: Arc<Engine>, src: &str, req: ExecRequest) -> Value {
|
||||||
|
let src = src.to_string();
|
||||||
|
tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.expect("spawn_blocking should not panic")
|
||||||
|
.expect("script execution should succeed")
|
||||||
|
.body
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_err(engine: Arc<Engine>, src: &str, req: ExecRequest) {
|
||||||
|
let src = src.to_string();
|
||||||
|
let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.expect("spawn_blocking should not panic");
|
||||||
|
assert!(res.is_err(), "expected script to throw");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn token_contains_topics_and_expiry() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let body = run_ok(
|
||||||
|
make_engine(),
|
||||||
|
r#"#{ token: pubsub::subscriber_token(["chat", "notify"], 120) }"#,
|
||||||
|
request(app, true),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let token = body["token"].as_str().expect("token string");
|
||||||
|
let claims = subscriber_token::verify(&FAKE_KEY, token, 1_000_001).unwrap();
|
||||||
|
assert_eq!(claims.app_id, app);
|
||||||
|
assert_eq!(
|
||||||
|
claims.topics,
|
||||||
|
vec!["chat".to_string(), "notify".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(claims.exp - claims.iat, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn omitted_ttl_uses_default() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let body = run_ok(
|
||||||
|
make_engine(),
|
||||||
|
r#"#{ token: pubsub::subscriber_token(["chat"]) }"#,
|
||||||
|
request(app, true),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let token = body["token"].as_str().unwrap();
|
||||||
|
let claims = subscriber_token::verify(&FAKE_KEY, token, 1_000_001).unwrap();
|
||||||
|
assert_eq!(claims.exp - claims.iat, DEFAULT_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn unit_ttl_uses_default() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let body = run_ok(
|
||||||
|
make_engine(),
|
||||||
|
r#"#{ token: pubsub::subscriber_token(["chat"], ()) }"#,
|
||||||
|
request(app, true),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let token = body["token"].as_str().unwrap();
|
||||||
|
let claims = subscriber_token::verify(&FAKE_KEY, token, 1_000_001).unwrap();
|
||||||
|
assert_eq!(claims.exp - claims.iat, DEFAULT_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn empty_topics_throws() {
|
||||||
|
run_err(
|
||||||
|
make_engine(),
|
||||||
|
r#"pubsub::subscriber_token([], 60)"#,
|
||||||
|
request(AppId::new(), true),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn ttl_below_min_throws() {
|
||||||
|
run_err(
|
||||||
|
make_engine(),
|
||||||
|
r#"pubsub::subscriber_token(["chat"], 5)"#,
|
||||||
|
request(AppId::new(), true),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn ttl_above_max_throws() {
|
||||||
|
run_err(
|
||||||
|
make_engine(),
|
||||||
|
r#"pubsub::subscriber_token(["chat"], 90000)"#,
|
||||||
|
request(AppId::new(), true),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn anonymous_principal_throws() {
|
||||||
|
run_err(
|
||||||
|
make_engine(),
|
||||||
|
r#"pubsub::subscriber_token(["chat"], 60)"#,
|
||||||
|
request(AppId::new(), false),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn unregistered_topic_throws() {
|
||||||
|
run_err(
|
||||||
|
make_engine(),
|
||||||
|
r#"pubsub::subscriber_token(["chat", "secret"], 60)"#,
|
||||||
|
request(AppId::new(), true),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
31
crates/manager-core/migrations/0021_topics.sql
Normal file
31
crates/manager-core/migrations/0021_topics.sql
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
-- v1.1.6: Explicit registration for externally-subscribable topics.
|
||||||
|
--
|
||||||
|
-- Internal-only topics remain implicit per the §5 design-notes
|
||||||
|
-- decision: anyone can publish_durable("any.topic", msg) and triggers
|
||||||
|
-- can subscribe without a row here. This table only holds topics that
|
||||||
|
-- have been explicitly externalized — external SSE subscribers can
|
||||||
|
-- only subscribe to topics with a row here AND external_subscribable
|
||||||
|
-- = TRUE.
|
||||||
|
--
|
||||||
|
-- The publish path (v1.1.5's publish_durable) does NOT consult this
|
||||||
|
-- table: publishing to a topic with no row still fans out to triggers
|
||||||
|
-- and to any in-process external subscribers (none exist for an
|
||||||
|
-- unregistered topic, since external subscribers can't subscribe to
|
||||||
|
-- one). The topics table is read by the SSE subscribe path only.
|
||||||
|
--
|
||||||
|
-- auth_mode values: 'public' + 'token' in v1.1.6. 'session' arrives in
|
||||||
|
-- v1.1.8 (users-SDK); 'script' arrives in v1.2 (script-mediated auth).
|
||||||
|
-- The CHECK constraint extends in those releases.
|
||||||
|
CREATE TABLE topics (
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
external_subscribable BOOL NOT NULL DEFAULT FALSE,
|
||||||
|
auth_mode TEXT NOT NULL DEFAULT 'public'
|
||||||
|
CHECK (auth_mode IN ('public', 'token')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (app_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Hot lookup: "is topic T in app X externally subscribable?" The PK
|
||||||
|
-- (app_id, name) already covers this; an explicit index is redundant.
|
||||||
19
crates/manager-core/migrations/0022_app_secrets.sql
Normal file
19
crates/manager-core/migrations/0022_app_secrets.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- v1.1.6: per-app secret material. Currently holds the HMAC signing key
|
||||||
|
-- used to mint + verify realtime subscriber tokens
|
||||||
|
-- (pubsub::subscriber_token → SSE /realtime/topics handshake).
|
||||||
|
--
|
||||||
|
-- The key is:
|
||||||
|
-- * stable across restarts (issued tokens stay valid until expiry),
|
||||||
|
-- * per-app (a token signed by app A is rejected by app B),
|
||||||
|
-- * never script-accessible (scripts can't print/exfiltrate it — the
|
||||||
|
-- SDK only mints tokens, it never returns the key).
|
||||||
|
--
|
||||||
|
-- The row is created lazily on the first pubsub::subscriber_token call
|
||||||
|
-- for an app (32 random bytes). This table is the natural home for
|
||||||
|
-- v1.1.7's encrypted per-app secrets work.
|
||||||
|
CREATE TABLE app_secrets (
|
||||||
|
app_id UUID PRIMARY KEY REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
realtime_signing_key BYTEA NOT NULL, -- 32 random bytes
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
91
crates/manager-core/src/app_secrets_repo.rs
Normal file
91
crates/manager-core/src/app_secrets_repo.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
//! `AppSecretsRepo` — per-app secret material (v1.1.6).
|
||||||
|
//!
|
||||||
|
//! Today this holds only the HMAC signing key for realtime subscriber
|
||||||
|
//! tokens. The key is generated lazily (32 random bytes) on the first
|
||||||
|
//! `pubsub::subscriber_token` call for an app and never changes
|
||||||
|
//! thereafter in v1.1.6 (no rotation API yet — rotation is the
|
||||||
|
//! key-invalidation mechanism, deferred). The key is never exposed to
|
||||||
|
//! scripts: the SDK mints tokens, it never returns the key.
|
||||||
|
//!
|
||||||
|
//! This table is the natural home for v1.1.7's encrypted per-app
|
||||||
|
//! secrets work.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_shared::AppId;
|
||||||
|
use rand::RngCore;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
/// Length of a freshly-generated realtime signing key.
|
||||||
|
pub const SIGNING_KEY_LEN: usize = 32;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppSecretsRepoError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AppSecretsRepo: Send + Sync {
|
||||||
|
/// Fetch the app's realtime signing key, generating + persisting one
|
||||||
|
/// (32 random bytes) if absent. Idempotent under concurrency: a
|
||||||
|
/// racing creator's `ON CONFLICT DO NOTHING` insert is a no-op and
|
||||||
|
/// the existing key is returned.
|
||||||
|
async fn get_or_create_signing_key(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Vec<u8>, AppSecretsRepoError>;
|
||||||
|
|
||||||
|
/// Fetch the signing key if it exists, WITHOUT creating one. The SSE
|
||||||
|
/// verify path uses this: a missing key means no token was ever
|
||||||
|
/// minted for the app, so any presented token must be rejected.
|
||||||
|
async fn signing_key(&self, app_id: AppId) -> Result<Option<Vec<u8>>, AppSecretsRepoError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresAppSecretsRepo {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresAppSecretsRepo {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AppSecretsRepo for PostgresAppSecretsRepo {
|
||||||
|
async fn get_or_create_signing_key(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Vec<u8>, AppSecretsRepoError> {
|
||||||
|
let mut fresh = vec![0u8; SIGNING_KEY_LEN];
|
||||||
|
rand::thread_rng().fill_bytes(&mut fresh);
|
||||||
|
|
||||||
|
// Insert-if-absent then read: the racing-creator's insert is a
|
||||||
|
// no-op, and the SELECT always returns the winning key.
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO app_secrets (app_id, realtime_signing_key) \
|
||||||
|
VALUES ($1, $2) ON CONFLICT (app_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(&fresh)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let key: (Vec<u8>,) =
|
||||||
|
sqlx::query_as("SELECT realtime_signing_key FROM app_secrets WHERE app_id = $1")
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(key.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn signing_key(&self, app_id: AppId) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
|
||||||
|
let row: Option<(Vec<u8>,)> =
|
||||||
|
sqlx::query_as("SELECT realtime_signing_key FROM app_secrets WHERE app_id = $1")
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|r| r.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,12 @@ pub enum Capability {
|
|||||||
/// to `app:admin` on API keys. Public-HTTP scripts (principal None)
|
/// to `app:admin` on API keys. Public-HTTP scripts (principal None)
|
||||||
/// fail this check — managing dead letters is an admin act.
|
/// fail this check — managing dead letters is an admin act.
|
||||||
AppDeadLetterManage(AppId),
|
AppDeadLetterManage(AppId),
|
||||||
|
/// Register / list / update / delete externally-subscribable topics
|
||||||
|
/// for this app (v1.1.6). Maps to `app:admin` on API keys —
|
||||||
|
/// externalizing a topic is an app-configuration act with security
|
||||||
|
/// weight (it opens an internal pub/sub topic to outside SSE
|
||||||
|
/// subscribers). Granted to `app_admin`+.
|
||||||
|
AppTopicManage(AppId),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Capability {
|
impl Capability {
|
||||||
@@ -123,7 +129,8 @@ impl Capability {
|
|||||||
| Self::AppFilesWrite(id)
|
| Self::AppFilesWrite(id)
|
||||||
| Self::AppPubsubPublish(id)
|
| Self::AppPubsubPublish(id)
|
||||||
| Self::AppManageTriggers(id)
|
| Self::AppManageTriggers(id)
|
||||||
| Self::AppDeadLetterManage(id) => Some(id),
|
| Self::AppDeadLetterManage(id)
|
||||||
|
| Self::AppTopicManage(id) => Some(id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,9 +157,10 @@ impl Capability {
|
|||||||
| Self::AppPubsubPublish(_) => Scope::ScriptWrite,
|
| 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(_)
|
||||||
Scope::AppAdmin
|
| Self::AppManageTriggers(_)
|
||||||
}
|
| Self::AppDeadLetterManage(_)
|
||||||
|
| Self::AppTopicManage(_) => Scope::AppAdmin,
|
||||||
Self::AppLogRead(_) => Scope::LogRead,
|
Self::AppLogRead(_) => Scope::LogRead,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,6 +324,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
|||||||
| Capability::AppAdmin(_)
|
| Capability::AppAdmin(_)
|
||||||
| Capability::AppManageTriggers(_)
|
| Capability::AppManageTriggers(_)
|
||||||
| Capability::AppDeadLetterManage(_)
|
| Capability::AppDeadLetterManage(_)
|
||||||
|
| Capability::AppTopicManage(_)
|
||||||
);
|
);
|
||||||
match role {
|
match role {
|
||||||
AppRole::Viewer => in_viewer,
|
AppRole::Viewer => in_viewer,
|
||||||
@@ -659,6 +668,35 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn topic_manage_requires_app_admin() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let app = AppId::new();
|
||||||
|
// Maps to the app:admin scope, not a new one.
|
||||||
|
assert_eq!(
|
||||||
|
Capability::AppTopicManage(app).required_scope(),
|
||||||
|
Scope::AppAdmin
|
||||||
|
);
|
||||||
|
|
||||||
|
// Member with only Editor role cannot manage topics.
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
repo.grant(p.user_id, app, AppRole::Editor).await;
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppTopicManage(app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
);
|
||||||
|
|
||||||
|
// App-admin role can.
|
||||||
|
let admin = principal(InstanceRole::Member);
|
||||||
|
repo.grant(admin.user_id, app, AppRole::AppAdmin).await;
|
||||||
|
assert!(can(&repo, &admin, Capability::AppTopicManage(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capability_app_id_extraction() {
|
fn capability_app_id_extraction() {
|
||||||
let app = AppId::new();
|
let app = AppId::new();
|
||||||
|
|||||||
@@ -633,20 +633,36 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, FilesError::MissingField("content_type")));
|
assert!(matches!(err, FilesError::MissingField("content_type")));
|
||||||
// data
|
// Empty `data` is NO LONGER rejected (v1.1.6 relaxation) — see
|
||||||
let err = files
|
// `empty_file_round_trips`.
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_file_round_trips() {
|
||||||
|
// v1.1.6: a zero-byte blob is a valid stored state (sentinels,
|
||||||
|
// placeholders). Create with empty data, then read it back.
|
||||||
|
let files = svc();
|
||||||
|
let cx = anon_cx(AppId::new());
|
||||||
|
let id = files
|
||||||
.create(
|
.create(
|
||||||
&cx,
|
&cx,
|
||||||
"c",
|
"c",
|
||||||
NewFile {
|
NewFile {
|
||||||
name: "f".into(),
|
name: "empty.bin".into(),
|
||||||
content_type: "text/plain".into(),
|
content_type: "application/octet-stream".into(),
|
||||||
data: vec![],
|
data: vec![],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.expect("empty file create should succeed");
|
||||||
assert!(matches!(err, FilesError::MissingField("data")));
|
let bytes = files.get(&cx, "c", &id.to_string()).await.unwrap();
|
||||||
|
assert_eq!(bytes, Some(Vec::new()));
|
||||||
|
let meta = files
|
||||||
|
.head(&cx, "c", &id.to_string())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("metadata present");
|
||||||
|
assert_eq!(meta.size, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
185
crates/manager-core/src/files_sweep.rs
Normal file
185
crates/manager-core/src/files_sweep.rs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
//! Orphan `*.tmp.*` blob sweeper (v1.1.6, v1.1.5 follow-up).
|
||||||
|
//!
|
||||||
|
//! The files repo writes blobs atomically: it streams into a
|
||||||
|
//! `<id>.tmp.<pid>-<seq>` temp file, fsyncs, then renames to the final
|
||||||
|
//! `<id>` path. A crash between create and rename leaves an orphan temp
|
||||||
|
//! file that is never read and never reclaimed. This sweeper deletes
|
||||||
|
//! those: every `PICLOUD_FILES_ORPHAN_SWEEP_INTERVAL_SEC` (default 6h) it
|
||||||
|
//! walks `<root>/files/` and unlinks any `*.tmp.*` file older than
|
||||||
|
//! `PICLOUD_FILES_ORPHAN_TMP_TTL_SEC` (default 1h).
|
||||||
|
//!
|
||||||
|
//! Deliberately bounded: it does NOT cross-check on-disk files against DB
|
||||||
|
//! rows (the full reconciling sweeper is v1.3+). It only targets the temp
|
||||||
|
//! files, which are unambiguously orphans once past the TTL — no live
|
||||||
|
//! writer keeps one around for an hour.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
|
const ENV_INTERVAL: &str = "PICLOUD_FILES_ORPHAN_SWEEP_INTERVAL_SEC";
|
||||||
|
const ENV_TMP_TTL: &str = "PICLOUD_FILES_ORPHAN_TMP_TTL_SEC";
|
||||||
|
const DEFAULT_INTERVAL_SECS: u64 = 21_600; // 6h
|
||||||
|
const DEFAULT_TMP_TTL_SECS: u64 = 3_600; // 1h
|
||||||
|
|
||||||
|
/// Marker that identifies a temp blob (`<id>.tmp.<pid>-<seq>`). A final
|
||||||
|
/// blob is named just `<id>` (a UUID), so it never contains this.
|
||||||
|
const TMP_MARKER: &str = ".tmp.";
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct SweepStats {
|
||||||
|
pub dirs_walked: u64,
|
||||||
|
pub files_deleted: u64,
|
||||||
|
pub bytes_reclaimed: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the periodic orphan sweep. Spawned at startup alongside the
|
||||||
|
/// cron scheduler and the realtime/cache GC tasks.
|
||||||
|
pub fn spawn_files_orphan_sweep(files_root: PathBuf) {
|
||||||
|
let interval = Duration::from_secs(read_secs(ENV_INTERVAL, DEFAULT_INTERVAL_SECS));
|
||||||
|
let ttl = Duration::from_secs(read_secs(ENV_TMP_TTL, DEFAULT_TMP_TTL_SECS));
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut ticker = tokio::time::interval(interval);
|
||||||
|
ticker.tick().await; // skip the immediate first fire
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
let root = files_root.clone();
|
||||||
|
// Blocking filesystem walk off the async worker.
|
||||||
|
let stats = tokio::task::spawn_blocking(move || sweep_orphan_tmp_files(&root, ttl))
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
tracing::info!(
|
||||||
|
dirs_walked = stats.dirs_walked,
|
||||||
|
files_deleted = stats.files_deleted,
|
||||||
|
bytes_reclaimed = stats.bytes_reclaimed,
|
||||||
|
"files orphan sweep complete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk `<files_root>/files/` and delete `*.tmp.*` files older than
|
||||||
|
/// `ttl`. Missing root is not an error (returns zeroed stats). Pure +
|
||||||
|
/// synchronous so it's unit-testable without a runtime.
|
||||||
|
#[must_use]
|
||||||
|
pub fn sweep_orphan_tmp_files(files_root: &Path, ttl: Duration) -> SweepStats {
|
||||||
|
let mut stats = SweepStats::default();
|
||||||
|
let blobs_dir = files_root.join("files");
|
||||||
|
if !blobs_dir.is_dir() {
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
let now = SystemTime::now();
|
||||||
|
walk(&blobs_dir, ttl, now, &mut stats);
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk(dir: &Path, ttl: Duration, now: SystemTime, stats: &mut SweepStats) {
|
||||||
|
stats.dirs_walked += 1;
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let Ok(ft) = entry.file_type() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let path = entry.path();
|
||||||
|
if ft.is_dir() {
|
||||||
|
walk(&path, ttl, now, stats);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !ft.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !entry.file_name().to_string_lossy().contains(TMP_MARKER) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(meta) = entry.metadata() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let age = meta
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| now.duration_since(m).ok())
|
||||||
|
.unwrap_or(Duration::ZERO);
|
||||||
|
if age >= ttl {
|
||||||
|
let size = meta.len();
|
||||||
|
if std::fs::remove_file(&path).is_ok() {
|
||||||
|
stats.files_deleted += 1;
|
||||||
|
stats.bytes_reclaimed += size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_secs(key: &str, default: u64) -> u64 {
|
||||||
|
match std::env::var(key) {
|
||||||
|
Err(_) => default,
|
||||||
|
Ok(v) => match v.parse::<u64>() {
|
||||||
|
Ok(n) if n > 0 => n,
|
||||||
|
_ => {
|
||||||
|
tracing::warn!(env = key, value = %v, "invalid; using default");
|
||||||
|
default
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
fn tmp_root() -> PathBuf {
|
||||||
|
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("picloud-sweep-test-{}-{n}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(dir.join("files").join("ab")).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn touch(path: &Path) {
|
||||||
|
std::fs::write(path, b"x").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deletes_old_tmp_files() {
|
||||||
|
let root = tmp_root();
|
||||||
|
let tmp = root.join("files/ab/uuid.tmp.123-0");
|
||||||
|
touch(&tmp);
|
||||||
|
// ttl 0 → any tmp file counts as old.
|
||||||
|
let stats = sweep_orphan_tmp_files(&root, Duration::ZERO);
|
||||||
|
assert_eq!(stats.files_deleted, 1);
|
||||||
|
assert!(!tmp.exists());
|
||||||
|
assert!(stats.bytes_reclaimed >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_young_tmp_files() {
|
||||||
|
let root = tmp_root();
|
||||||
|
let tmp = root.join("files/ab/uuid.tmp.123-0");
|
||||||
|
touch(&tmp);
|
||||||
|
// Large TTL → the just-created file is too young to reap.
|
||||||
|
let stats = sweep_orphan_tmp_files(&root, Duration::from_secs(3600));
|
||||||
|
assert_eq!(stats.files_deleted, 0);
|
||||||
|
assert!(tmp.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_non_tmp_files() {
|
||||||
|
let root = tmp_root();
|
||||||
|
let blob = root.join("files/ab/0123456789abcdef");
|
||||||
|
touch(&blob);
|
||||||
|
let stats = sweep_orphan_tmp_files(&root, Duration::ZERO);
|
||||||
|
assert_eq!(stats.files_deleted, 0);
|
||||||
|
assert!(blob.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_root_does_not_panic() {
|
||||||
|
let root = std::env::temp_dir().join("picloud-sweep-nonexistent-xyz");
|
||||||
|
let stats = sweep_orphan_tmp_files(&root, Duration::ZERO);
|
||||||
|
assert_eq!(stats.files_deleted, 0);
|
||||||
|
assert_eq!(stats.dirs_walked, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ pub mod app_domain_repo;
|
|||||||
pub mod app_members_api;
|
pub mod app_members_api;
|
||||||
pub mod app_members_repo;
|
pub mod app_members_repo;
|
||||||
pub mod app_repo;
|
pub mod app_repo;
|
||||||
|
pub mod app_secrets_repo;
|
||||||
pub mod apps_api;
|
pub mod apps_api;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod auth_api;
|
pub mod auth_api;
|
||||||
@@ -33,6 +34,7 @@ pub mod docs_service;
|
|||||||
pub mod files_api;
|
pub mod files_api;
|
||||||
pub mod files_repo;
|
pub mod files_repo;
|
||||||
pub mod files_service;
|
pub mod files_service;
|
||||||
|
pub mod files_sweep;
|
||||||
pub mod gc;
|
pub mod gc;
|
||||||
pub mod http_service;
|
pub mod http_service;
|
||||||
pub mod kv_repo;
|
pub mod kv_repo;
|
||||||
@@ -45,12 +47,15 @@ pub mod outbox_repo;
|
|||||||
pub mod principal_resolver;
|
pub mod principal_resolver;
|
||||||
pub mod pubsub_repo;
|
pub mod pubsub_repo;
|
||||||
pub mod pubsub_service;
|
pub mod pubsub_service;
|
||||||
|
pub mod realtime_authority;
|
||||||
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 ssrf;
|
||||||
|
pub mod topic_repo;
|
||||||
|
pub mod topics_api;
|
||||||
pub mod trigger_config;
|
pub mod trigger_config;
|
||||||
pub mod trigger_repo;
|
pub mod trigger_repo;
|
||||||
pub mod triggers_api;
|
pub mod triggers_api;
|
||||||
@@ -81,6 +86,9 @@ pub use app_members_repo::{
|
|||||||
PostgresAppMembersRepository,
|
PostgresAppMembersRepository,
|
||||||
};
|
};
|
||||||
pub use app_repo::{resolve_app, AppLookup, AppRepository, PostgresAppRepository};
|
pub use app_repo::{resolve_app, AppLookup, AppRepository, PostgresAppRepository};
|
||||||
|
pub use app_secrets_repo::{
|
||||||
|
AppSecretsRepo, AppSecretsRepoError, PostgresAppSecretsRepo, SIGNING_KEY_LEN,
|
||||||
|
};
|
||||||
pub use apps_api::{apps_router, AppsState};
|
pub use apps_api::{apps_router, AppsState};
|
||||||
pub use auth_api::auth_router;
|
pub use auth_api::auth_router;
|
||||||
pub use auth_bootstrap::{
|
pub use auth_bootstrap::{
|
||||||
@@ -104,6 +112,7 @@ pub use docs_service::DocsServiceImpl;
|
|||||||
pub use files_api::{files_admin_router, FilesAdminState};
|
pub use files_api::{files_admin_router, FilesAdminState};
|
||||||
pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo};
|
pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo};
|
||||||
pub use files_service::FilesServiceImpl;
|
pub use files_service::FilesServiceImpl;
|
||||||
|
pub use files_sweep::{spawn_files_orphan_sweep, sweep_orphan_tmp_files, SweepStats};
|
||||||
pub use gc::{spawn_abandoned_gc, spawn_dead_letter_gc};
|
pub use gc::{spawn_abandoned_gc, spawn_dead_letter_gc};
|
||||||
pub use http_service::{HttpConfig, HttpServiceImpl};
|
pub use http_service::{HttpConfig, HttpServiceImpl};
|
||||||
pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo};
|
pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo};
|
||||||
@@ -116,7 +125,8 @@ pub use outbox_repo::{
|
|||||||
};
|
};
|
||||||
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_repo::{PostgresPubsubRepo, PublishCtx, PubsubRepo, PubsubRepoError};
|
||||||
pub use pubsub_service::PubsubServiceImpl;
|
pub use pubsub_service::{PubsubServiceImpl, SubscriberTokenConfig};
|
||||||
|
pub use realtime_authority::RealtimeAuthorityImpl;
|
||||||
pub use repo::{
|
pub use repo::{
|
||||||
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
|
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
|
||||||
RepoResolver, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
RepoResolver, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
||||||
@@ -124,6 +134,8 @@ pub use repo::{
|
|||||||
pub use route_admin::{compile_routes, route_admin_router, RouteAdminState};
|
pub use route_admin::{compile_routes, route_admin_router, RouteAdminState};
|
||||||
pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository};
|
pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository};
|
||||||
pub use sandbox::{CeilingError, SandboxCeiling};
|
pub use sandbox::{CeilingError, SandboxCeiling};
|
||||||
|
pub use topic_repo::{PostgresTopicRepo, Topic, TopicAuthMode, TopicRepo, TopicRepoError};
|
||||||
|
pub use topics_api::{topics_router, TopicsApiError, TopicsState};
|
||||||
pub use trigger_config::{BackoffShape, TriggerConfig};
|
pub use trigger_config::{BackoffShape, TriggerConfig};
|
||||||
pub use trigger_repo::{
|
pub use trigger_repo::{
|
||||||
collection_matches, CreateDeadLetterTrigger, CreateDocsTrigger, CreateFilesTrigger,
|
collection_matches, CreateDeadLetterTrigger, CreateDocsTrigger, CreateFilesTrigger,
|
||||||
|
|||||||
@@ -11,20 +11,106 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_shared::{PubsubError, PubsubService, SdkCallCx, TriggerEvent};
|
use picloud_shared::subscriber_token::{self, TokenClaims};
|
||||||
|
use picloud_shared::{
|
||||||
|
PubsubError, PubsubService, RealtimeBroadcaster, RealtimeEvent, SdkCallCx, TriggerEvent,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app_secrets_repo::AppSecretsRepo;
|
||||||
use crate::authz::{self, AuthzRepo, Capability};
|
use crate::authz::{self, AuthzRepo, Capability};
|
||||||
use crate::pubsub_repo::{PublishCtx, PubsubRepo, PubsubRepoError};
|
use crate::pubsub_repo::{PublishCtx, PubsubRepo, PubsubRepoError};
|
||||||
|
use crate::topic_repo::TopicRepo;
|
||||||
|
|
||||||
|
/// TTL bounds (seconds) for `pubsub::subscriber_token`. Env-overridable
|
||||||
|
/// via `PICLOUD_SUBSCRIBER_TOKEN_TTL_{MIN,MAX,DEFAULT}_SEC`.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct SubscriberTokenConfig {
|
||||||
|
pub min_ttl: i64,
|
||||||
|
pub max_ttl: i64,
|
||||||
|
pub default_ttl: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubscriberTokenConfig {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn conservative() -> Self {
|
||||||
|
Self {
|
||||||
|
min_ttl: 10,
|
||||||
|
max_ttl: 86_400,
|
||||||
|
default_ttl: 3_600,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load from env, falling back to the conservative defaults for any
|
||||||
|
/// missing / invalid value.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let mut c = Self::conservative();
|
||||||
|
load_i64(&mut c.min_ttl, "PICLOUD_SUBSCRIBER_TOKEN_TTL_MIN_SEC");
|
||||||
|
load_i64(&mut c.max_ttl, "PICLOUD_SUBSCRIBER_TOKEN_TTL_MAX_SEC");
|
||||||
|
load_i64(
|
||||||
|
&mut c.default_ttl,
|
||||||
|
"PICLOUD_SUBSCRIBER_TOKEN_TTL_DEFAULT_SEC",
|
||||||
|
);
|
||||||
|
c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SubscriberTokenConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::conservative()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_i64(dst: &mut i64, key: &str) {
|
||||||
|
if let Ok(v) = std::env::var(key) {
|
||||||
|
match v.parse::<i64>() {
|
||||||
|
Ok(n) if n > 0 => *dst = n,
|
||||||
|
_ => tracing::warn!(env = key, value = %v, "ignoring invalid token-ttl value"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct PubsubServiceImpl {
|
pub struct PubsubServiceImpl {
|
||||||
repo: Arc<dyn PubsubRepo>,
|
repo: Arc<dyn PubsubRepo>,
|
||||||
authz: Arc<dyn AuthzRepo>,
|
authz: Arc<dyn AuthzRepo>,
|
||||||
|
// Realtime extras (v1.1.6) — optional so the existing two-arg
|
||||||
|
// constructor (and its unit tests) keep working without them. The
|
||||||
|
// production binary attaches them via `with_realtime`.
|
||||||
|
realtime: Option<Arc<dyn RealtimeBroadcaster>>,
|
||||||
|
topics: Option<Arc<dyn TopicRepo>>,
|
||||||
|
secrets: Option<Arc<dyn AppSecretsRepo>>,
|
||||||
|
token_config: SubscriberTokenConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PubsubServiceImpl {
|
impl PubsubServiceImpl {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(repo: Arc<dyn PubsubRepo>, authz: Arc<dyn AuthzRepo>) -> Self {
|
pub fn new(repo: Arc<dyn PubsubRepo>, authz: Arc<dyn AuthzRepo>) -> Self {
|
||||||
Self { repo, authz }
|
Self {
|
||||||
|
repo,
|
||||||
|
authz,
|
||||||
|
realtime: None,
|
||||||
|
topics: None,
|
||||||
|
secrets: None,
|
||||||
|
token_config: SubscriberTokenConfig::conservative(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach the v1.1.6 realtime surface: the in-process broadcaster
|
||||||
|
/// (publish fan-out to SSE subscribers), the topic registry +
|
||||||
|
/// app-secrets repo (subscriber-token minting), and the TTL config.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_realtime(
|
||||||
|
mut self,
|
||||||
|
broadcaster: Arc<dyn RealtimeBroadcaster>,
|
||||||
|
topics: Arc<dyn TopicRepo>,
|
||||||
|
secrets: Arc<dyn AppSecretsRepo>,
|
||||||
|
token_config: SubscriberTokenConfig,
|
||||||
|
) -> Self {
|
||||||
|
self.realtime = Some(broadcaster);
|
||||||
|
self.topics = Some(topics);
|
||||||
|
self.secrets = Some(secrets);
|
||||||
|
self.token_config = token_config;
|
||||||
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_publish(&self, cx: &SdkCallCx) -> Result<(), PubsubError> {
|
async fn check_publish(&self, cx: &SdkCallCx) -> Result<(), PubsubError> {
|
||||||
@@ -60,12 +146,15 @@ impl PubsubService for PubsubServiceImpl {
|
|||||||
}
|
}
|
||||||
self.check_publish(cx).await?;
|
self.check_publish(cx).await?;
|
||||||
|
|
||||||
// `published_at` is stamped on the manager side so every
|
// `published_at` is stamped once on the manager side so every
|
||||||
// delivery agrees on one instant.
|
// delivery path — durable triggers AND the realtime broadcast —
|
||||||
|
// agrees on one instant. The message is cloned into the trigger
|
||||||
|
// event so the realtime path can reuse the original.
|
||||||
|
let published_at = chrono::Utc::now();
|
||||||
let event = TriggerEvent::Pubsub {
|
let event = TriggerEvent::Pubsub {
|
||||||
topic: topic.to_string(),
|
topic: topic.to_string(),
|
||||||
message,
|
message: message.clone(),
|
||||||
published_at: chrono::Utc::now(),
|
published_at,
|
||||||
};
|
};
|
||||||
let payload = serde_json::to_value(&event)
|
let payload = serde_json::to_value(&event)
|
||||||
.map_err(|e| PubsubError::Rejected(format!("event serialize: {e}")))?;
|
.map_err(|e| PubsubError::Rejected(format!("event serialize: {e}")))?;
|
||||||
@@ -76,12 +165,115 @@ impl PubsubService for PubsubServiceImpl {
|
|||||||
trigger_depth: cx.trigger_depth,
|
trigger_depth: cx.trigger_depth,
|
||||||
root_execution_id: cx.root_execution_id,
|
root_execution_id: cx.root_execution_id,
|
||||||
};
|
};
|
||||||
|
// Order (design notes §8): transactional outbox fan-out + commit
|
||||||
|
// FIRST; only then the best-effort realtime broadcast. If the
|
||||||
|
// fan-out fails, the publish throws and no broadcast happens. If
|
||||||
|
// the broadcast fails after a committed fan-out, trigger
|
||||||
|
// deliveries still happen and only SSE subscribers miss this
|
||||||
|
// event (no replay in v1.1.6).
|
||||||
|
//
|
||||||
// No matching triggers → 0 rows written, publish still succeeds.
|
// No matching triggers → 0 rows written, publish still succeeds.
|
||||||
self.repo
|
self.repo
|
||||||
.fan_out_publish(publish_ctx, topic, payload)
|
.fan_out_publish(publish_ctx, topic, payload)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Non-transactional, best-effort fan-out to in-process SSE
|
||||||
|
// subscribers. Run on a child task so a panicking broadcaster
|
||||||
|
// (or a future cluster-mode resolver fault) becomes a warn log,
|
||||||
|
// never a failed publish — the durable deliveries already
|
||||||
|
// committed above.
|
||||||
|
if let Some(realtime) = self.realtime.clone() {
|
||||||
|
let app_id = cx.app_id;
|
||||||
|
let topic_owned = topic.to_string();
|
||||||
|
let realtime_event = RealtimeEvent {
|
||||||
|
topic: topic_owned.clone(),
|
||||||
|
message,
|
||||||
|
published_at,
|
||||||
|
};
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
realtime.publish(app_id, &topic_owned, realtime_event).await;
|
||||||
|
});
|
||||||
|
if let Err(e) = handle.await {
|
||||||
|
tracing::warn!(error = %e, "realtime broadcast failed; publish unaffected");
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn mint_subscriber_token(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
topics: Vec<String>,
|
||||||
|
ttl_seconds: Option<i64>,
|
||||||
|
) -> Result<String, PubsubError> {
|
||||||
|
// Anonymous (public-HTTP) scripts can't mint — that would bypass
|
||||||
|
// the token-minting authz boundary.
|
||||||
|
let Some(principal) = cx.principal.as_ref() else {
|
||||||
|
return Err(PubsubError::SubscriberToken(
|
||||||
|
"pubsub::subscriber_token: requires an authenticated principal \
|
||||||
|
(a script on a public route cannot mint tokens)"
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
// Minting reuses the existing pub/sub publish capability (no new
|
||||||
|
// scope — the seven-scope commitment holds).
|
||||||
|
authz::require(
|
||||||
|
&*self.authz,
|
||||||
|
principal,
|
||||||
|
Capability::AppPubsubPublish(cx.app_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| PubsubError::Forbidden)?;
|
||||||
|
|
||||||
|
let (Some(topic_repo), Some(secrets)) = (self.topics.as_ref(), self.secrets.as_ref())
|
||||||
|
else {
|
||||||
|
return Err(PubsubError::Unavailable(
|
||||||
|
"subscriber tokens are not wired in".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if topics.is_empty() {
|
||||||
|
return Err(PubsubError::SubscriberToken(
|
||||||
|
"pubsub::subscriber_token: topics list must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ttl = ttl_seconds.unwrap_or(self.token_config.default_ttl);
|
||||||
|
if ttl < self.token_config.min_ttl || ttl > self.token_config.max_ttl {
|
||||||
|
return Err(PubsubError::SubscriberToken(format!(
|
||||||
|
"pubsub::subscriber_token: ttl_seconds must be between {} and {}",
|
||||||
|
self.token_config.min_ttl, self.token_config.max_ttl
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every requested topic must be registered as externally
|
||||||
|
// subscribable in this app — fail fast rather than mint a token
|
||||||
|
// that won't work.
|
||||||
|
for name in &topics {
|
||||||
|
let registered = topic_repo
|
||||||
|
.get(cx.app_id, name)
|
||||||
|
.await
|
||||||
|
.map_err(|e| PubsubError::Unavailable(e.to_string()))?;
|
||||||
|
if !registered.map(|t| t.external_subscribable).unwrap_or(false) {
|
||||||
|
return Err(PubsubError::SubscriberToken(format!(
|
||||||
|
"pubsub::subscriber_token: topic {name} is not externally subscribable"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = secrets
|
||||||
|
.get_or_create_signing_key(cx.app_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| PubsubError::Unavailable(e.to_string()))?;
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let claims = TokenClaims {
|
||||||
|
app_id: cx.app_id,
|
||||||
|
topics,
|
||||||
|
exp: now.saturating_add(ttl),
|
||||||
|
iat: now,
|
||||||
|
};
|
||||||
|
Ok(subscriber_token::sign(&key, &claims))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -317,4 +509,218 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// v1.1.6 realtime broadcast + subscriber-token minting
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
use crate::app_secrets_repo::{AppSecretsRepo, AppSecretsRepoError};
|
||||||
|
use crate::topic_repo::{Topic, TopicAuthMode, TopicRepo, TopicRepoError};
|
||||||
|
use picloud_orchestrator_core::InProcessBroadcaster;
|
||||||
|
use picloud_shared::{RealtimeBroadcaster, RealtimeEvent};
|
||||||
|
|
||||||
|
/// Topic repo fake: returns the configured topics as registered +
|
||||||
|
/// externally-subscribable (unless absent).
|
||||||
|
struct FakeTopicRepo(Vec<String>);
|
||||||
|
#[async_trait]
|
||||||
|
impl TopicRepo for FakeTopicRepo {
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
_: AppId,
|
||||||
|
_: &str,
|
||||||
|
_: bool,
|
||||||
|
_: TopicAuthMode,
|
||||||
|
) -> Result<Topic, TopicRepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn list(&self, _: AppId) -> Result<Vec<Topic>, TopicRepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn get(&self, _: AppId, name: &str) -> Result<Option<Topic>, TopicRepoError> {
|
||||||
|
Ok(self.0.iter().any(|t| t == name).then(|| Topic {
|
||||||
|
name: name.to_string(),
|
||||||
|
external_subscribable: true,
|
||||||
|
auth_mode: TopicAuthMode::Token,
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
updated_at: chrono::Utc::now(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
_: AppId,
|
||||||
|
_: &str,
|
||||||
|
_: Option<bool>,
|
||||||
|
_: Option<TopicAuthMode>,
|
||||||
|
) -> Result<Option<Topic>, TopicRepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn delete(&self, _: AppId, _: &str) -> Result<bool, TopicRepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeSecrets;
|
||||||
|
#[async_trait]
|
||||||
|
impl AppSecretsRepo for FakeSecrets {
|
||||||
|
async fn get_or_create_signing_key(
|
||||||
|
&self,
|
||||||
|
_: AppId,
|
||||||
|
) -> Result<Vec<u8>, AppSecretsRepoError> {
|
||||||
|
Ok(vec![42u8; 32])
|
||||||
|
}
|
||||||
|
async fn signing_key(&self, _: AppId) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
|
||||||
|
Ok(Some(vec![42u8; 32]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcaster that panics on publish — proves a broadcast fault
|
||||||
|
/// can't fail the publish.
|
||||||
|
struct PanicBroadcaster;
|
||||||
|
#[async_trait]
|
||||||
|
impl RealtimeBroadcaster for PanicBroadcaster {
|
||||||
|
async fn subscribe(
|
||||||
|
&self,
|
||||||
|
_: AppId,
|
||||||
|
_: &str,
|
||||||
|
) -> Result<tokio::sync::broadcast::Receiver<RealtimeEvent>, picloud_shared::BroadcasterError>
|
||||||
|
{
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn publish(&self, _: AppId, _: &str, _: RealtimeEvent) {
|
||||||
|
panic!("boom");
|
||||||
|
}
|
||||||
|
async fn drop_topic(&self, _: AppId, _: &str) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn realtime_svc(
|
||||||
|
repo: Arc<dyn PubsubRepo>,
|
||||||
|
broadcaster: Arc<dyn RealtimeBroadcaster>,
|
||||||
|
topics: Vec<String>,
|
||||||
|
) -> PubsubServiceImpl {
|
||||||
|
PubsubServiceImpl::new(repo, Arc::new(EditorAuthzRepo)).with_realtime(
|
||||||
|
broadcaster,
|
||||||
|
Arc::new(FakeTopicRepo(topics)),
|
||||||
|
Arc::new(FakeSecrets),
|
||||||
|
SubscriberTokenConfig::conservative(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn publish_broadcasts_to_in_process_subscribers() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let broadcaster = Arc::new(InProcessBroadcaster::new(16));
|
||||||
|
let mut rx = broadcaster.subscribe(app, "chat").await.unwrap();
|
||||||
|
let svc = realtime_svc(
|
||||||
|
Arc::new(InMemoryPubsubRepo::new(vec![])),
|
||||||
|
broadcaster,
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
svc.publish_durable(&anon_cx(app), "chat", serde_json::json!({ "hi": 1 }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let ev = rx.recv().await.unwrap();
|
||||||
|
assert_eq!(ev.topic, "chat");
|
||||||
|
assert_eq!(ev.message, serde_json::json!({ "hi": 1 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn panicking_broadcaster_does_not_fail_publish() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let svc = realtime_svc(
|
||||||
|
Arc::new(InMemoryPubsubRepo::new(vec![])),
|
||||||
|
Arc::new(PanicBroadcaster),
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
// The outbox fan-out committed; the broadcast panic is swallowed.
|
||||||
|
svc.publish_durable(&anon_cx(app), "chat", serde_json::json!(1))
|
||||||
|
.await
|
||||||
|
.expect("publish must succeed despite broadcast panic");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mint_svc(topics: Vec<String>) -> PubsubServiceImpl {
|
||||||
|
realtime_svc(
|
||||||
|
Arc::new(InMemoryPubsubRepo::new(vec![])),
|
||||||
|
Arc::new(picloud_shared::NoopRealtimeBroadcaster),
|
||||||
|
topics,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mint_returns_token_scoped_to_topics() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let svc = mint_svc(vec!["chat".into(), "notify".into()]);
|
||||||
|
let token = svc
|
||||||
|
.mint_subscriber_token(
|
||||||
|
&member_cx(app),
|
||||||
|
vec!["chat".into(), "notify".into()],
|
||||||
|
Some(120),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// Verify with the fake key; claims carry the topics + expiry.
|
||||||
|
let claims = subscriber_token::verify(&[42u8; 32], &token, chrono::Utc::now().timestamp())
|
||||||
|
.expect("token verifies");
|
||||||
|
assert_eq!(claims.app_id, app);
|
||||||
|
assert!(claims.allows_topic("chat") && claims.allows_topic("notify"));
|
||||||
|
assert!(claims.exp > claims.iat);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mint_anonymous_principal_throws() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let svc = mint_svc(vec!["chat".into()]);
|
||||||
|
let err = svc
|
||||||
|
.mint_subscriber_token(&anon_cx(app), vec!["chat".into()], None)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, PubsubError::SubscriberToken(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mint_empty_topics_throws() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let svc = mint_svc(vec!["chat".into()]);
|
||||||
|
let err = svc
|
||||||
|
.mint_subscriber_token(&member_cx(app), vec![], None)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, PubsubError::SubscriberToken(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mint_ttl_below_min_and_above_max_throw() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let svc = mint_svc(vec!["chat".into()]);
|
||||||
|
for bad in [Some(5), Some(90_000)] {
|
||||||
|
let err = svc
|
||||||
|
.mint_subscriber_token(&member_cx(app), vec!["chat".into()], bad)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, PubsubError::SubscriberToken(_)),
|
||||||
|
"ttl {bad:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mint_unregistered_topic_throws_with_message() {
|
||||||
|
let app = AppId::new();
|
||||||
|
// "chat" registered; "secret" is not.
|
||||||
|
let svc = mint_svc(vec!["chat".into()]);
|
||||||
|
let err = svc
|
||||||
|
.mint_subscriber_token(&member_cx(app), vec!["chat".into(), "secret".into()], None)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
match err {
|
||||||
|
PubsubError::SubscriberToken(msg) => {
|
||||||
|
assert!(
|
||||||
|
msg.contains("topic secret is not externally subscribable"),
|
||||||
|
"got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected SubscriberToken, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
338
crates/manager-core/src/realtime_authority.rs
Normal file
338
crates/manager-core/src/realtime_authority.rs
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
//! `RealtimeAuthorityImpl` — the DB-backed SSE subscribe gate (v1.1.6).
|
||||||
|
//!
|
||||||
|
//! Backs the [`picloud_shared::RealtimeAuthority`] trait the SSE handler
|
||||||
|
//! in orchestrator-core calls. All `topics`-table reads and signing-key
|
||||||
|
//! material stay inside this impl so the data-plane crate never touches
|
||||||
|
//! the key.
|
||||||
|
//!
|
||||||
|
//! Verdict mapping (see [`SubscribeDenied`]):
|
||||||
|
//! * topic missing OR not externally subscribable → `NotFound` (404).
|
||||||
|
//! Both collapse to 404 so the endpoint can't probe internal topics.
|
||||||
|
//! * `auth_mode = 'public'` → allow.
|
||||||
|
//! * `auth_mode = 'token'` → verify the HMAC token (present, signed by
|
||||||
|
//! this app's key, unexpired, scoped to this topic) → allow, else
|
||||||
|
//! `Unauthorized` (401, generic — never says which check failed).
|
||||||
|
//!
|
||||||
|
//! Signing keys never change in v1.1.6 (no rotation API), so a small
|
||||||
|
//! in-memory cache avoids a per-subscribe DB read once an app's key has
|
||||||
|
//! been seen. The cache is purely an optimization — a cold miss reads
|
||||||
|
//! the row.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_shared::{subscriber_token, AppId, RealtimeAuthority, SubscribeDenied};
|
||||||
|
|
||||||
|
use crate::app_secrets_repo::AppSecretsRepo;
|
||||||
|
use crate::topic_repo::{TopicAuthMode, TopicRepo};
|
||||||
|
|
||||||
|
pub struct RealtimeAuthorityImpl {
|
||||||
|
topics: Arc<dyn TopicRepo>,
|
||||||
|
secrets: Arc<dyn AppSecretsRepo>,
|
||||||
|
key_cache: Mutex<HashMap<AppId, Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RealtimeAuthorityImpl {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(topics: Arc<dyn TopicRepo>, secrets: Arc<dyn AppSecretsRepo>) -> Self {
|
||||||
|
Self {
|
||||||
|
topics,
|
||||||
|
secrets,
|
||||||
|
key_cache: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the app's signing key, consulting the cache first. Returns
|
||||||
|
/// `None` when the app has no key (no token ever minted) — which the
|
||||||
|
/// caller maps to `Unauthorized`.
|
||||||
|
async fn signing_key(&self, app_id: AppId) -> Result<Option<Vec<u8>>, SubscribeDenied> {
|
||||||
|
if let Ok(cache) = self.key_cache.lock() {
|
||||||
|
if let Some(k) = cache.get(&app_id) {
|
||||||
|
return Ok(Some(k.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let key = self
|
||||||
|
.secrets
|
||||||
|
.signing_key(app_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SubscribeDenied::Backend(e.to_string()))?;
|
||||||
|
if let Some(ref k) = key {
|
||||||
|
if let Ok(mut cache) = self.key_cache.lock() {
|
||||||
|
cache.insert(app_id, k.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl RealtimeAuthority for RealtimeAuthorityImpl {
|
||||||
|
async fn authorize_subscribe(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
topic: &str,
|
||||||
|
token: Option<&str>,
|
||||||
|
) -> Result<(), SubscribeDenied> {
|
||||||
|
let registered = self
|
||||||
|
.topics
|
||||||
|
.get(app_id, topic)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SubscribeDenied::Backend(e.to_string()))?;
|
||||||
|
|
||||||
|
// Missing topic AND internal-only topic both 404 — don't leak
|
||||||
|
// which internal topics exist.
|
||||||
|
let Some(t) = registered.filter(|t| t.external_subscribable) else {
|
||||||
|
return Err(SubscribeDenied::NotFound);
|
||||||
|
};
|
||||||
|
|
||||||
|
match t.auth_mode {
|
||||||
|
TopicAuthMode::Public => Ok(()),
|
||||||
|
TopicAuthMode::Token => {
|
||||||
|
let token = token.ok_or(SubscribeDenied::Unauthorized)?;
|
||||||
|
let key = self
|
||||||
|
.signing_key(app_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(SubscribeDenied::Unauthorized)?;
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let claims = subscriber_token::verify(&key, token, now)
|
||||||
|
.map_err(|_| SubscribeDenied::Unauthorized)?;
|
||||||
|
// Per-app key already makes a cross-app token fail the
|
||||||
|
// signature check; this is belt-and-suspenders.
|
||||||
|
if claims.app_id != app_id || !claims.allows_topic(topic) {
|
||||||
|
return Err(SubscribeDenied::Unauthorized);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::app_secrets_repo::AppSecretsRepoError;
|
||||||
|
use crate::topic_repo::{Topic, TopicRepoError};
|
||||||
|
use chrono::Utc;
|
||||||
|
use picloud_shared::subscriber_token::{sign, TokenClaims};
|
||||||
|
|
||||||
|
struct FakeTopics(Vec<(AppId, Topic)>);
|
||||||
|
#[async_trait]
|
||||||
|
impl TopicRepo for FakeTopics {
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
_: AppId,
|
||||||
|
_: &str,
|
||||||
|
_: bool,
|
||||||
|
_: TopicAuthMode,
|
||||||
|
) -> Result<Topic, TopicRepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn list(&self, _: AppId) -> Result<Vec<Topic>, TopicRepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn get(&self, app_id: AppId, name: &str) -> Result<Option<Topic>, TopicRepoError> {
|
||||||
|
Ok(self
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.find(|(a, t)| *a == app_id && t.name == name)
|
||||||
|
.map(|(_, t)| t.clone()))
|
||||||
|
}
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
_: AppId,
|
||||||
|
_: &str,
|
||||||
|
_: Option<bool>,
|
||||||
|
_: Option<TopicAuthMode>,
|
||||||
|
) -> Result<Option<Topic>, TopicRepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn delete(&self, _: AppId, _: &str) -> Result<bool, TopicRepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FakeSecrets(AppId, Vec<u8>);
|
||||||
|
#[async_trait]
|
||||||
|
impl AppSecretsRepo for FakeSecrets {
|
||||||
|
async fn get_or_create_signing_key(
|
||||||
|
&self,
|
||||||
|
_: AppId,
|
||||||
|
) -> Result<Vec<u8>, AppSecretsRepoError> {
|
||||||
|
Ok(self.1.clone())
|
||||||
|
}
|
||||||
|
async fn signing_key(&self, app_id: AppId) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
|
||||||
|
Ok((app_id == self.0).then(|| self.1.clone()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn topic(name: &str, external: bool, mode: TopicAuthMode) -> Topic {
|
||||||
|
Topic {
|
||||||
|
name: name.to_string(),
|
||||||
|
external_subscribable: external,
|
||||||
|
auth_mode: mode,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authority(
|
||||||
|
topics: Vec<(AppId, Topic)>,
|
||||||
|
key_app: AppId,
|
||||||
|
key: Vec<u8>,
|
||||||
|
) -> RealtimeAuthorityImpl {
|
||||||
|
RealtimeAuthorityImpl::new(
|
||||||
|
Arc::new(FakeTopics(topics)),
|
||||||
|
Arc::new(FakeSecrets(key_app, key)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_topic_is_not_found() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let a = authority(vec![], app, vec![0u8; 32]);
|
||||||
|
assert_eq!(
|
||||||
|
a.authorize_subscribe(app, "ghost", None).await,
|
||||||
|
Err(SubscribeDenied::NotFound)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn internal_only_topic_is_not_found() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let a = authority(
|
||||||
|
vec![(app, topic("internal", false, TopicAuthMode::Public))],
|
||||||
|
app,
|
||||||
|
vec![0u8; 32],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
a.authorize_subscribe(app, "internal", None).await,
|
||||||
|
Err(SubscribeDenied::NotFound)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn public_topic_allows_without_token() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let a = authority(
|
||||||
|
vec![(app, topic("news", true, TopicAuthMode::Public))],
|
||||||
|
app,
|
||||||
|
vec![0u8; 32],
|
||||||
|
);
|
||||||
|
assert!(a.authorize_subscribe(app, "news", None).await.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn token_topic_without_token_is_unauthorized() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let a = authority(
|
||||||
|
vec![(app, topic("chat", true, TopicAuthMode::Token))],
|
||||||
|
app,
|
||||||
|
vec![7u8; 32],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
a.authorize_subscribe(app, "chat", None).await,
|
||||||
|
Err(SubscribeDenied::Unauthorized)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn token_topic_with_valid_token_allows() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let key = vec![9u8; 32];
|
||||||
|
let a = authority(
|
||||||
|
vec![(app, topic("chat", true, TopicAuthMode::Token))],
|
||||||
|
app,
|
||||||
|
key.clone(),
|
||||||
|
);
|
||||||
|
let token = sign(
|
||||||
|
&key,
|
||||||
|
&TokenClaims {
|
||||||
|
app_id: app,
|
||||||
|
topics: vec!["chat".into()],
|
||||||
|
iat: Utc::now().timestamp(),
|
||||||
|
exp: Utc::now().timestamp() + 60,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(a
|
||||||
|
.authorize_subscribe(app, "chat", Some(&token))
|
||||||
|
.await
|
||||||
|
.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn token_for_other_topic_is_unauthorized() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let key = vec![9u8; 32];
|
||||||
|
let a = authority(
|
||||||
|
vec![(app, topic("chat", true, TopicAuthMode::Token))],
|
||||||
|
app,
|
||||||
|
key.clone(),
|
||||||
|
);
|
||||||
|
let token = sign(
|
||||||
|
&key,
|
||||||
|
&TokenClaims {
|
||||||
|
app_id: app,
|
||||||
|
topics: vec!["other".into()],
|
||||||
|
iat: Utc::now().timestamp(),
|
||||||
|
exp: Utc::now().timestamp() + 60,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
a.authorize_subscribe(app, "chat", Some(&token)).await,
|
||||||
|
Err(SubscribeDenied::Unauthorized)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn expired_token_is_unauthorized() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let key = vec![9u8; 32];
|
||||||
|
let a = authority(
|
||||||
|
vec![(app, topic("chat", true, TopicAuthMode::Token))],
|
||||||
|
app,
|
||||||
|
key.clone(),
|
||||||
|
);
|
||||||
|
let token = sign(
|
||||||
|
&key,
|
||||||
|
&TokenClaims {
|
||||||
|
app_id: app,
|
||||||
|
topics: vec!["chat".into()],
|
||||||
|
iat: Utc::now().timestamp() - 120,
|
||||||
|
exp: Utc::now().timestamp() - 60,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
a.authorize_subscribe(app, "chat", Some(&token)).await,
|
||||||
|
Err(SubscribeDenied::Unauthorized)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn token_signed_by_other_app_key_is_unauthorized() {
|
||||||
|
let app_a = AppId::new();
|
||||||
|
let app_b = AppId::new();
|
||||||
|
let key_a = vec![1u8; 32];
|
||||||
|
let key_b = vec![2u8; 32];
|
||||||
|
// Authority for app B; its key is key_b.
|
||||||
|
let a = authority(
|
||||||
|
vec![(app_b, topic("chat", true, TopicAuthMode::Token))],
|
||||||
|
app_b,
|
||||||
|
key_b,
|
||||||
|
);
|
||||||
|
// Token signed by app A's key, claiming app A.
|
||||||
|
let token = sign(
|
||||||
|
&key_a,
|
||||||
|
&TokenClaims {
|
||||||
|
app_id: app_a,
|
||||||
|
topics: vec!["chat".into()],
|
||||||
|
iat: Utc::now().timestamp(),
|
||||||
|
exp: Utc::now().timestamp() + 60,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
a.authorize_subscribe(app_b, "chat", Some(&token)).await,
|
||||||
|
Err(SubscribeDenied::Unauthorized)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
212
crates/manager-core/src/topic_repo.rs
Normal file
212
crates/manager-core/src/topic_repo.rs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
//! `TopicRepo` — CRUD for the `topics` table (v1.1.6).
|
||||||
|
//!
|
||||||
|
//! This table holds ONLY topics that have been explicitly externalized
|
||||||
|
//! for SSE subscription (design notes §5). Internal-only pub/sub topics
|
||||||
|
//! stay implicit — they never get a row here, and the publish path never
|
||||||
|
//! consults this table. The two readers are the topic admin endpoints
|
||||||
|
//! ([`crate::topics_api`]) and the SSE subscribe authorization
|
||||||
|
//! ([`crate::realtime_authority`]).
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::AppId;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
/// External-subscriber auth gate for a topic. `'public'` + `'token'` in
|
||||||
|
/// v1.1.6; `'session'` (v1.1.8) and `'script'` (v1.2) extend the DB
|
||||||
|
/// CHECK constraint and this enum later.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum TopicAuthMode {
|
||||||
|
Public,
|
||||||
|
Token,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TopicAuthMode {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Public => "public",
|
||||||
|
Self::Token => "token",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_db(s: &str) -> Result<Self, TopicRepoError> {
|
||||||
|
match s {
|
||||||
|
"public" => Ok(Self::Public),
|
||||||
|
"token" => Ok(Self::Token),
|
||||||
|
other => Err(TopicRepoError::Backend(format!(
|
||||||
|
"unknown auth_mode in DB: {other}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A registered, externally-subscribable topic row.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Topic {
|
||||||
|
pub name: String,
|
||||||
|
pub external_subscribable: bool,
|
||||||
|
pub auth_mode: TopicAuthMode,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum TopicRepoError {
|
||||||
|
#[error("a topic named {0:?} already exists in this app")]
|
||||||
|
AlreadyExists(String),
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
#[error("topic backend error: {0}")]
|
||||||
|
Backend(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait TopicRepo: Send + Sync {
|
||||||
|
/// Register a topic. Errors `AlreadyExists` on PK conflict.
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
name: &str,
|
||||||
|
external_subscribable: bool,
|
||||||
|
auth_mode: TopicAuthMode,
|
||||||
|
) -> Result<Topic, TopicRepoError>;
|
||||||
|
|
||||||
|
/// List every registered topic in the app, ordered by name.
|
||||||
|
async fn list(&self, app_id: AppId) -> Result<Vec<Topic>, TopicRepoError>;
|
||||||
|
|
||||||
|
/// Fetch one topic by name, `None` if not registered.
|
||||||
|
async fn get(&self, app_id: AppId, name: &str) -> Result<Option<Topic>, TopicRepoError>;
|
||||||
|
|
||||||
|
/// Update `external_subscribable` and/or `auth_mode` (each `None`
|
||||||
|
/// leaves the column unchanged). `None` return = no such topic.
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
name: &str,
|
||||||
|
external_subscribable: Option<bool>,
|
||||||
|
auth_mode: Option<TopicAuthMode>,
|
||||||
|
) -> Result<Option<Topic>, TopicRepoError>;
|
||||||
|
|
||||||
|
/// Unregister a topic. Returns `true` if a row was removed.
|
||||||
|
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, TopicRepoError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct TopicRow {
|
||||||
|
name: String,
|
||||||
|
external_subscribable: bool,
|
||||||
|
auth_mode: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TopicRow {
|
||||||
|
fn into_topic(self) -> Result<Topic, TopicRepoError> {
|
||||||
|
Ok(Topic {
|
||||||
|
auth_mode: TopicAuthMode::from_db(&self.auth_mode)?,
|
||||||
|
name: self.name,
|
||||||
|
external_subscribable: self.external_subscribable,
|
||||||
|
created_at: self.created_at,
|
||||||
|
updated_at: self.updated_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELECT_COLS: &str = "name, external_subscribable, auth_mode, created_at, updated_at";
|
||||||
|
|
||||||
|
pub struct PostgresTopicRepo {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresTopicRepo {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TopicRepo for PostgresTopicRepo {
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
name: &str,
|
||||||
|
external_subscribable: bool,
|
||||||
|
auth_mode: TopicAuthMode,
|
||||||
|
) -> Result<Topic, TopicRepoError> {
|
||||||
|
let row: Option<TopicRow> = sqlx::query_as(&format!(
|
||||||
|
"INSERT INTO topics (app_id, name, external_subscribable, auth_mode) \
|
||||||
|
VALUES ($1, $2, $3, $4) \
|
||||||
|
ON CONFLICT (app_id, name) DO NOTHING \
|
||||||
|
RETURNING {SELECT_COLS}"
|
||||||
|
))
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(name)
|
||||||
|
.bind(external_subscribable)
|
||||||
|
.bind(auth_mode.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
match row {
|
||||||
|
Some(r) => r.into_topic(),
|
||||||
|
None => Err(TopicRepoError::AlreadyExists(name.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(&self, app_id: AppId) -> Result<Vec<Topic>, TopicRepoError> {
|
||||||
|
let rows: Vec<TopicRow> = sqlx::query_as(&format!(
|
||||||
|
"SELECT {SELECT_COLS} FROM topics WHERE app_id = $1 ORDER BY name"
|
||||||
|
))
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter().map(TopicRow::into_topic).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, app_id: AppId, name: &str) -> Result<Option<Topic>, TopicRepoError> {
|
||||||
|
let row: Option<TopicRow> = sqlx::query_as(&format!(
|
||||||
|
"SELECT {SELECT_COLS} FROM topics WHERE app_id = $1 AND name = $2"
|
||||||
|
))
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(name)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(TopicRow::into_topic).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
name: &str,
|
||||||
|
external_subscribable: Option<bool>,
|
||||||
|
auth_mode: Option<TopicAuthMode>,
|
||||||
|
) -> Result<Option<Topic>, TopicRepoError> {
|
||||||
|
// COALESCE leaves a column untouched when its bind is NULL.
|
||||||
|
let row: Option<TopicRow> = sqlx::query_as(&format!(
|
||||||
|
"UPDATE topics SET \
|
||||||
|
external_subscribable = COALESCE($3, external_subscribable), \
|
||||||
|
auth_mode = COALESCE($4, auth_mode), \
|
||||||
|
updated_at = NOW() \
|
||||||
|
WHERE app_id = $1 AND name = $2 \
|
||||||
|
RETURNING {SELECT_COLS}"
|
||||||
|
))
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(name)
|
||||||
|
.bind(external_subscribable)
|
||||||
|
.bind(auth_mode.map(|m| m.as_str()))
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(TopicRow::into_topic).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, TopicRepoError> {
|
||||||
|
let res = sqlx::query("DELETE FROM topics WHERE app_id = $1 AND name = $2")
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(name)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
629
crates/manager-core/src/topics_api.rs
Normal file
629
crates/manager-core/src/topics_api.rs
Normal file
@@ -0,0 +1,629 @@
|
|||||||
|
//! `/api/v1/admin/apps/{id}/topics*` — topic registration admin
|
||||||
|
//! endpoints (v1.1.6).
|
||||||
|
//!
|
||||||
|
//! These manage the `topics` table: the explicit registry of which
|
||||||
|
//! pub/sub topics are externally subscribable over SSE (design notes
|
||||||
|
//! §5). Internal-only topics never appear here.
|
||||||
|
//!
|
||||||
|
//! * `POST /apps/{id}/topics` — register a topic.
|
||||||
|
//! * `GET /apps/{id}/topics` — list registered topics.
|
||||||
|
//! * `PATCH /apps/{id}/topics/{name}` — flip external/auth_mode.
|
||||||
|
//! * `DELETE /apps/{id}/topics/{name}` — unregister + disconnect.
|
||||||
|
//!
|
||||||
|
//! The PATCH endpoint is deliberately its OWN surface (not folded into a
|
||||||
|
//! generic topic update) so every change to externally-subscribable
|
||||||
|
//! status is a discrete, watchable/auditable API call (§5 commitment).
|
||||||
|
//!
|
||||||
|
//! Create / update / delete are gated by `AppTopicManage` (→ `app:admin`
|
||||||
|
//! scope); list is gated by the existing `AppRead`. DELETE also drops
|
||||||
|
//! the topic's in-process broadcast channel so live SSE subscribers
|
||||||
|
//! disconnect cleanly.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::{get, patch};
|
||||||
|
use axum::{Extension, Router};
|
||||||
|
use picloud_shared::{AppId, Principal, RealtimeBroadcaster};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||||
|
use crate::topic_repo::{Topic, TopicAuthMode, TopicRepo, TopicRepoError};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct TopicsState {
|
||||||
|
pub topics: Arc<dyn TopicRepo>,
|
||||||
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
|
pub broadcaster: Arc<dyn RealtimeBroadcaster>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn topics_router(state: TopicsState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/apps/{app_id}/topics", get(list_topics).post(create_topic))
|
||||||
|
.route(
|
||||||
|
"/apps/{app_id}/topics/{name}",
|
||||||
|
patch(update_topic).delete(delete_topic),
|
||||||
|
)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateTopicRequest {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub external_subscribable: bool,
|
||||||
|
#[serde(default = "default_auth_mode")]
|
||||||
|
pub auth_mode: TopicAuthMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_auth_mode() -> TopicAuthMode {
|
||||||
|
TopicAuthMode::Public
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateTopicRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
pub external_subscribable: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth_mode: Option<TopicAuthMode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Topic names are concrete (external pattern subscription is v1.2), so
|
||||||
|
/// reject empties and `*` wildcards at registration.
|
||||||
|
fn validate_topic_name(name: &str) -> Result<(), TopicsApiError> {
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err(TopicsApiError::Invalid(
|
||||||
|
"topic name must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if name.contains('*') {
|
||||||
|
return Err(TopicsApiError::Invalid(
|
||||||
|
"topic name must be a concrete topic, not a pattern (no '*')".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_topic(
|
||||||
|
State(s): State<TopicsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(app_id): Path<AppId>,
|
||||||
|
Json(input): Json<CreateTopicRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<Topic>), TopicsApiError> {
|
||||||
|
ensure_app_exists(&*s.apps, app_id).await?;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppTopicManage(app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
validate_topic_name(&input.name)?;
|
||||||
|
let topic = s
|
||||||
|
.topics
|
||||||
|
.create(
|
||||||
|
app_id,
|
||||||
|
input.name.trim(),
|
||||||
|
input.external_subscribable,
|
||||||
|
input.auth_mode,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(topic)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
struct ListTopicsResponse {
|
||||||
|
topics: Vec<Topic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_topics(
|
||||||
|
State(s): State<TopicsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(app_id): Path<AppId>,
|
||||||
|
) -> Result<Json<ListTopicsResponse>, TopicsApiError> {
|
||||||
|
ensure_app_exists(&*s.apps, app_id).await?;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppRead(app_id)).await?;
|
||||||
|
let topics = s.topics.list(app_id).await?;
|
||||||
|
Ok(Json(ListTopicsResponse { topics }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_topic(
|
||||||
|
State(s): State<TopicsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((app_id, name)): Path<(AppId, String)>,
|
||||||
|
Json(input): Json<UpdateTopicRequest>,
|
||||||
|
) -> Result<Json<Topic>, TopicsApiError> {
|
||||||
|
ensure_app_exists(&*s.apps, app_id).await?;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppTopicManage(app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let topic = s
|
||||||
|
.topics
|
||||||
|
.update(app_id, &name, input.external_subscribable, input.auth_mode)
|
||||||
|
.await?
|
||||||
|
.ok_or(TopicsApiError::NotFound)?;
|
||||||
|
Ok(Json(topic))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_topic(
|
||||||
|
State(s): State<TopicsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((app_id, name)): Path<(AppId, String)>,
|
||||||
|
) -> Result<StatusCode, TopicsApiError> {
|
||||||
|
ensure_app_exists(&*s.apps, app_id).await?;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppTopicManage(app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !s.topics.delete(app_id, &name).await? {
|
||||||
|
return Err(TopicsApiError::NotFound);
|
||||||
|
}
|
||||||
|
// Disconnect any live SSE subscribers for the now-unregistered topic.
|
||||||
|
s.broadcaster.drop_topic(app_id, &name).await;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_app_exists(apps: &dyn AppRepository, app_id: AppId) -> Result<(), TopicsApiError> {
|
||||||
|
apps.get_by_id(app_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| TopicsApiError::Backend(e.to_string()))?
|
||||||
|
.ok_or(TopicsApiError::AppNotFound)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum TopicsApiError {
|
||||||
|
#[error("app not found")]
|
||||||
|
AppNotFound,
|
||||||
|
#[error("topic not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("{0}")]
|
||||||
|
AlreadyExists(String),
|
||||||
|
#[error("invalid request: {0}")]
|
||||||
|
Invalid(String),
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
#[error("topics backend: {0}")]
|
||||||
|
Backend(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for TopicsApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzError> for TopicsApiError {
|
||||||
|
fn from(e: AuthzError) -> Self {
|
||||||
|
Self::AuthzRepo(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TopicRepoError> for TopicsApiError {
|
||||||
|
fn from(e: TopicRepoError) -> Self {
|
||||||
|
match e {
|
||||||
|
TopicRepoError::AlreadyExists(name) => {
|
||||||
|
Self::AlreadyExists(format!("a topic named {name:?} already exists in this app"))
|
||||||
|
}
|
||||||
|
other => Self::Backend(other.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for TopicsApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, body) = match &self {
|
||||||
|
Self::AppNotFound | Self::NotFound => {
|
||||||
|
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
|
Self::AlreadyExists(_) => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
||||||
|
Self::Invalid(_) => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
json!({ "error": self.to_string() }),
|
||||||
|
),
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "topics admin authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Backend(e) => {
|
||||||
|
tracing::error!(error = %e, "topics admin backend error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
//! In-memory handler tests: capability enforcement, the
|
||||||
|
//! `external_subscribable` default, the flip being its own endpoint,
|
||||||
|
//! cross-app isolation, and DELETE disconnecting subscribers. The
|
||||||
|
//! Postgres repo is exercised by the schema + integration suites.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::repo::ScriptRepositoryError;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::Utc;
|
||||||
|
use picloud_shared::{
|
||||||
|
AdminUserId, App, AppRole, BroadcasterError, InstanceRole, RealtimeEvent, UserId,
|
||||||
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex as StdMutex;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct InMemoryTopicRepo {
|
||||||
|
inner: Mutex<HashMap<(AppId, String), Topic>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TopicRepo for InMemoryTopicRepo {
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
name: &str,
|
||||||
|
external_subscribable: bool,
|
||||||
|
auth_mode: TopicAuthMode,
|
||||||
|
) -> Result<Topic, TopicRepoError> {
|
||||||
|
let mut g = self.inner.lock().await;
|
||||||
|
if g.contains_key(&(app_id, name.to_string())) {
|
||||||
|
return Err(TopicRepoError::AlreadyExists(name.to_string()));
|
||||||
|
}
|
||||||
|
let now = Utc::now();
|
||||||
|
let t = Topic {
|
||||||
|
name: name.to_string(),
|
||||||
|
external_subscribable,
|
||||||
|
auth_mode,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
g.insert((app_id, name.to_string()), t.clone());
|
||||||
|
Ok(t)
|
||||||
|
}
|
||||||
|
async fn list(&self, app_id: AppId) -> Result<Vec<Topic>, TopicRepoError> {
|
||||||
|
let g = self.inner.lock().await;
|
||||||
|
let mut v: Vec<Topic> = g
|
||||||
|
.iter()
|
||||||
|
.filter(|((a, _), _)| *a == app_id)
|
||||||
|
.map(|(_, t)| t.clone())
|
||||||
|
.collect();
|
||||||
|
v.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
async fn get(&self, app_id: AppId, name: &str) -> Result<Option<Topic>, TopicRepoError> {
|
||||||
|
Ok(self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&(app_id, name.to_string()))
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
name: &str,
|
||||||
|
external_subscribable: Option<bool>,
|
||||||
|
auth_mode: Option<TopicAuthMode>,
|
||||||
|
) -> Result<Option<Topic>, TopicRepoError> {
|
||||||
|
let mut g = self.inner.lock().await;
|
||||||
|
let Some(t) = g.get_mut(&(app_id, name.to_string())) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if let Some(e) = external_subscribable {
|
||||||
|
t.external_subscribable = e;
|
||||||
|
}
|
||||||
|
if let Some(m) = auth_mode {
|
||||||
|
t.auth_mode = m;
|
||||||
|
}
|
||||||
|
t.updated_at = Utc::now();
|
||||||
|
Ok(Some(t.clone()))
|
||||||
|
}
|
||||||
|
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, TopicRepoError> {
|
||||||
|
Ok(self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.remove(&(app_id, name.to_string()))
|
||||||
|
.is_some())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InMemoryAppRepo(AppId);
|
||||||
|
#[async_trait]
|
||||||
|
impl AppRepository for InMemoryAppRepo {
|
||||||
|
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn list_for_user(&self, _: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
|
if id != self.0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let now = Utc::now();
|
||||||
|
Ok(Some(App {
|
||||||
|
id,
|
||||||
|
slug: "test".into(),
|
||||||
|
name: "test".into(),
|
||||||
|
description: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
async fn get_by_slug(&self, _: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn get_by_slug_or_history(
|
||||||
|
&self,
|
||||||
|
_: &str,
|
||||||
|
) -> Result<Option<crate::app_repo::AppLookup>, ScriptRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn slug_in_history(&self, _: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
_: &str,
|
||||||
|
_: &str,
|
||||||
|
_: Option<&str>,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn create_with_takeover(
|
||||||
|
&self,
|
||||||
|
_: &str,
|
||||||
|
_: &str,
|
||||||
|
_: Option<&str>,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
_: AppId,
|
||||||
|
_: Option<&str>,
|
||||||
|
_: Option<Option<&str>>,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn rename_slug(
|
||||||
|
&self,
|
||||||
|
_: AppId,
|
||||||
|
_: &str,
|
||||||
|
_: bool,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn delete(&self, _: AppId) -> Result<(), ScriptRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn delete_cascade(&self, _: AppId) -> Result<(), ScriptRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn count_scripts_in_app(&self, _: AppId) -> Result<i64, ScriptRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grants `AppAdmin` only for `granted_app`; denies elsewhere — used
|
||||||
|
/// for the cross-app isolation test.
|
||||||
|
struct PerAppAuthzRepo {
|
||||||
|
granted_app: AppId,
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl AuthzRepo for PerAppAuthzRepo {
|
||||||
|
async fn membership(
|
||||||
|
&self,
|
||||||
|
_: UserId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
Ok((app_id == self.granted_app).then_some(AppRole::AppAdmin))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DenyAuthzRepo;
|
||||||
|
#[async_trait]
|
||||||
|
impl AuthzRepo for DenyAuthzRepo {
|
||||||
|
async fn membership(&self, _: UserId, _: AppId) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RecordingBroadcaster {
|
||||||
|
dropped: StdMutex<Vec<(AppId, String)>>,
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl RealtimeBroadcaster for RecordingBroadcaster {
|
||||||
|
async fn subscribe(
|
||||||
|
&self,
|
||||||
|
_: AppId,
|
||||||
|
_: &str,
|
||||||
|
) -> Result<tokio::sync::broadcast::Receiver<RealtimeEvent>, BroadcasterError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn publish(&self, _: AppId, _: &str, _: RealtimeEvent) {}
|
||||||
|
async fn drop_topic(&self, app_id: AppId, topic: &str) {
|
||||||
|
self.dropped
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((app_id, topic.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn member() -> Principal {
|
||||||
|
Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: InstanceRole::Member,
|
||||||
|
scopes: None,
|
||||||
|
app_binding: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state(app_id: AppId, authz: Arc<dyn AuthzRepo>) -> (TopicsState, Arc<RecordingBroadcaster>) {
|
||||||
|
let bc = Arc::new(RecordingBroadcaster::default());
|
||||||
|
let state = TopicsState {
|
||||||
|
topics: Arc::new(InMemoryTopicRepo::default()),
|
||||||
|
apps: Arc::new(InMemoryAppRepo(app_id)),
|
||||||
|
authz,
|
||||||
|
broadcaster: bc.clone(),
|
||||||
|
};
|
||||||
|
(state, bc)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_defaults_external_subscribable_false() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
||||||
|
let (status, Json(topic)) = create_topic(
|
||||||
|
State(s),
|
||||||
|
Extension(member()),
|
||||||
|
Path(app),
|
||||||
|
Json(CreateTopicRequest {
|
||||||
|
name: "chat".into(),
|
||||||
|
external_subscribable: false,
|
||||||
|
auth_mode: TopicAuthMode::Public,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(status, StatusCode::CREATED);
|
||||||
|
assert!(!topic.external_subscribable);
|
||||||
|
assert_eq!(topic.name, "chat");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn flip_requires_app_admin_role() {
|
||||||
|
let app = AppId::new();
|
||||||
|
// Topic exists; the caller has no role → PATCH is forbidden.
|
||||||
|
let (s, _) = state(app, Arc::new(DenyAuthzRepo));
|
||||||
|
s.topics
|
||||||
|
.create(app, "chat", false, TopicAuthMode::Public)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = update_topic(
|
||||||
|
State(s),
|
||||||
|
Extension(member()),
|
||||||
|
Path((app, "chat".to_string())),
|
||||||
|
Json(UpdateTopicRequest {
|
||||||
|
external_subscribable: Some(true),
|
||||||
|
auth_mode: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, TopicsApiError::Forbidden));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn flip_is_its_own_endpoint_and_toggles_external() {
|
||||||
|
// The PATCH handler is a distinct surface from create; flipping
|
||||||
|
// external_subscribable false→true is a single discrete call.
|
||||||
|
let app = AppId::new();
|
||||||
|
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
||||||
|
s.topics
|
||||||
|
.create(app, "chat", false, TopicAuthMode::Public)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let Json(updated) = update_topic(
|
||||||
|
State(s),
|
||||||
|
Extension(member()),
|
||||||
|
Path((app, "chat".to_string())),
|
||||||
|
Json(UpdateTopicRequest {
|
||||||
|
external_subscribable: Some(true),
|
||||||
|
auth_mode: Some(TopicAuthMode::Token),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(updated.external_subscribable);
|
||||||
|
assert_eq!(updated.auth_mode, TopicAuthMode::Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_disconnects_subscribers() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let (s, bc) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
||||||
|
s.topics
|
||||||
|
.create(app, "chat", true, TopicAuthMode::Public)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let status = delete_topic(
|
||||||
|
State(s),
|
||||||
|
Extension(member()),
|
||||||
|
Path((app, "chat".to_string())),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||||
|
assert_eq!(
|
||||||
|
bc.dropped.lock().unwrap().as_slice(),
|
||||||
|
&[(app, "chat".to_string())]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cross_app_admin_cannot_manage_other_app() {
|
||||||
|
let app_a = AppId::new();
|
||||||
|
let app_b = AppId::new();
|
||||||
|
// Caller is admin of app A only; both apps exist via separate state.
|
||||||
|
let authz = Arc::new(PerAppAuthzRepo { granted_app: app_a });
|
||||||
|
// App-B-scoped state, but the caller only has A's grant.
|
||||||
|
let (s, _) = state(app_b, authz);
|
||||||
|
let err = create_topic(
|
||||||
|
State(s),
|
||||||
|
Extension(member()),
|
||||||
|
Path(app_b),
|
||||||
|
Json(CreateTopicRequest {
|
||||||
|
name: "chat".into(),
|
||||||
|
external_subscribable: true,
|
||||||
|
auth_mode: TopicAuthMode::Public,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, TopicsApiError::Forbidden));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pattern_name_rejected() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
||||||
|
let err = create_topic(
|
||||||
|
State(s),
|
||||||
|
Extension(member()),
|
||||||
|
Path(app),
|
||||||
|
Json(CreateTopicRequest {
|
||||||
|
name: "user.*".into(),
|
||||||
|
external_subscribable: true,
|
||||||
|
auth_mode: TopicAuthMode::Public,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, TopicsApiError::Invalid(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,12 @@ table: app_members
|
|||||||
role: text NOT NULL
|
role: text NOT NULL
|
||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: app_secrets
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
realtime_signing_key: bytea NOT NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
table: app_slug_history
|
table: app_slug_history
|
||||||
slug: text NOT NULL
|
slug: text NOT NULL
|
||||||
current_app_id: uuid NOT NULL
|
current_app_id: uuid NOT NULL
|
||||||
@@ -211,6 +217,14 @@ table: scripts
|
|||||||
app_id: uuid NOT NULL
|
app_id: uuid NOT NULL
|
||||||
kind: text NOT NULL default='endpoint'::text
|
kind: text NOT NULL default='endpoint'::text
|
||||||
|
|
||||||
|
table: topics
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
name: text NOT NULL
|
||||||
|
external_subscribable: boolean NOT NULL default=false
|
||||||
|
auth_mode: text NOT NULL default='public'::text
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
table: triggers
|
table: triggers
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
app_id: uuid NOT NULL
|
app_id: uuid NOT NULL
|
||||||
@@ -256,6 +270,9 @@ indexes on app_members:
|
|||||||
app_members_pkey: public.app_members USING btree (app_id, user_id)
|
app_members_pkey: public.app_members USING btree (app_id, user_id)
|
||||||
app_members_user_id_idx: public.app_members USING btree (user_id)
|
app_members_user_id_idx: public.app_members USING btree (user_id)
|
||||||
|
|
||||||
|
indexes on app_secrets:
|
||||||
|
app_secrets_pkey: public.app_secrets USING btree (app_id)
|
||||||
|
|
||||||
indexes on app_slug_history:
|
indexes on app_slug_history:
|
||||||
app_slug_history_pkey: public.app_slug_history USING btree (slug)
|
app_slug_history_pkey: public.app_slug_history USING btree (slug)
|
||||||
|
|
||||||
@@ -328,6 +345,9 @@ indexes on scripts:
|
|||||||
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 topics:
|
||||||
|
topics_pkey: public.topics USING btree (app_id, name)
|
||||||
|
|
||||||
indexes on triggers:
|
indexes on triggers:
|
||||||
idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE (enabled = true)
|
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))
|
idx_triggers_app_pubsub_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (kind = 'pubsub'::text))
|
||||||
@@ -366,6 +386,10 @@ constraints on app_members:
|
|||||||
[FOREIGN KEY] app_members_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
[FOREIGN KEY] app_members_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] app_members_pkey: PRIMARY KEY (app_id, user_id)
|
[PRIMARY KEY] app_members_pkey: PRIMARY KEY (app_id, user_id)
|
||||||
|
|
||||||
|
constraints on app_secrets:
|
||||||
|
[FOREIGN KEY] app_secrets_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] app_secrets_pkey: PRIMARY KEY (app_id)
|
||||||
|
|
||||||
constraints on app_slug_history:
|
constraints on app_slug_history:
|
||||||
[FOREIGN KEY] app_slug_history_current_app_id_fkey: FOREIGN KEY (current_app_id) REFERENCES apps(id) ON DELETE CASCADE
|
[FOREIGN KEY] app_slug_history_current_app_id_fkey: FOREIGN KEY (current_app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] app_slug_history_pkey: PRIMARY KEY (slug)
|
[PRIMARY KEY] app_slug_history_pkey: PRIMARY KEY (slug)
|
||||||
@@ -448,6 +472,11 @@ constraints on scripts:
|
|||||||
[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 topics:
|
||||||
|
[CHECK] topics_auth_mode_check: CHECK ((auth_mode = ANY (ARRAY['public'::text, 'token'::text])))
|
||||||
|
[FOREIGN KEY] topics_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] topics_pkey: PRIMARY KEY (app_id, name)
|
||||||
|
|
||||||
constraints on triggers:
|
constraints on triggers:
|
||||||
[CHECK] triggers_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
|
[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_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'dead_letter'::text, 'docs'::text, 'cron'::text, 'files'::text, 'pubsub'::text])))
|
||||||
@@ -478,3 +507,5 @@ constraints on triggers:
|
|||||||
0018: files
|
0018: files
|
||||||
0019: files triggers
|
0019: files triggers
|
||||||
0020: pubsub triggers
|
0020: pubsub triggers
|
||||||
|
0021: topics
|
||||||
|
0022: app secrets
|
||||||
|
|||||||
@@ -23,8 +23,13 @@ chrono.workspace = true
|
|||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
rhai.workspace = true
|
rhai.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
tokio-stream.workspace = true
|
||||||
urlencoding.workspace = true
|
urlencoding.workspace = true
|
||||||
|
|
||||||
# v1.1.3 — top-level script AST cache lives in orchestrator-core's
|
# v1.1.3 — top-level script AST cache lives in orchestrator-core's
|
||||||
# LocalExecutorClient; key is ScriptId, value is `(updated_at, Arc<rhai::AST>)`.
|
# LocalExecutorClient; key is ScriptId, value is `(updated_at, Arc<rhai::AST>)`.
|
||||||
lru.workspace = true
|
lru.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# `ServiceExt::oneshot` for driving the SSE router in unit tests.
|
||||||
|
tower.workspace = true
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ pub mod api;
|
|||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod gate;
|
pub mod gate;
|
||||||
pub mod inbox;
|
pub mod inbox;
|
||||||
|
pub mod realtime;
|
||||||
|
pub mod realtime_api;
|
||||||
pub mod resolver;
|
pub mod resolver;
|
||||||
pub mod routing;
|
pub mod routing;
|
||||||
|
|
||||||
@@ -19,4 +21,8 @@ pub use api::{data_plane_router, user_routes_router, DataPlaneState};
|
|||||||
pub use client::{ExecutorClient, LocalExecutorClient, RemoteExecutorClient, ScriptIdentity};
|
pub use client::{ExecutorClient, LocalExecutorClient, RemoteExecutorClient, ScriptIdentity};
|
||||||
pub use gate::{AcquireError, ExecutionGate};
|
pub use gate::{AcquireError, ExecutionGate};
|
||||||
pub use inbox::InboxRegistry;
|
pub use inbox::InboxRegistry;
|
||||||
|
pub use realtime::{spawn_realtime_gc, InProcessBroadcaster, DEFAULT_BROADCAST_CAPACITY};
|
||||||
|
pub use realtime_api::{
|
||||||
|
heartbeat_secs_from_env, realtime_router, RealtimeState, DEFAULT_HEARTBEAT_SECS,
|
||||||
|
};
|
||||||
pub use resolver::{ResolverError, ScriptResolver};
|
pub use resolver::{ResolverError, ScriptResolver};
|
||||||
|
|||||||
242
crates/orchestrator-core/src/realtime.rs
Normal file
242
crates/orchestrator-core/src/realtime.rs
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
//! In-process `RealtimeBroadcaster` — the SSE fan-out registry (v1.1.6).
|
||||||
|
//!
|
||||||
|
//! Sibling of [`crate::inbox::InboxRegistry`], but multi-receiver and
|
||||||
|
//! repeated-event: a `Mutex<HashMap<(AppId, topic), broadcast::Sender>>`
|
||||||
|
//! over `tokio::sync::broadcast` instead of a oneshot map. The publish
|
||||||
|
//! side ([`PubsubServiceImpl`]) and the SSE subscribe side both hold one
|
||||||
|
//! shared `Arc<InProcessBroadcaster>`.
|
||||||
|
//!
|
||||||
|
//! Delivery is best-effort: each channel has a bounded buffer
|
||||||
|
//! (`PICLOUD_REALTIME_BROADCAST_CAPACITY`, default 64); a slow consumer
|
||||||
|
//! that falls behind sees the oldest events dropped (standard
|
||||||
|
//! `broadcast` lag semantics — the receiver gets `RecvError::Lagged`).
|
||||||
|
//! SSE's transport-layer auto-reconnect is the recovery path; there's no
|
||||||
|
//! server-side replay in v1.1.6.
|
||||||
|
//!
|
||||||
|
//! Channels are created lazily on first subscribe. A periodic GC task
|
||||||
|
//! ([`spawn_realtime_gc`]) drops senders whose receiver count has fallen
|
||||||
|
//! to zero so one-shot subscribers don't grow the map unboundedly.
|
||||||
|
//!
|
||||||
|
//! Cluster mode (v1.3+) swaps this for a Postgres `LISTEN/NOTIFY`-backed
|
||||||
|
//! resolver behind the same `RealtimeBroadcaster` trait.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_shared::{AppId, BroadcasterError, RealtimeBroadcaster, RealtimeEvent};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
/// Default per-channel broadcast buffer depth.
|
||||||
|
pub const DEFAULT_BROADCAST_CAPACITY: usize = 64;
|
||||||
|
const ENV_CAPACITY: &str = "PICLOUD_REALTIME_BROADCAST_CAPACITY";
|
||||||
|
|
||||||
|
/// Default GC sweep interval for empty channels.
|
||||||
|
pub const DEFAULT_GC_INTERVAL_SECS: u64 = 60;
|
||||||
|
|
||||||
|
pub struct InProcessBroadcaster {
|
||||||
|
inner: Mutex<HashMap<(AppId, String), broadcast::Sender<RealtimeEvent>>>,
|
||||||
|
capacity: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InProcessBroadcaster {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(capacity: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Mutex::new(HashMap::new()),
|
||||||
|
capacity: capacity.max(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build from `PICLOUD_REALTIME_BROADCAST_CAPACITY` (default 64).
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let capacity = match std::env::var(ENV_CAPACITY) {
|
||||||
|
Err(_) => DEFAULT_BROADCAST_CAPACITY,
|
||||||
|
Ok(v) => match v.parse::<usize>() {
|
||||||
|
Ok(n) if n > 0 => n,
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::warn!(env = ENV_CAPACITY, value = %v, "must be > 0; using default");
|
||||||
|
DEFAULT_BROADCAST_CAPACITY
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(env = ENV_CAPACITY, value = %v, error = %e, "invalid; using default");
|
||||||
|
DEFAULT_BROADCAST_CAPACITY
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Self::new(capacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of live channels in the map (test/observability helper).
|
||||||
|
#[must_use]
|
||||||
|
pub fn channel_count(&self) -> usize {
|
||||||
|
self.inner.lock().map(|g| g.len()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop senders with zero receivers. Returns how many were removed.
|
||||||
|
/// Called periodically by [`spawn_realtime_gc`].
|
||||||
|
pub fn gc(&self) -> usize {
|
||||||
|
let Ok(mut g) = self.inner.lock() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let before = g.len();
|
||||||
|
g.retain(|_, tx| tx.receiver_count() > 0);
|
||||||
|
before - g.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl RealtimeBroadcaster for InProcessBroadcaster {
|
||||||
|
async fn subscribe(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
topic: &str,
|
||||||
|
) -> Result<broadcast::Receiver<RealtimeEvent>, BroadcasterError> {
|
||||||
|
let mut g = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| BroadcasterError::Unavailable("broadcaster map poisoned".into()))?;
|
||||||
|
let tx = g
|
||||||
|
.entry((app_id, topic.to_string()))
|
||||||
|
.or_insert_with(|| broadcast::channel(self.capacity).0);
|
||||||
|
Ok(tx.subscribe())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn publish(&self, app_id: AppId, topic: &str, event: RealtimeEvent) {
|
||||||
|
let Ok(g) = self.inner.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Only fan out to an existing channel: a topic with no live
|
||||||
|
// subscribers has no sender (publish never creates one). `send`
|
||||||
|
// returns Err iff every receiver has dropped — a benign no-op.
|
||||||
|
if let Some(tx) = g.get(&(app_id, topic.to_string())) {
|
||||||
|
let _ = tx.send(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn drop_topic(&self, app_id: AppId, topic: &str) {
|
||||||
|
if let Ok(mut g) = self.inner.lock() {
|
||||||
|
// Removing the sender closes the channel; existing receivers
|
||||||
|
// observe `RecvError::Closed` and disconnect cleanly.
|
||||||
|
g.remove(&(app_id, topic.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the background GC sweep that drops empty channels every
|
||||||
|
/// `interval_secs` (default [`DEFAULT_GC_INTERVAL_SECS`]). Spawned at
|
||||||
|
/// startup alongside the other housekeeping tasks.
|
||||||
|
pub fn spawn_realtime_gc(broadcaster: Arc<InProcessBroadcaster>, interval_secs: u64) {
|
||||||
|
let period = Duration::from_secs(interval_secs.max(1));
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut ticker = tokio::time::interval(period);
|
||||||
|
ticker.tick().await; // skip the immediate first fire
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
let removed = broadcaster.gc();
|
||||||
|
if removed > 0 {
|
||||||
|
tracing::debug!(removed, "realtime broadcaster GC dropped empty channels");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn event(topic: &str, n: i64) -> RealtimeEvent {
|
||||||
|
RealtimeEvent {
|
||||||
|
topic: topic.to_string(),
|
||||||
|
message: json!({ "n": n }),
|
||||||
|
published_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn multiple_subscribers_each_receive_each_event() {
|
||||||
|
let b = InProcessBroadcaster::new(16);
|
||||||
|
let app = AppId::new();
|
||||||
|
let mut rx1 = b.subscribe(app, "chat").await.unwrap();
|
||||||
|
let mut rx2 = b.subscribe(app, "chat").await.unwrap();
|
||||||
|
|
||||||
|
b.publish(app, "chat", event("chat", 1)).await;
|
||||||
|
b.publish(app, "chat", event("chat", 2)).await;
|
||||||
|
|
||||||
|
for rx in [&mut rx1, &mut rx2] {
|
||||||
|
assert_eq!(rx.recv().await.unwrap().message, json!({ "n": 1 }));
|
||||||
|
assert_eq!(rx.recv().await.unwrap().message, json!({ "n": 2 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dropped_subscriber_does_not_leak_after_gc() {
|
||||||
|
let b = InProcessBroadcaster::new(16);
|
||||||
|
let app = AppId::new();
|
||||||
|
let rx = b.subscribe(app, "t").await.unwrap();
|
||||||
|
assert_eq!(b.channel_count(), 1);
|
||||||
|
drop(rx);
|
||||||
|
// GC reclaims the now-empty channel.
|
||||||
|
assert_eq!(b.gc(), 1);
|
||||||
|
assert_eq!(b.channel_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn drop_topic_disconnects_existing_subscribers() {
|
||||||
|
let b = InProcessBroadcaster::new(16);
|
||||||
|
let app = AppId::new();
|
||||||
|
let mut rx = b.subscribe(app, "t").await.unwrap();
|
||||||
|
b.drop_topic(app, "t").await;
|
||||||
|
// Sender gone → receiver observes a closed channel.
|
||||||
|
assert!(rx.recv().await.is_err());
|
||||||
|
assert_eq!(b.channel_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn slow_consumer_loses_oldest_events() {
|
||||||
|
// Capacity 2: a consumer that never drains sees the oldest
|
||||||
|
// events dropped (broadcast Lagged semantics).
|
||||||
|
let b = InProcessBroadcaster::new(2);
|
||||||
|
let app = AppId::new();
|
||||||
|
let mut rx = b.subscribe(app, "t").await.unwrap();
|
||||||
|
for i in 0..5 {
|
||||||
|
b.publish(app, "t", event("t", i)).await;
|
||||||
|
}
|
||||||
|
// First recv reports the lag rather than event 0.
|
||||||
|
let first = rx.recv().await;
|
||||||
|
assert!(
|
||||||
|
matches!(first, Err(broadcast::error::RecvError::Lagged(_))),
|
||||||
|
"expected Lagged, got {first:?}"
|
||||||
|
);
|
||||||
|
// Subsequent recvs return the most recent buffered events.
|
||||||
|
let next = rx.recv().await.unwrap();
|
||||||
|
assert_eq!(next.message, json!({ "n": 3 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cross_app_isolation() {
|
||||||
|
let b = InProcessBroadcaster::new(16);
|
||||||
|
let app_a = AppId::new();
|
||||||
|
let app_b = AppId::new();
|
||||||
|
let mut rx_a = b.subscribe(app_a, "shared").await.unwrap();
|
||||||
|
let mut rx_b = b.subscribe(app_b, "shared").await.unwrap();
|
||||||
|
|
||||||
|
b.publish(app_a, "shared", event("shared", 1)).await;
|
||||||
|
// App B's subscriber must not see app A's publish.
|
||||||
|
assert_eq!(rx_a.recv().await.unwrap().message, json!({ "n": 1 }));
|
||||||
|
assert!(rx_b.try_recv().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn publish_with_no_subscribers_is_noop() {
|
||||||
|
let b = InProcessBroadcaster::new(16);
|
||||||
|
let app = AppId::new();
|
||||||
|
// No subscriber → no sender created → no panic, nothing fanned out.
|
||||||
|
b.publish(app, "ghost", event("ghost", 1)).await;
|
||||||
|
assert_eq!(b.channel_count(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
408
crates/orchestrator-core/src/realtime_api.rs
Normal file
408
crates/orchestrator-core/src/realtime_api.rs
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
//! SSE realtime endpoint — `GET /realtime/topics/{topic}` (v1.1.6).
|
||||||
|
//!
|
||||||
|
//! This is a data-plane surface, deliberately NOT under `/api/`
|
||||||
|
//! (realtime is its own versioning surface per the path scheme). It is
|
||||||
|
//! merged at the router root by the `picloud` binary alongside
|
||||||
|
//! `/healthz`, `/version`, and the user-route fallback.
|
||||||
|
//!
|
||||||
|
//! Handshake:
|
||||||
|
//! 1. Resolve `Host` → `app_id` (two-phase dispatch). No app → 404.
|
||||||
|
//! 2. Extract the token from `Authorization: Bearer <t>` OR `?token=<t>`
|
||||||
|
//! (EventSource can't set custom headers, so the query form is the
|
||||||
|
//! browser-compatible path).
|
||||||
|
//! 3. Ask the injected [`RealtimeAuthority`]: missing/internal topic →
|
||||||
|
//! 404, bad/absent token on a token-gated topic → 401, otherwise OK.
|
||||||
|
//! 4. Acquire a `broadcast::Receiver` and stream events as SSE until
|
||||||
|
//! the client disconnects (dropping the receiver — the broadcaster
|
||||||
|
//! cleans up on its own).
|
||||||
|
//!
|
||||||
|
//! Heartbeats (`:` comment lines) keep idle proxies from closing the
|
||||||
|
//! connection; interval is `PICLOUD_REALTIME_HEARTBEAT_SEC` (default 30).
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::Router;
|
||||||
|
use picloud_shared::{RealtimeAuthority, RealtimeBroadcaster, SubscribeDenied};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio_stream::wrappers::BroadcastStream;
|
||||||
|
use tokio_stream::{Stream, StreamExt};
|
||||||
|
|
||||||
|
use crate::routing::AppDomainTable;
|
||||||
|
|
||||||
|
/// Default heartbeat interval (seconds) for idle SSE connections.
|
||||||
|
pub const DEFAULT_HEARTBEAT_SECS: u64 = 30;
|
||||||
|
const ENV_HEARTBEAT: &str = "PICLOUD_REALTIME_HEARTBEAT_SEC";
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RealtimeState {
|
||||||
|
/// Host → app_id resolver (shared with the rest of the data plane).
|
||||||
|
pub app_domains: Arc<AppDomainTable>,
|
||||||
|
pub broadcaster: Arc<dyn RealtimeBroadcaster>,
|
||||||
|
pub authority: Arc<dyn RealtimeAuthority>,
|
||||||
|
pub heartbeat: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RealtimeState {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
app_domains: Arc<AppDomainTable>,
|
||||||
|
broadcaster: Arc<dyn RealtimeBroadcaster>,
|
||||||
|
authority: Arc<dyn RealtimeAuthority>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
app_domains,
|
||||||
|
broadcaster,
|
||||||
|
authority,
|
||||||
|
heartbeat: Duration::from_secs(heartbeat_secs_from_env()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `PICLOUD_REALTIME_HEARTBEAT_SEC` (default 30, must be > 0).
|
||||||
|
#[must_use]
|
||||||
|
pub fn heartbeat_secs_from_env() -> u64 {
|
||||||
|
match std::env::var(ENV_HEARTBEAT) {
|
||||||
|
Err(_) => DEFAULT_HEARTBEAT_SECS,
|
||||||
|
Ok(v) => match v.parse::<u64>() {
|
||||||
|
Ok(n) if n > 0 => n,
|
||||||
|
_ => {
|
||||||
|
tracing::warn!(env = ENV_HEARTBEAT, value = %v, "invalid; using default");
|
||||||
|
DEFAULT_HEARTBEAT_SECS
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Router for the realtime SSE surface. Merged at the router root.
|
||||||
|
#[must_use]
|
||||||
|
pub fn realtime_router(state: RealtimeState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/realtime/topics/{topic}", get(sse_topic))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TokenQuery {
|
||||||
|
token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sse_topic(
|
||||||
|
State(state): State<RealtimeState>,
|
||||||
|
Path(topic): Path<String>,
|
||||||
|
Query(q): Query<TokenQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Response {
|
||||||
|
// 1. Host → app.
|
||||||
|
let host = headers
|
||||||
|
.get("host")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
let Some(app_id) = state.app_domains.resolve_app(host) else {
|
||||||
|
return not_found("no app claims this host");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Token: Authorization: Bearer <t> takes precedence, else ?token=.
|
||||||
|
let token = bearer_token(&headers).or(q.token);
|
||||||
|
|
||||||
|
// 3. Authorize.
|
||||||
|
match state
|
||||||
|
.authority
|
||||||
|
.authorize_subscribe(app_id, &topic, token.as_deref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(SubscribeDenied::NotFound) => return not_found("topic not found"),
|
||||||
|
Err(SubscribeDenied::Unauthorized) => return unauthorized(),
|
||||||
|
Err(SubscribeDenied::Backend(e)) => {
|
||||||
|
tracing::error!(error = %e, "realtime authority backend error");
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
axum::Json(serde_json::json!({ "error": "internal error" })),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Subscribe + stream.
|
||||||
|
let rx = match state.broadcaster.subscribe(app_id, &topic).await {
|
||||||
|
Ok(rx) => rx,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "failed to acquire realtime subscription");
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
axum::Json(serde_json::json!({ "error": "internal error" })),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let stream = event_stream(rx);
|
||||||
|
let sse =
|
||||||
|
Sse::new(stream).keep_alive(KeepAlive::new().interval(state.heartbeat).text("heartbeat"));
|
||||||
|
|
||||||
|
// Sse sets Content-Type: text/event-stream + Cache-Control: no-cache.
|
||||||
|
// Add X-Accel-Buffering: no so an intermediate nginx doesn't buffer
|
||||||
|
// the stream (ignored by other proxies). Connection management is
|
||||||
|
// hyper's concern (and is hop-by-hop on HTTP/1.1, server-managed on
|
||||||
|
// HTTP/2), so we don't set Connection ourselves.
|
||||||
|
let mut resp = sse.into_response();
|
||||||
|
resp.headers_mut().insert(
|
||||||
|
"X-Accel-Buffering",
|
||||||
|
axum::http::HeaderValue::from_static("no"),
|
||||||
|
);
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map the broadcast receiver into a stream of SSE events. Lagged
|
||||||
|
/// notifications (slow consumer) are skipped; a closed channel
|
||||||
|
/// (`drop_topic`, or all senders gone) ends the stream and the SSE
|
||||||
|
/// connection closes cleanly.
|
||||||
|
fn event_stream(
|
||||||
|
rx: tokio::sync::broadcast::Receiver<picloud_shared::RealtimeEvent>,
|
||||||
|
) -> impl Stream<Item = Result<Event, std::convert::Infallible>> {
|
||||||
|
BroadcastStream::new(rx).filter_map(|item| {
|
||||||
|
let ev = item.ok()?; // drop Lagged errors
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"topic": ev.topic,
|
||||||
|
"message": ev.message,
|
||||||
|
"published_at": ev.published_at.to_rfc3339(),
|
||||||
|
});
|
||||||
|
Some(Ok(Event::default().data(payload.to_string())))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bearer_token(headers: &HeaderMap) -> Option<String> {
|
||||||
|
let raw = headers
|
||||||
|
.get(axum::http::header::AUTHORIZATION)?
|
||||||
|
.to_str()
|
||||||
|
.ok()?;
|
||||||
|
raw.strip_prefix("Bearer ")
|
||||||
|
.map(|t| t.trim().to_string())
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn not_found(msg: &str) -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
axum::Json(serde_json::json!({ "error": msg })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unauthorized() -> Response {
|
||||||
|
// Generic — never leaks which check failed.
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
axum::Json(serde_json::json!({ "error": "unauthorized" })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::realtime::InProcessBroadcaster;
|
||||||
|
use crate::routing::AppDomainTable;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::Request;
|
||||||
|
use picloud_shared::{AppId, RealtimeEvent};
|
||||||
|
use tower::ServiceExt; // oneshot
|
||||||
|
|
||||||
|
/// Authority stub returning a fixed verdict.
|
||||||
|
struct StubAuthority(Result<(), SubscribeDenied>);
|
||||||
|
#[async_trait]
|
||||||
|
impl RealtimeAuthority for StubAuthority {
|
||||||
|
async fn authorize_subscribe(
|
||||||
|
&self,
|
||||||
|
_: AppId,
|
||||||
|
_: &str,
|
||||||
|
_: Option<&str>,
|
||||||
|
) -> Result<(), SubscribeDenied> {
|
||||||
|
self.0.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App-domain table that maps a fixed host to a fixed app.
|
||||||
|
fn domains(host: &str, app: AppId) -> Arc<AppDomainTable> {
|
||||||
|
use crate::routing::{parse_app_domain, CompiledAppDomain};
|
||||||
|
let d = parse_app_domain(host).unwrap();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![CompiledAppDomain {
|
||||||
|
app_id: app,
|
||||||
|
pattern: d.pattern,
|
||||||
|
shape_key: d.shape_key,
|
||||||
|
}]);
|
||||||
|
Arc::new(table)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state(
|
||||||
|
app: AppId,
|
||||||
|
host: &str,
|
||||||
|
verdict: Result<(), SubscribeDenied>,
|
||||||
|
broadcaster: Arc<dyn RealtimeBroadcaster>,
|
||||||
|
) -> RealtimeState {
|
||||||
|
RealtimeState {
|
||||||
|
app_domains: domains(host, app),
|
||||||
|
broadcaster,
|
||||||
|
authority: Arc::new(StubAuthority(verdict)),
|
||||||
|
heartbeat: Duration::from_millis(100),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_status(state: RealtimeState, host: &str, topic: &str) -> StatusCode {
|
||||||
|
let app = realtime_router(state);
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri(format!("/realtime/topics/{topic}"))
|
||||||
|
.header("host", host)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
app.oneshot(req).await.unwrap().status()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_host_is_404() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let st = state(
|
||||||
|
app,
|
||||||
|
"app.example.com",
|
||||||
|
Ok(()),
|
||||||
|
Arc::new(InProcessBroadcaster::new(8)),
|
||||||
|
);
|
||||||
|
// Request a different host → no app claims it.
|
||||||
|
assert_eq!(
|
||||||
|
get_status(st, "other.example.com", "chat").await,
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn not_found_topic_is_404() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let st = state(
|
||||||
|
app,
|
||||||
|
"app.example.com",
|
||||||
|
Err(SubscribeDenied::NotFound),
|
||||||
|
Arc::new(InProcessBroadcaster::new(8)),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_status(st, "app.example.com", "ghost").await,
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unauthorized_token_is_401() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let st = state(
|
||||||
|
app,
|
||||||
|
"app.example.com",
|
||||||
|
Err(SubscribeDenied::Unauthorized),
|
||||||
|
Arc::new(InProcessBroadcaster::new(8)),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_status(st, "app.example.com", "chat").await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn public_topic_returns_event_stream() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let st = state(
|
||||||
|
app,
|
||||||
|
"app.example.com",
|
||||||
|
Ok(()),
|
||||||
|
Arc::new(InProcessBroadcaster::new(8)),
|
||||||
|
);
|
||||||
|
let appr = realtime_router(st);
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/realtime/topics/chat")
|
||||||
|
.header("host", "app.example.com")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = appr.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let ct = resp
|
||||||
|
.headers()
|
||||||
|
.get(axum::http::header::CONTENT_TYPE)
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap();
|
||||||
|
assert!(ct.starts_with("text/event-stream"));
|
||||||
|
assert_eq!(resp.headers().get("x-accel-buffering").unwrap(), "no");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn subscribe_receives_published_event() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let broadcaster = Arc::new(InProcessBroadcaster::new(8));
|
||||||
|
let st = state(app, "app.example.com", Ok(()), broadcaster.clone());
|
||||||
|
let appr = realtime_router(st);
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/realtime/topics/chat")
|
||||||
|
.header("host", "app.example.com")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = appr.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// The handler has subscribed; publish and read the first chunk.
|
||||||
|
// Give the streaming task a beat to register its receiver.
|
||||||
|
let mut body = resp.into_body().into_data_stream();
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
broadcaster
|
||||||
|
.publish(
|
||||||
|
app,
|
||||||
|
"chat",
|
||||||
|
RealtimeEvent {
|
||||||
|
topic: "chat".into(),
|
||||||
|
message: serde_json::json!({ "hi": 1 }),
|
||||||
|
published_at: chrono::Utc::now(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let chunk = tokio::time::timeout(Duration::from_secs(2), body.next())
|
||||||
|
.await
|
||||||
|
.expect("a chunk within timeout")
|
||||||
|
.expect("stream item")
|
||||||
|
.expect("chunk ok");
|
||||||
|
let text = String::from_utf8_lossy(&chunk);
|
||||||
|
assert!(text.contains("data:"), "got: {text}");
|
||||||
|
assert!(text.contains("\"hi\":1"), "got: {text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn heartbeat_fires_on_idle_connection() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let broadcaster = Arc::new(InProcessBroadcaster::new(8));
|
||||||
|
// Hold a clone so the channel's sender outlives the router (which
|
||||||
|
// oneshot consumes) — otherwise the stream closes immediately.
|
||||||
|
let _keepalive = broadcaster.clone();
|
||||||
|
let st = state(app, "app.example.com", Ok(()), broadcaster);
|
||||||
|
let appr = realtime_router(st);
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/realtime/topics/chat")
|
||||||
|
.header("host", "app.example.com")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = appr.oneshot(req).await.unwrap();
|
||||||
|
let mut body = resp.into_body().into_data_stream();
|
||||||
|
// No publish — with a 100ms heartbeat, a keep-alive comment must
|
||||||
|
// arrive well within a second.
|
||||||
|
let chunk = tokio::time::timeout(Duration::from_secs(1), body.next())
|
||||||
|
.await
|
||||||
|
.expect("heartbeat within timeout")
|
||||||
|
.expect("stream item")
|
||||||
|
.expect("chunk ok");
|
||||||
|
let text = String::from_utf8_lossy(&chunk);
|
||||||
|
assert!(text.starts_with(':'), "expected SSE comment, got: {text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,29 +12,33 @@ 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,
|
attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
|
||||||
files_admin_router, migrations, require_authenticated, route_admin_router, triggers_router,
|
files_admin_router, migrations, require_authenticated, route_admin_router, topics_router,
|
||||||
AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository,
|
triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState,
|
||||||
AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository,
|
AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository,
|
||||||
AppMembersState, AppRepository, AppsState, AuthState, AuthzRepo, DeadLetterRepo,
|
AppMembersRepository, AppMembersState, AppRepository, AppsState, AuthState, AuthzRepo,
|
||||||
DeadLettersState, Dispatcher, DocsServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl,
|
DeadLetterRepo, DeadLettersState, Dispatcher, DocsServiceImpl, FilesAdminState, FilesConfig,
|
||||||
FsFilesRepo, HttpConfig, HttpServiceImpl, KvServiceImpl, OutboxEventEmitter, OutboxRepo,
|
FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl, KvServiceImpl, OutboxEventEmitter,
|
||||||
PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||||
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
||||||
PostgresAppRepository, PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo,
|
PostgresAppRepository, PostgresAppSecretsRepo, PostgresDeadLetterRepo,
|
||||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo,
|
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
|
||||||
PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresTriggerRepo,
|
PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo,
|
||||||
PrincipalResolver, PubsubServiceImpl, RepoResolver, RouteAdminState, RouteRepository,
|
PostgresRouteRepository, PostgresScriptRepository, PostgresTopicRepo, PostgresTriggerRepo,
|
||||||
SandboxCeiling, ScriptRepository, TriggerConfig, TriggerRepo, TriggersState,
|
PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState,
|
||||||
|
RouteRepository, SandboxCeiling, ScriptRepository, SubscriberTokenConfig, TopicRepo,
|
||||||
|
TopicsState, TriggerConfig, TriggerRepo, TriggersState,
|
||||||
};
|
};
|
||||||
|
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
|
||||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||||
use picloud_orchestrator_core::{
|
use picloud_orchestrator_core::{
|
||||||
data_plane_router, user_routes_router, DataPlaneState, ExecutionGate, InboxRegistry,
|
data_plane_router, realtime_router, spawn_realtime_gc, user_routes_router, DataPlaneState,
|
||||||
LocalExecutorClient,
|
ExecutionGate, InProcessBroadcaster, InboxRegistry, LocalExecutorClient, RealtimeState,
|
||||||
};
|
};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
DeadLetterService, DocsService, ExecutionLogSink, FilesService, HttpService, InboxResolver,
|
DeadLetterService, DocsService, ExecutionLogSink, FilesService, HttpService, InboxResolver,
|
||||||
KvService, OutboxWriter, PubsubService, ScriptValidator, ServiceEventEmitter, Services,
|
KvService, OutboxWriter, PubsubService, RealtimeAuthority, RealtimeBroadcaster,
|
||||||
API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION,
|
ScriptValidator, ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION, SDK_VERSION,
|
||||||
|
WIRE_VERSION,
|
||||||
};
|
};
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -162,6 +166,8 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
// the bytes live on disk under `PICLOUD_FILES_ROOT` (default ./data).
|
// the bytes live on disk under `PICLOUD_FILES_ROOT` (default ./data).
|
||||||
let files_config = FilesConfig::from_env();
|
let files_config = FilesConfig::from_env();
|
||||||
let files_max_size = files_config.max_file_size_bytes;
|
let files_max_size = files_config.max_file_size_bytes;
|
||||||
|
// Kept for the v1.1.6 orphan sweeper (cleans stale `*.tmp.*` files).
|
||||||
|
let files_root = files_config.root.clone();
|
||||||
let files_repo = Arc::new(FsFilesRepo::new(pool.clone(), files_config));
|
let files_repo = Arc::new(FsFilesRepo::new(pool.clone(), files_config));
|
||||||
let files: Arc<dyn FilesService> = Arc::new(FilesServiceImpl::new(
|
let files: Arc<dyn FilesService> = Arc::new(FilesServiceImpl::new(
|
||||||
files_repo.clone(),
|
files_repo.clone(),
|
||||||
@@ -169,12 +175,34 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
events.clone(),
|
events.clone(),
|
||||||
files_max_size,
|
files_max_size,
|
||||||
));
|
));
|
||||||
// v1.1.5 durable pub/sub. Publishes fan out to matching pubsub
|
// v1.1.6 realtime: the in-process broadcaster is shared between the
|
||||||
// triggers at publish time (one outbox row each), delivered by the
|
// publish path (PubsubServiceImpl fans out to SSE subscribers after
|
||||||
// same dispatcher as every other async trigger.
|
// the durable outbox fan-out) and the SSE endpoint (subscribe side).
|
||||||
|
// The topic registry + app-secrets repo back the subscriber-token
|
||||||
|
// mint + SSE subscribe-authorization.
|
||||||
|
let broadcaster_concrete = Arc::new(InProcessBroadcaster::from_env());
|
||||||
|
let broadcaster: Arc<dyn RealtimeBroadcaster> = broadcaster_concrete.clone();
|
||||||
|
let topic_repo: Arc<dyn TopicRepo> = Arc::new(PostgresTopicRepo::new(pool.clone()));
|
||||||
|
let app_secrets_repo = Arc::new(PostgresAppSecretsRepo::new(pool.clone()));
|
||||||
|
let realtime_authority: Arc<dyn RealtimeAuthority> = Arc::new(RealtimeAuthorityImpl::new(
|
||||||
|
topic_repo.clone(),
|
||||||
|
app_secrets_repo.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// v1.1.5 durable pub/sub, extended in v1.1.6 with the realtime
|
||||||
|
// broadcast + subscriber-token mint. Publishes fan out to matching
|
||||||
|
// pubsub triggers at publish time (one outbox row each, delivered by
|
||||||
|
// the same dispatcher as every other async trigger) AND, best-effort,
|
||||||
|
// to in-process SSE subscribers.
|
||||||
let pubsub_repo = Arc::new(PostgresPubsubRepo::new(pool.clone()));
|
let pubsub_repo = Arc::new(PostgresPubsubRepo::new(pool.clone()));
|
||||||
let pubsub: Arc<dyn PubsubService> =
|
let pubsub: Arc<dyn PubsubService> = Arc::new(
|
||||||
Arc::new(PubsubServiceImpl::new(pubsub_repo, authz.clone()));
|
PubsubServiceImpl::new(pubsub_repo, authz.clone()).with_realtime(
|
||||||
|
broadcaster.clone(),
|
||||||
|
topic_repo.clone(),
|
||||||
|
app_secrets_repo,
|
||||||
|
SubscriberTokenConfig::from_env(),
|
||||||
|
),
|
||||||
|
);
|
||||||
let services = Services::new(
|
let services = Services::new(
|
||||||
kv,
|
kv,
|
||||||
docs,
|
docs,
|
||||||
@@ -284,6 +312,10 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
// enqueues due triggers into the outbox; the dispatcher above
|
// enqueues due triggers into the outbox; the dispatcher above
|
||||||
// delivers them like any other async trigger.
|
// delivers them like any other async trigger.
|
||||||
picloud_manager_core::spawn_cron_scheduler(pool, trigger_config.cron_tick_interval_ms);
|
picloud_manager_core::spawn_cron_scheduler(pool, trigger_config.cron_tick_interval_ms);
|
||||||
|
// v1.1.6: GC empty realtime broadcast channels (one-shot subscribers)
|
||||||
|
// and sweep orphaned `*.tmp.*` blobs left by crashed file writes.
|
||||||
|
spawn_realtime_gc(broadcaster_concrete, DEFAULT_GC_INTERVAL_SECS);
|
||||||
|
picloud_manager_core::spawn_files_orphan_sweep(files_root);
|
||||||
let triggers_state = TriggersState {
|
let triggers_state = TriggersState {
|
||||||
triggers: trigger_repo,
|
triggers: trigger_repo,
|
||||||
apps: apps_repo.clone(),
|
apps: apps_repo.clone(),
|
||||||
@@ -302,11 +334,17 @@ 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 topics_state = TopicsState {
|
||||||
|
topics: topic_repo,
|
||||||
|
apps: apps_repo.clone(),
|
||||||
|
authz: authz.clone(),
|
||||||
|
broadcaster: broadcaster.clone(),
|
||||||
|
};
|
||||||
let apps_state = AppsState {
|
let apps_state = AppsState {
|
||||||
apps: apps_repo,
|
apps: apps_repo,
|
||||||
domains: domains_repo,
|
domains: domains_repo,
|
||||||
routes: route_repo,
|
routes: route_repo,
|
||||||
domain_table: app_domain_table,
|
domain_table: app_domain_table.clone(),
|
||||||
authz: authz.clone(),
|
authz: authz.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -345,6 +383,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
.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(files_admin_router(files_admin_state))
|
||||||
|
.merge(topics_router(topics_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(),
|
||||||
@@ -375,10 +414,21 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
.nest("/admin", guarded_admin)
|
.nest("/admin", guarded_admin)
|
||||||
.merge(data_plane_routed);
|
.merge(data_plane_routed);
|
||||||
|
|
||||||
|
// v1.1.6 SSE realtime surface, merged at the root (deliberately NOT
|
||||||
|
// under /api/ — realtime is its own versioning surface). Public auth
|
||||||
|
// is per-topic; no principal middleware (token verification is the
|
||||||
|
// gate, handled inside the authority).
|
||||||
|
let realtime = realtime_router(RealtimeState::new(
|
||||||
|
app_domain_table,
|
||||||
|
broadcaster,
|
||||||
|
realtime_authority,
|
||||||
|
));
|
||||||
|
|
||||||
Ok(Router::new()
|
Ok(Router::new()
|
||||||
.route("/healthz", get(healthz))
|
.route("/healthz", get(healthz))
|
||||||
.route("/version", get(version))
|
.route("/version", get(version))
|
||||||
.nest(&format!("/api/v{API_VERSION}"), api_v1)
|
.nest(&format!("/api/v{API_VERSION}"), api_v1)
|
||||||
|
.merge(realtime)
|
||||||
.merge(user_routes)
|
.merge(user_routes)
|
||||||
.layer(TraceLayer::new_for_http()))
|
.layer(TraceLayer::new_for_http()))
|
||||||
}
|
}
|
||||||
|
|||||||
353
crates/picloud/tests/dispatcher_e2e.rs
Normal file
353
crates/picloud/tests/dispatcher_e2e.rs
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
//! End-to-end dispatcher tests — one per trigger kind (v1.1.5 follow-up,
|
||||||
|
//! landed in v1.1.6). Each test wires the full all-in-one app via
|
||||||
|
//! `build_app` (which spawns the real dispatcher + cron scheduler +
|
||||||
|
//! executor), creates an app + a logging handler script + a trigger,
|
||||||
|
//! causes the originating event, and polls for the handler's side effect.
|
||||||
|
//!
|
||||||
|
//! ## Gating
|
||||||
|
//!
|
||||||
|
//! These need a Postgres reachable via `DATABASE_URL`. They follow the
|
||||||
|
//! `schema_snapshot` pattern (NOT `#[ignore]`): when `DATABASE_URL` is
|
||||||
|
//! unset the test prints a notice and returns early, so plain
|
||||||
|
//! `cargo test` stays green locally while CI (which sets `DATABASE_URL`)
|
||||||
|
//! runs them.
|
||||||
|
//!
|
||||||
|
//! ## How "the handler fired" is observed
|
||||||
|
//!
|
||||||
|
//! The dispatcher does not write `execution_log` rows for trigger
|
||||||
|
//! handlers, so each handler instead records its `ctx.event` into a KV
|
||||||
|
//! marker (`collection = "e2e_markers"`, which no trigger watches — no
|
||||||
|
//! recursion). The test polls `kv_entries` for that marker and asserts
|
||||||
|
//! the event shape. See HANDBACK §deviations for why this lives in
|
||||||
|
//! `picloud/tests/` rather than `manager-core/tests/` (build_app lives in
|
||||||
|
//! the `picloud` crate) and for the `dead_letter` reinterpretation.
|
||||||
|
|
||||||
|
#![allow(clippy::needless_pass_by_value)]
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum_test::TestServer;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Connect + migrate, or return `None` (printing a skip notice) when
|
||||||
|
/// `DATABASE_URL` is unset — mirrors `schema_snapshot.rs`.
|
||||||
|
async fn pool_or_skip() -> Option<PgPool> {
|
||||||
|
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||||
|
eprintln!("dispatcher_e2e: DATABASE_URL unset — skipping");
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&url)
|
||||||
|
.await
|
||||||
|
.expect("connect to DATABASE_URL");
|
||||||
|
sqlx::migrate!("../manager-core/migrations")
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.expect("apply migrations");
|
||||||
|
Some(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the app over the shared pool with a uniquely-named owner admin,
|
||||||
|
/// log in, and create a fresh app. `suffix` must be unique per test (the
|
||||||
|
/// pool is shared, so names must not collide).
|
||||||
|
async fn server_for(pool: PgPool, suffix: &str) -> (TestServer, String) {
|
||||||
|
use picloud_manager_core::auth::hash_password;
|
||||||
|
use picloud_shared::InstanceRole;
|
||||||
|
|
||||||
|
let unique = format!("{suffix}-{}", Uuid::new_v4().simple());
|
||||||
|
let auth = picloud::AuthDeps::from_pool(pool.clone());
|
||||||
|
let username = format!("e2e-{unique}");
|
||||||
|
let hash = hash_password("pw").expect("hash");
|
||||||
|
auth.users
|
||||||
|
.create(&username, &hash, InstanceRole::Owner, None)
|
||||||
|
.await
|
||||||
|
.expect("seed admin");
|
||||||
|
|
||||||
|
let app = picloud::build_app(pool, auth).await.expect("build_app");
|
||||||
|
let mut server = TestServer::new(app).expect("TestServer");
|
||||||
|
let resp = server
|
||||||
|
.post("/api/v1/admin/auth/login")
|
||||||
|
.json(&json!({ "username": username, "password": "pw" }))
|
||||||
|
.await;
|
||||||
|
resp.assert_status_ok();
|
||||||
|
let token = resp.json::<Value>()["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("login token")
|
||||||
|
.to_string();
|
||||||
|
server.add_header("authorization", format!("Bearer {token}"));
|
||||||
|
|
||||||
|
// A fresh app keeps each test's KV / events isolated from siblings.
|
||||||
|
let slug = format!("e2e-{unique}");
|
||||||
|
let created: Value = server
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": slug, "name": slug }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let app_id = created["id"].as_str().expect("app id").to_string();
|
||||||
|
(server, app_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_script(server: &TestServer, app_id: &str, name: &str, source: &str) -> String {
|
||||||
|
let created: Value = server
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.json(&json!({ "app_id": app_id, "name": name, "source": source }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
created["id"].as_str().expect("script id").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A handler that records its `ctx.event` into a KV marker the test can
|
||||||
|
/// observe. The marker collection is watched by no trigger.
|
||||||
|
const MARKER_HANDLER: &str = r#"
|
||||||
|
let e = ctx.event;
|
||||||
|
kv::collection("e2e_markers").set("marker", e);
|
||||||
|
#{ ok: true }
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Poll the marker KV key until present (or ~10s timeout).
|
||||||
|
async fn poll_marker(pool: &PgPool, app_id: &str) -> Option<Value> {
|
||||||
|
poll_marker_n(pool, app_id, 100).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll the marker KV key for `iters` × 100ms.
|
||||||
|
async fn poll_marker_n(pool: &PgPool, app_id: &str, iters: u32) -> Option<Value> {
|
||||||
|
let app_uuid = Uuid::parse_str(app_id).expect("app uuid");
|
||||||
|
for _ in 0..iters {
|
||||||
|
let row: Option<(Value,)> = sqlx::query_as(
|
||||||
|
"SELECT value FROM kv_entries \
|
||||||
|
WHERE app_id = $1 AND collection = 'e2e_markers' AND key = 'marker'",
|
||||||
|
)
|
||||||
|
.bind(app_uuid)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.expect("query marker");
|
||||||
|
if let Some((value,)) = row {
|
||||||
|
return Some(value);
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(server: &TestServer, script_id: &str) {
|
||||||
|
server
|
||||||
|
.post(&format!("/api/v1/execute/{script_id}"))
|
||||||
|
.json(&json!({}))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatcher_delivers_kv_to_handler() {
|
||||||
|
let Some(pool) = pool_or_skip().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (server, app_id) = server_for(pool.clone(), "kv").await;
|
||||||
|
|
||||||
|
let handler = create_script(&server, &app_id, "kv-handler", MARKER_HANDLER).await;
|
||||||
|
server
|
||||||
|
.post(&format!("/api/v1/admin/apps/{app_id}/triggers/kv"))
|
||||||
|
.json(&json!({ "script_id": handler, "collection_glob": "src" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
let source = create_script(
|
||||||
|
&server,
|
||||||
|
&app_id,
|
||||||
|
"kv-source",
|
||||||
|
r#"kv::collection("src").set("k", 42); #{ ok: true }"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
execute(&server, &source).await;
|
||||||
|
|
||||||
|
let event = poll_marker(&pool, &app_id).await.expect("kv handler fired");
|
||||||
|
assert_eq!(event["source"], "kv");
|
||||||
|
assert_eq!(event["op"], "insert");
|
||||||
|
assert_eq!(event["kv"]["collection"], "src");
|
||||||
|
assert_eq!(event["kv"]["key"], "k");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatcher_delivers_docs_to_handler() {
|
||||||
|
let Some(pool) = pool_or_skip().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (server, app_id) = server_for(pool.clone(), "docs").await;
|
||||||
|
|
||||||
|
let handler = create_script(&server, &app_id, "docs-handler", MARKER_HANDLER).await;
|
||||||
|
server
|
||||||
|
.post(&format!("/api/v1/admin/apps/{app_id}/triggers/docs"))
|
||||||
|
.json(&json!({ "script_id": handler, "collection_glob": "src" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
let source = create_script(
|
||||||
|
&server,
|
||||||
|
&app_id,
|
||||||
|
"docs-source",
|
||||||
|
r#"docs::collection("src").create(#{ x: 1 }); #{ ok: true }"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
execute(&server, &source).await;
|
||||||
|
|
||||||
|
let event = poll_marker(&pool, &app_id)
|
||||||
|
.await
|
||||||
|
.expect("docs handler fired");
|
||||||
|
assert_eq!(event["source"], "docs");
|
||||||
|
assert_eq!(event["op"], "create");
|
||||||
|
assert_eq!(event["docs"]["collection"], "src");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatcher_delivers_cron_to_handler() {
|
||||||
|
let Some(pool) = pool_or_skip().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (server, app_id) = server_for(pool.clone(), "cron").await;
|
||||||
|
|
||||||
|
let handler = create_script(&server, &app_id, "cron-handler", MARKER_HANDLER).await;
|
||||||
|
// Fire every second (6-field cron, seconds-resolution).
|
||||||
|
server
|
||||||
|
.post(&format!("/api/v1/admin/apps/{app_id}/triggers/cron"))
|
||||||
|
.json(&json!({ "script_id": handler, "schedule": "* * * * * *", "timezone": "UTC" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
// No source — the scheduler enqueues the due tick on its own. The
|
||||||
|
// scheduler skips its first tick and then ticks every
|
||||||
|
// PICLOUD_CRON_TICK_INTERVAL_MS (default 30s), so poll past that
|
||||||
|
// (set the env var lower to speed CI up if desired).
|
||||||
|
let event = poll_marker_n(&pool, &app_id, 450)
|
||||||
|
.await
|
||||||
|
.expect("cron handler fired");
|
||||||
|
assert_eq!(event["source"], "cron");
|
||||||
|
assert_eq!(event["op"], "tick");
|
||||||
|
assert_eq!(event["cron"]["timezone"], "UTC");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatcher_delivers_files_to_handler() {
|
||||||
|
let Some(pool) = pool_or_skip().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (server, app_id) = server_for(pool.clone(), "files").await;
|
||||||
|
|
||||||
|
let handler = create_script(&server, &app_id, "files-handler", MARKER_HANDLER).await;
|
||||||
|
server
|
||||||
|
.post(&format!("/api/v1/admin/apps/{app_id}/triggers/files"))
|
||||||
|
.json(&json!({ "script_id": handler, "collection_glob": "src" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
let source = create_script(
|
||||||
|
&server,
|
||||||
|
&app_id,
|
||||||
|
"files-source",
|
||||||
|
r#"
|
||||||
|
let data = base64::decode("aGk=");
|
||||||
|
files::collection("src").create(#{ name: "f.txt", content_type: "text/plain", data: data });
|
||||||
|
#{ ok: true }
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
execute(&server, &source).await;
|
||||||
|
|
||||||
|
let event = poll_marker(&pool, &app_id)
|
||||||
|
.await
|
||||||
|
.expect("files handler fired");
|
||||||
|
assert_eq!(event["source"], "files");
|
||||||
|
assert_eq!(event["op"], "create");
|
||||||
|
assert_eq!(event["files"]["collection"], "src");
|
||||||
|
assert_eq!(event["files"]["name"], "f.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatcher_delivers_pubsub_to_handler() {
|
||||||
|
let Some(pool) = pool_or_skip().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (server, app_id) = server_for(pool.clone(), "pubsub").await;
|
||||||
|
|
||||||
|
let handler = create_script(&server, &app_id, "pubsub-handler", MARKER_HANDLER).await;
|
||||||
|
server
|
||||||
|
.post(&format!("/api/v1/admin/apps/{app_id}/triggers/pubsub"))
|
||||||
|
.json(&json!({ "script_id": handler, "topic_pattern": "e2e.topic" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
let source = create_script(
|
||||||
|
&server,
|
||||||
|
&app_id,
|
||||||
|
"pubsub-source",
|
||||||
|
r#"pubsub::publish_durable("e2e.topic", #{ hello: 1 }); #{ ok: true }"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
execute(&server, &source).await;
|
||||||
|
|
||||||
|
let event = poll_marker(&pool, &app_id)
|
||||||
|
.await
|
||||||
|
.expect("pubsub handler fired");
|
||||||
|
assert_eq!(event["source"], "pubsub");
|
||||||
|
assert_eq!(event["op"], "publish");
|
||||||
|
assert_eq!(event["pubsub"]["topic"], "e2e.topic");
|
||||||
|
assert_eq!(event["pubsub"]["message"]["hello"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatcher_delivers_dead_letter_to_handler() {
|
||||||
|
// NOTE: the dead-letter creation path (`dispatcher::handle_failure` →
|
||||||
|
// `DeadLetterRepo::insert`) writes the `dead_letters` row but does not
|
||||||
|
// appear to enqueue deliveries for `dead_letter`-kind triggers
|
||||||
|
// (`TriggerRepo::list_matching_dead_letter` has no production caller —
|
||||||
|
// see HANDBACK latent-findings). So this test asserts the wired
|
||||||
|
// behavior: a failing handler that exhausts its (single) attempt
|
||||||
|
// produces a dead-letter row. If/when DL→handler fan-out lands, this
|
||||||
|
// can be upgraded to assert the handler marker like the others.
|
||||||
|
let Some(pool) = pool_or_skip().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (server, app_id) = server_for(pool.clone(), "dl").await;
|
||||||
|
|
||||||
|
// A handler that always throws, with a single attempt so it
|
||||||
|
// dead-letters immediately (no retry backoff).
|
||||||
|
let failing = create_script(&server, &app_id, "dl-failing", r#"throw "boom";"#).await;
|
||||||
|
server
|
||||||
|
.post(&format!("/api/v1/admin/apps/{app_id}/triggers/kv"))
|
||||||
|
.json(&json!({
|
||||||
|
"script_id": failing,
|
||||||
|
"collection_glob": "dlsrc",
|
||||||
|
"retry_max_attempts": 1,
|
||||||
|
"retry_base_ms": 0
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
let source = create_script(
|
||||||
|
&server,
|
||||||
|
&app_id,
|
||||||
|
"dl-source",
|
||||||
|
r#"kv::collection("dlsrc").set("k", 1); #{ ok: true }"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
execute(&server, &source).await;
|
||||||
|
|
||||||
|
// Poll the dead_letters table for this app.
|
||||||
|
let app_uuid = Uuid::parse_str(&app_id).unwrap();
|
||||||
|
let mut count: i64 = 0;
|
||||||
|
for _ in 0..100 {
|
||||||
|
count = sqlx::query_scalar("SELECT COUNT(*) FROM dead_letters WHERE app_id = $1")
|
||||||
|
.bind(app_uuid)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("count dead_letters");
|
||||||
|
if count > 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
assert!(count > 0, "a dead-letter row should have been produced");
|
||||||
|
}
|
||||||
@@ -15,3 +15,12 @@ serde_json.workspace = true
|
|||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
|
# Realtime broadcaster trait returns a broadcast::Receiver; subscriber
|
||||||
|
# tokens are HMAC-SHA256 over a base64url payload (v1.1.6).
|
||||||
|
tokio = { workspace = true, features = ["sync"] }
|
||||||
|
hmac.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time", "sync"] }
|
||||||
|
|||||||
@@ -177,8 +177,11 @@ pub enum FilesError {
|
|||||||
|
|
||||||
impl NewFile {
|
impl NewFile {
|
||||||
/// Validate required fields + length caps at the SDK boundary.
|
/// 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).
|
/// Empty `data` is **accepted** as a valid stored state (v1.1.6
|
||||||
|
/// relaxed the v1.1.5 rejection — empty files are a legitimate use
|
||||||
|
/// case: sentinels, placeholders, zero-byte uploads. See HANDBACK
|
||||||
|
/// §7). `name` and `content_type` are still required.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
@@ -191,9 +194,6 @@ impl NewFile {
|
|||||||
if self.content_type.trim().is_empty() {
|
if self.content_type.trim().is_empty() {
|
||||||
return Err(FilesError::MissingField("content_type"));
|
return Err(FilesError::MissingField("content_type"));
|
||||||
}
|
}
|
||||||
if self.data.is_empty() {
|
|
||||||
return Err(FilesError::MissingField("data"));
|
|
||||||
}
|
|
||||||
if self.name.len() > MAX_FILE_NAME_BYTES {
|
if self.name.len() > MAX_FILE_NAME_BYTES {
|
||||||
return Err(FilesError::NameTooLong(self.name.len()));
|
return Err(FilesError::NameTooLong(self.name.len()));
|
||||||
}
|
}
|
||||||
@@ -218,9 +218,9 @@ impl FileUpdate {
|
|||||||
/// Returns the field-specific [`FilesError`] for the first failing
|
/// Returns the field-specific [`FilesError`] for the first failing
|
||||||
/// check.
|
/// check.
|
||||||
pub fn validate(&self, max_size: usize) -> Result<(), FilesError> {
|
pub fn validate(&self, max_size: usize) -> Result<(), FilesError> {
|
||||||
if self.data.is_empty() {
|
// Empty replacement bytes are accepted (v1.1.6 relaxation —
|
||||||
return Err(FilesError::MissingField("data"));
|
// consistent with NewFile::validate; updating a file to zero
|
||||||
}
|
// bytes is as legitimate as creating one).
|
||||||
if let Some(name) = &self.name {
|
if let Some(name) = &self.name {
|
||||||
if name.trim().is_empty() {
|
if name.trim().is_empty() {
|
||||||
return Err(FilesError::MissingField("name"));
|
return Err(FilesError::MissingField("name"));
|
||||||
|
|||||||
@@ -21,11 +21,14 @@ pub mod log_sink;
|
|||||||
pub mod modules;
|
pub mod modules;
|
||||||
pub mod outbox_writer;
|
pub mod outbox_writer;
|
||||||
pub mod pubsub;
|
pub mod pubsub;
|
||||||
|
pub mod realtime;
|
||||||
|
pub mod realtime_authority;
|
||||||
pub mod route;
|
pub mod route;
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
pub mod script;
|
pub mod script;
|
||||||
pub mod sdk_cx;
|
pub mod sdk_cx;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
|
pub mod subscriber_token;
|
||||||
pub mod trigger_event;
|
pub mod trigger_event;
|
||||||
pub mod validator;
|
pub mod validator;
|
||||||
pub mod version;
|
pub mod version;
|
||||||
@@ -54,6 +57,8 @@ pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, Outbox
|
|||||||
pub use pubsub::{
|
pub use pubsub::{
|
||||||
topic_matches, validate_topic_pattern, NoopPubsubService, PubsubError, PubsubService,
|
topic_matches, validate_topic_pattern, NoopPubsubService, PubsubError, PubsubService,
|
||||||
};
|
};
|
||||||
|
pub use realtime::{BroadcasterError, NoopRealtimeBroadcaster, RealtimeBroadcaster, RealtimeEvent};
|
||||||
|
pub use realtime_authority::{DenyAllRealtimeAuthority, RealtimeAuthority, SubscribeDenied};
|
||||||
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};
|
||||||
|
|||||||
@@ -30,6 +30,32 @@ pub trait PubsubService: Send + Sync {
|
|||||||
topic: &str,
|
topic: &str,
|
||||||
message: serde_json::Value,
|
message: serde_json::Value,
|
||||||
) -> Result<(), PubsubError>;
|
) -> Result<(), PubsubError>;
|
||||||
|
|
||||||
|
/// Mint an HMAC-signed realtime subscriber token (v1.1.6). Backs the
|
||||||
|
/// `pubsub::subscriber_token(topics, ttl)` Rhai SDK call. The minted
|
||||||
|
/// token authorizes an external SSE client to subscribe to the given
|
||||||
|
/// `topics` for `ttl_seconds` (clamped to the configured bounds; the
|
||||||
|
/// configured default applies when `ttl_seconds` is `None`).
|
||||||
|
///
|
||||||
|
/// Every topic must already be registered as externally subscribable
|
||||||
|
/// in `cx.app_id`; `cx.principal` must be `Some` (anonymous
|
||||||
|
/// public-HTTP scripts can't mint). See [`PubsubError::SubscriberToken`]
|
||||||
|
/// for the rejection messages.
|
||||||
|
///
|
||||||
|
/// The default impl errors `Unavailable` so test fakes and the
|
||||||
|
/// `NoopPubsubService` keep compiling; the real minting lives in
|
||||||
|
/// manager-core's `PubsubServiceImpl`.
|
||||||
|
async fn mint_subscriber_token(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
topics: Vec<String>,
|
||||||
|
ttl_seconds: Option<i64>,
|
||||||
|
) -> Result<String, PubsubError> {
|
||||||
|
let _ = (cx, topics, ttl_seconds);
|
||||||
|
Err(PubsubError::Unavailable(
|
||||||
|
"subscriber tokens are not wired in".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -47,6 +73,13 @@ pub enum PubsubError {
|
|||||||
#[error("pubsub rejected: {0}")]
|
#[error("pubsub rejected: {0}")]
|
||||||
Rejected(String),
|
Rejected(String),
|
||||||
|
|
||||||
|
/// A `pubsub::subscriber_token` mint was rejected (empty topics,
|
||||||
|
/// unregistered topic, ttl out of range, anonymous caller). The
|
||||||
|
/// string is the full user-facing message; the SDK surfaces it
|
||||||
|
/// verbatim so scripts see the documented wording.
|
||||||
|
#[error("{0}")]
|
||||||
|
SubscriberToken(String),
|
||||||
|
|
||||||
/// Anything else — Postgres unavailable, etc.
|
/// Anything else — Postgres unavailable, etc.
|
||||||
#[error("pubsub backend error: {0}")]
|
#[error("pubsub backend error: {0}")]
|
||||||
Unavailable(String),
|
Unavailable(String),
|
||||||
|
|||||||
86
crates/shared/src/realtime.rs
Normal file
86
crates/shared/src/realtime.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
//! `RealtimeBroadcaster` — the in-process fan-out seam for SSE realtime
|
||||||
|
//! delivery (v1.1.6).
|
||||||
|
//!
|
||||||
|
//! Structurally a sibling of [`crate::inbox::InboxResolver`]: the trait
|
||||||
|
//! lives here in `picloud-shared` because the publish side
|
||||||
|
//! (`PubsubServiceImpl` in manager-core) and the subscribe side (the SSE
|
||||||
|
//! handler in orchestrator-core) live in different crates and both need
|
||||||
|
//! one shared instance. The in-process impl lives in orchestrator-core
|
||||||
|
//! (`Mutex<HashMap<(AppId, topic), broadcast::Sender>>`); cluster mode
|
||||||
|
//! (v1.3+) swaps it for a Postgres `LISTEN/NOTIFY`-backed resolver behind
|
||||||
|
//! the same trait without touching either caller.
|
||||||
|
//!
|
||||||
|
//! Delivery is **best-effort, at-most-once**: this is the realtime path,
|
||||||
|
//! NOT the durable one. Durable trigger fan-out (retry / dead-letter)
|
||||||
|
//! goes through the outbox and is the publish caller's separate concern.
|
||||||
|
//! A slow SSE consumer loses the oldest events (bounded broadcast
|
||||||
|
//! buffer); SSE's own transport-layer auto-reconnect is the recovery
|
||||||
|
//! mechanism (no server-side replay in v1.1.6).
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
use crate::AppId;
|
||||||
|
|
||||||
|
/// A single realtime event delivered to in-process SSE subscribers. The
|
||||||
|
/// SSE handler serializes this to `data: {...}\n\n` on the wire.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RealtimeEvent {
|
||||||
|
pub topic: String,
|
||||||
|
pub message: serde_json::Value,
|
||||||
|
pub published_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum BroadcasterError {
|
||||||
|
/// Reserved for backends that can fail to register a subscriber
|
||||||
|
/// (e.g. the cluster-mode `LISTEN/NOTIFY` resolver). The in-process
|
||||||
|
/// impl never returns this.
|
||||||
|
#[error("realtime broadcaster unavailable: {0}")]
|
||||||
|
Unavailable(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait RealtimeBroadcaster: Send + Sync {
|
||||||
|
/// Subscribe to events on `(app_id, topic)`. Returns a receiver that
|
||||||
|
/// yields events until dropped. Channels are created lazily on first
|
||||||
|
/// subscribe.
|
||||||
|
async fn subscribe(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
topic: &str,
|
||||||
|
) -> Result<broadcast::Receiver<RealtimeEvent>, BroadcasterError>;
|
||||||
|
|
||||||
|
/// Publish an event to in-process subscribers. NOT durable — the
|
||||||
|
/// outbox-backed durable fan-out is the publish caller's separate
|
||||||
|
/// concern. A publish with no live subscribers is a silent no-op.
|
||||||
|
async fn publish(&self, app_id: AppId, topic: &str, event: RealtimeEvent);
|
||||||
|
|
||||||
|
/// Drop every subscriber for a topic (called on topic DELETE). Live
|
||||||
|
/// receivers observe a closed channel and disconnect cleanly.
|
||||||
|
async fn drop_topic(&self, app_id: AppId, topic: &str);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bootstrap / test impl: subscribe yields a receiver on a throwaway
|
||||||
|
/// channel, publish is a no-op. Lets a `Services`-style bundle build
|
||||||
|
/// without the real registry wired in.
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct NoopRealtimeBroadcaster;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl RealtimeBroadcaster for NoopRealtimeBroadcaster {
|
||||||
|
async fn subscribe(
|
||||||
|
&self,
|
||||||
|
_app_id: AppId,
|
||||||
|
_topic: &str,
|
||||||
|
) -> Result<broadcast::Receiver<RealtimeEvent>, BroadcasterError> {
|
||||||
|
let (_tx, rx) = broadcast::channel(1);
|
||||||
|
Ok(rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn publish(&self, _app_id: AppId, _topic: &str, _event: RealtimeEvent) {}
|
||||||
|
|
||||||
|
async fn drop_topic(&self, _app_id: AppId, _topic: &str) {}
|
||||||
|
}
|
||||||
70
crates/shared/src/realtime_authority.rs
Normal file
70
crates/shared/src/realtime_authority.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
//! `RealtimeAuthority` — the SSE subscribe authorization seam (v1.1.6).
|
||||||
|
//!
|
||||||
|
//! The SSE endpoint (`GET /realtime/topics/{topic}`) is a data-plane
|
||||||
|
//! surface in orchestrator-core, but deciding whether a subscribe is
|
||||||
|
//! allowed needs a `topics` table read plus (for token-gated topics) an
|
||||||
|
//! HMAC verify against the app's signing key — both of which require DB
|
||||||
|
//! access and the signing-key material that must NOT leak into the
|
||||||
|
//! data-plane crate. This trait keeps all of that inside the manager-core
|
||||||
|
//! impl: orchestrator-core only ever sees the three-way verdict below.
|
||||||
|
//!
|
||||||
|
//! `NotFound` is deliberately returned for *both* "no such topic" and
|
||||||
|
//! "topic exists but isn't externally subscribable" so the endpoint
|
||||||
|
//! can't be used to probe which internal topics exist (design notes §5).
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::AppId;
|
||||||
|
|
||||||
|
/// Why a subscribe attempt was refused. The SSE handler maps these to
|
||||||
|
/// HTTP status codes.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SubscribeDenied {
|
||||||
|
/// No externally-subscribable topic by that name in this app → 404.
|
||||||
|
/// Used for genuinely-missing topics AND internal-only ones, so the
|
||||||
|
/// endpoint doesn't leak which internal topics exist.
|
||||||
|
NotFound,
|
||||||
|
/// The topic is token-gated and the presented token was missing,
|
||||||
|
/// malformed, badly signed, expired, or not scoped to this topic →
|
||||||
|
/// 401 (generic; never says which check failed).
|
||||||
|
Unauthorized,
|
||||||
|
/// Backend failure (DB unavailable, etc.) → 500.
|
||||||
|
Backend(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait RealtimeAuthority: Send + Sync {
|
||||||
|
/// Decide whether an external client may subscribe to
|
||||||
|
/// `(app_id, topic)`. `token` is the bearer/query token if the
|
||||||
|
/// client presented one (`None` otherwise).
|
||||||
|
///
|
||||||
|
/// Returns `Ok(())` when the subscribe is permitted (public topic,
|
||||||
|
/// or token-gated topic with a valid token scoped to it).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// [`SubscribeDenied`] — see the variants for the status mapping.
|
||||||
|
async fn authorize_subscribe(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
topic: &str,
|
||||||
|
token: Option<&str>,
|
||||||
|
) -> Result<(), SubscribeDenied>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bootstrap impl: denies everything as `NotFound`. Replaced in
|
||||||
|
/// `build_app` with the manager-core DB-backed authority.
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct DenyAllRealtimeAuthority;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl RealtimeAuthority for DenyAllRealtimeAuthority {
|
||||||
|
async fn authorize_subscribe(
|
||||||
|
&self,
|
||||||
|
_app_id: AppId,
|
||||||
|
_topic: &str,
|
||||||
|
_token: Option<&str>,
|
||||||
|
) -> Result<(), SubscribeDenied> {
|
||||||
|
Err(SubscribeDenied::NotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
200
crates/shared/src/subscriber_token.rs
Normal file
200
crates/shared/src/subscriber_token.rs
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
//! HMAC-signed realtime subscriber tokens (v1.1.6, design notes §5).
|
||||||
|
//!
|
||||||
|
//! A token is a compact, URL-safe, two-part string:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! <base64url(payload)>.<base64url(signature)>
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! where `payload` is the JSON [`TokenClaims`] and `signature` is
|
||||||
|
//! `HMAC-SHA256(app_signing_key, base64url(payload))`. Tokens are minted
|
||||||
|
//! by scripts via `pubsub::subscriber_token` (the minter lives in
|
||||||
|
//! manager-core's `PubsubServiceImpl`) and verified by the SSE subscribe
|
||||||
|
//! path (the verifier lives in manager-core's `RealtimeAuthority` impl).
|
||||||
|
//! Both sides depend on this module so the byte-for-byte contract has a
|
||||||
|
//! single home.
|
||||||
|
//!
|
||||||
|
//! There is no per-token revocation in v1.1.6 by design: HMAC bearers
|
||||||
|
//! can't be individually revoked. Rotating an app's signing key
|
||||||
|
//! invalidates every token for that app wholesale; short TTLs are the
|
||||||
|
//! safety mechanism.
|
||||||
|
|
||||||
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
|
use base64::Engine as _;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::Sha256;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::AppId;
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
/// The signed payload. `exp` / `iat` are Unix seconds.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TokenClaims {
|
||||||
|
pub app_id: AppId,
|
||||||
|
pub topics: Vec<String>,
|
||||||
|
pub exp: i64,
|
||||||
|
pub iat: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenClaims {
|
||||||
|
/// Does this token grant access to `topic`?
|
||||||
|
#[must_use]
|
||||||
|
pub fn allows_topic(&self, topic: &str) -> bool {
|
||||||
|
self.topics.iter().any(|t| t == topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the token expired relative to `now_unix` (Unix seconds)?
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_expired(&self, now_unix: i64) -> bool {
|
||||||
|
now_unix >= self.exp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum TokenError {
|
||||||
|
#[error("token is malformed")]
|
||||||
|
Malformed,
|
||||||
|
#[error("token signature is invalid")]
|
||||||
|
BadSignature,
|
||||||
|
#[error("token has expired")]
|
||||||
|
Expired,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign `claims` with `key`, producing the `payload.signature` string.
|
||||||
|
#[must_use]
|
||||||
|
pub fn sign(key: &[u8], claims: &TokenClaims) -> String {
|
||||||
|
// `serde_json` on a fixed-field struct never fails to serialize.
|
||||||
|
let payload_json = serde_json::to_vec(claims).expect("TokenClaims serialize");
|
||||||
|
let payload_b64 = URL_SAFE_NO_PAD.encode(&payload_json);
|
||||||
|
let sig = mac_sign(key, payload_b64.as_bytes());
|
||||||
|
let sig_b64 = URL_SAFE_NO_PAD.encode(sig);
|
||||||
|
format!("{payload_b64}.{sig_b64}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify `token` against `key` and check expiry against `now_unix`
|
||||||
|
/// (Unix seconds). Returns the decoded [`TokenClaims`] on success.
|
||||||
|
///
|
||||||
|
/// Topic-scope checking (is the requested topic in the token's list?)
|
||||||
|
/// is the caller's responsibility via [`TokenClaims::allows_topic`] —
|
||||||
|
/// this function proves authenticity + liveness only.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// [`TokenError::Malformed`] if the shape / base64 / JSON is wrong,
|
||||||
|
/// [`TokenError::BadSignature`] if the HMAC doesn't match, or
|
||||||
|
/// [`TokenError::Expired`] if `now_unix >= exp`.
|
||||||
|
pub fn verify(key: &[u8], token: &str, now_unix: i64) -> Result<TokenClaims, TokenError> {
|
||||||
|
let (payload_b64, sig_b64) = token.split_once('.').ok_or(TokenError::Malformed)?;
|
||||||
|
|
||||||
|
let provided_sig = URL_SAFE_NO_PAD
|
||||||
|
.decode(sig_b64)
|
||||||
|
.map_err(|_| TokenError::Malformed)?;
|
||||||
|
|
||||||
|
// Constant-time verify of the MAC over the exact payload bytes.
|
||||||
|
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
|
||||||
|
mac.update(payload_b64.as_bytes());
|
||||||
|
mac.verify_slice(&provided_sig)
|
||||||
|
.map_err(|_| TokenError::BadSignature)?;
|
||||||
|
|
||||||
|
// Signature good → decode the claims and check expiry.
|
||||||
|
let payload_json = URL_SAFE_NO_PAD
|
||||||
|
.decode(payload_b64)
|
||||||
|
.map_err(|_| TokenError::Malformed)?;
|
||||||
|
let claims: TokenClaims =
|
||||||
|
serde_json::from_slice(&payload_json).map_err(|_| TokenError::Malformed)?;
|
||||||
|
|
||||||
|
if claims.is_expired(now_unix) {
|
||||||
|
return Err(TokenError::Expired);
|
||||||
|
}
|
||||||
|
Ok(claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mac_sign(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
|
||||||
|
mac.update(data);
|
||||||
|
mac.finalize().into_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn claims(app: AppId, topics: &[&str], exp: i64) -> TokenClaims {
|
||||||
|
TokenClaims {
|
||||||
|
app_id: app,
|
||||||
|
topics: topics.iter().map(|s| (*s).to_string()).collect(),
|
||||||
|
iat: 1000,
|
||||||
|
exp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_verifies() {
|
||||||
|
let key = b"super-secret-key-bytes-0123456789";
|
||||||
|
let app = AppId::new();
|
||||||
|
let c = claims(app, &["chat.room.1", "user.notify"], 5000);
|
||||||
|
let token = sign(key, &c);
|
||||||
|
let got = verify(key, &token, 2000).expect("valid token verifies");
|
||||||
|
assert_eq!(got, c);
|
||||||
|
assert!(got.allows_topic("chat.room.1"));
|
||||||
|
assert!(!got.allows_topic("chat.room.2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampered_payload_fails() {
|
||||||
|
let key = b"super-secret-key-bytes-0123456789";
|
||||||
|
let app = AppId::new();
|
||||||
|
let token = sign(key, &claims(app, &["t"], 5000));
|
||||||
|
// Flip a character in the payload half.
|
||||||
|
let (payload, sig) = token.split_once('.').unwrap();
|
||||||
|
let mut bytes = payload.as_bytes().to_vec();
|
||||||
|
bytes[0] ^= 0x01;
|
||||||
|
let tampered = format!("{}.{sig}", String::from_utf8_lossy(&bytes));
|
||||||
|
assert_eq!(verify(key, &tampered, 2000), Err(TokenError::BadSignature));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampered_signature_fails() {
|
||||||
|
let key = b"super-secret-key-bytes-0123456789";
|
||||||
|
let app = AppId::new();
|
||||||
|
let token = sign(key, &claims(app, &["t"], 5000));
|
||||||
|
let (payload, _sig) = token.split_once('.').unwrap();
|
||||||
|
// A valid-base64 but wrong signature.
|
||||||
|
let bogus = URL_SAFE_NO_PAD.encode([0u8; 32]);
|
||||||
|
let tampered = format!("{payload}.{bogus}");
|
||||||
|
assert_eq!(verify(key, &tampered, 2000), Err(TokenError::BadSignature));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_key_fails() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let token = sign(
|
||||||
|
b"key-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
&claims(app, &["t"], 5000),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
verify(b"key-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", &token, 2000),
|
||||||
|
Err(TokenError::BadSignature)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expired_token_fails_at_expiry_check() {
|
||||||
|
let key = b"super-secret-key-bytes-0123456789";
|
||||||
|
let app = AppId::new();
|
||||||
|
let token = sign(key, &claims(app, &["t"], 5000));
|
||||||
|
// now == exp → expired (>= boundary).
|
||||||
|
assert_eq!(verify(key, &token, 5000), Err(TokenError::Expired));
|
||||||
|
assert_eq!(verify(key, &token, 9999), Err(TokenError::Expired));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_token_fails() {
|
||||||
|
let key = b"super-secret-key-bytes-0123456789";
|
||||||
|
assert_eq!(verify(key, "no-dot-here", 0), Err(TokenError::Malformed));
|
||||||
|
assert_eq!(verify(key, "a.b.c", 0), Err(TokenError::Malformed));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,16 @@ pub const PRODUCT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
|||||||
/// publish-time fan-out and `ctx.event.pubsub` for pubsub-trigger
|
/// publish-time fan-out and `ctx.event.pubsub` for pubsub-trigger
|
||||||
/// handlers. The `Services` bundle gains `files: Arc<dyn FilesService>`
|
/// handlers. The `Services` bundle gains `files: Arc<dyn FilesService>`
|
||||||
/// and `pubsub: Arc<dyn PubsubService>`.
|
/// and `pubsub: Arc<dyn PubsubService>`.
|
||||||
pub const SDK_VERSION: &str = "1.6";
|
///
|
||||||
|
/// 1.7 additions (v1.1.6): `pubsub::subscriber_token(topics, ttl)` —
|
||||||
|
/// mints an HMAC-signed realtime subscriber token for externally-
|
||||||
|
/// subscribable topics (requires an authenticated principal). This is
|
||||||
|
/// the only new script-visible surface; the rest of the release is
|
||||||
|
/// server-side (the SSE `/realtime/topics/{topic}` endpoint; the
|
||||||
|
/// `RealtimeBroadcaster` / `RealtimeEvent` / `RealtimeAuthority` traits;
|
||||||
|
/// the `topics` registry + admin endpoints; the `@picloud/client`
|
||||||
|
/// TypeScript package).
|
||||||
|
pub const SDK_VERSION: &str = "1.7";
|
||||||
|
|
||||||
/// 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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.11.0",
|
"version": "0.12.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -270,6 +270,28 @@ export interface CreatePubsubTriggerInput {
|
|||||||
retry_base_ms?: number;
|
retry_base_ms?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.1.6 — externally-subscribable realtime topics.
|
||||||
|
export type TopicAuthMode = 'public' | 'token';
|
||||||
|
|
||||||
|
export interface Topic {
|
||||||
|
name: string;
|
||||||
|
external_subscribable: boolean;
|
||||||
|
auth_mode: TopicAuthMode;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTopicInput {
|
||||||
|
name: string;
|
||||||
|
external_subscribable: boolean;
|
||||||
|
auth_mode: TopicAuthMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTopicInput {
|
||||||
|
external_subscribable?: boolean;
|
||||||
|
auth_mode?: TopicAuthMode;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExecutionResult {
|
export interface ExecutionResult {
|
||||||
status: number;
|
status: number;
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
@@ -653,6 +675,28 @@ export const api = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
topics: {
|
||||||
|
list: (idOrSlug: string) =>
|
||||||
|
adminRequest<{ topics: Topic[] }>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics`
|
||||||
|
),
|
||||||
|
create: (idOrSlug: string, input: CreateTopicInput) =>
|
||||||
|
adminRequest<Topic>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}),
|
||||||
|
update: (idOrSlug: string, name: string, input: UpdateTopicInput) =>
|
||||||
|
adminRequest<Topic>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics/${encodeURIComponent(name)}`,
|
||||||
|
{ method: 'PATCH', body: JSON.stringify(input) }
|
||||||
|
),
|
||||||
|
remove: (idOrSlug: string, name: string) =>
|
||||||
|
adminRequest<null>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics/${encodeURIComponent(name)}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
files: {
|
files: {
|
||||||
list: (idOrSlug: string, collection: string, opts: { cursor?: string; limit?: number } = {}) => {
|
list: (idOrSlug: string, collection: string, opts: { cursor?: string; limit?: number } = {}) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
type AppMemberDto,
|
type AppMemberDto,
|
||||||
type AppRole,
|
type AppRole,
|
||||||
type Script,
|
type Script,
|
||||||
type Trigger
|
type Trigger,
|
||||||
|
type Topic,
|
||||||
|
type TopicAuthMode
|
||||||
} 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';
|
||||||
@@ -25,7 +27,7 @@
|
|||||||
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' | 'triggers';
|
type Tab = 'scripts' | 'domains' | 'members' | 'settings' | 'triggers' | 'topics';
|
||||||
|
|
||||||
// Common IANA timezones offered in the cron form dropdown. Not
|
// Common IANA timezones offered in the cron form dropdown. Not
|
||||||
// exhaustive — the backend validates any IANA name via chrono-tz.
|
// exhaustive — the backend validates any IANA name via chrono-tz.
|
||||||
@@ -194,6 +196,100 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Topics tab (v1.1.6 — externally-subscribable realtime topics). Admin-gated.
|
||||||
|
let topics = $state<Topic[]>([]);
|
||||||
|
let createTopicName = $state('');
|
||||||
|
let createTopicExternal = $state(false);
|
||||||
|
let createTopicAuthMode = $state<TopicAuthMode>('public');
|
||||||
|
let creatingTopic = $state(false);
|
||||||
|
let createTopicError = $state<string | null>(null);
|
||||||
|
// Edit modal.
|
||||||
|
let topicToEdit = $state<Topic | null>(null);
|
||||||
|
let editTopicExternal = $state(false);
|
||||||
|
let editTopicAuthMode = $state<TopicAuthMode>('public');
|
||||||
|
let savingTopic = $state(false);
|
||||||
|
let editTopicError = $state<string | null>(null);
|
||||||
|
// Flipping internal → external is the security-sensitive change.
|
||||||
|
const editFlipToExternal = $derived(
|
||||||
|
!!topicToEdit && !topicToEdit.external_subscribable && editTopicExternal
|
||||||
|
);
|
||||||
|
// Delete confirm.
|
||||||
|
let topicToRemove = $state<Topic | null>(null);
|
||||||
|
let removingTopic = $state(false);
|
||||||
|
|
||||||
|
async function loadTopics(idOrSlug: string) {
|
||||||
|
try {
|
||||||
|
const r = await api.topics.list(idOrSlug);
|
||||||
|
topics = r.topics;
|
||||||
|
} catch {
|
||||||
|
topics = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreateTopic(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!app) return;
|
||||||
|
creatingTopic = true;
|
||||||
|
createTopicError = null;
|
||||||
|
try {
|
||||||
|
await api.topics.create(app.id, {
|
||||||
|
name: createTopicName.trim(),
|
||||||
|
external_subscribable: createTopicExternal,
|
||||||
|
auth_mode: createTopicAuthMode
|
||||||
|
});
|
||||||
|
createTopicName = '';
|
||||||
|
createTopicExternal = false;
|
||||||
|
createTopicAuthMode = 'public';
|
||||||
|
await loadTopics(app.id);
|
||||||
|
} catch (err) {
|
||||||
|
createTopicError =
|
||||||
|
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
creatingTopic = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditTopic(t: Topic) {
|
||||||
|
topicToEdit = t;
|
||||||
|
editTopicExternal = t.external_subscribable;
|
||||||
|
editTopicAuthMode = t.auth_mode;
|
||||||
|
editTopicError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmEditTopic() {
|
||||||
|
if (!app || !topicToEdit) return;
|
||||||
|
savingTopic = true;
|
||||||
|
editTopicError = null;
|
||||||
|
try {
|
||||||
|
await api.topics.update(app.id, topicToEdit.name, {
|
||||||
|
external_subscribable: editTopicExternal,
|
||||||
|
auth_mode: editTopicAuthMode
|
||||||
|
});
|
||||||
|
topicToEdit = null;
|
||||||
|
await loadTopics(app.id);
|
||||||
|
} catch (err) {
|
||||||
|
editTopicError =
|
||||||
|
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
savingTopic = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRemoveTopic() {
|
||||||
|
if (!app || !topicToRemove) return;
|
||||||
|
removingTopic = true;
|
||||||
|
try {
|
||||||
|
await api.topics.remove(app.id, topicToRemove.name);
|
||||||
|
topicToRemove = null;
|
||||||
|
await loadTopics(app.id);
|
||||||
|
} catch (err) {
|
||||||
|
createTopicError =
|
||||||
|
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
removingTopic = 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);
|
||||||
@@ -234,7 +330,12 @@
|
|||||||
loadDeadLetterCount(app.id)
|
loadDeadLetterCount(app.id)
|
||||||
];
|
];
|
||||||
if (canAdmin) {
|
if (canAdmin) {
|
||||||
loaders.push(loadMembers(app.id), loadEligibleUsers(), loadTriggers(app.id));
|
loaders.push(
|
||||||
|
loadMembers(app.id),
|
||||||
|
loadEligibleUsers(),
|
||||||
|
loadTriggers(app.id),
|
||||||
|
loadTopics(app.id)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await Promise.all(loaders);
|
await Promise.all(loaders);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -503,7 +604,10 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (
|
if (
|
||||||
!canAdmin &&
|
!canAdmin &&
|
||||||
(activeTab === 'settings' || activeTab === 'members' || activeTab === 'triggers')
|
(activeTab === 'settings' ||
|
||||||
|
activeTab === 'members' ||
|
||||||
|
activeTab === 'triggers' ||
|
||||||
|
activeTab === 'topics')
|
||||||
) {
|
) {
|
||||||
activeTab = 'scripts';
|
activeTab = 'scripts';
|
||||||
}
|
}
|
||||||
@@ -551,6 +655,11 @@
|
|||||||
class:active={activeTab === 'triggers'}
|
class:active={activeTab === 'triggers'}
|
||||||
onclick={() => (activeTab = 'triggers')}>Triggers ({triggers.length})</button
|
onclick={() => (activeTab = 'triggers')}>Triggers ({triggers.length})</button
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={activeTab === 'topics'}
|
||||||
|
onclick={() => (activeTab = 'topics')}>Topics ({topics.length})</button
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:active={activeTab === 'settings'}
|
class:active={activeTab === 'settings'}
|
||||||
@@ -939,6 +1048,89 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
{:else if activeTab === 'topics' && canAdmin}
|
||||||
|
<section>
|
||||||
|
<h2>Realtime topics</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Pub/sub topics are <strong>internal-only</strong> by default — scripts
|
||||||
|
subscribe via triggers, browsers can't. Register a topic here and mark it
|
||||||
|
<strong>externally subscribable</strong> to let frontend clients connect over
|
||||||
|
SSE at <code>/realtime/topics/<name></code>. <code>public</code> topics
|
||||||
|
need no auth; <code>token</code> topics require a subscriber token minted by a
|
||||||
|
script via <code>pubsub::subscriber_token</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form class="create-form" onsubmit={submitCreateTopic}>
|
||||||
|
<div class="row">
|
||||||
|
<label class="grow">
|
||||||
|
<span>Topic name</span>
|
||||||
|
<input bind:value={createTopicName} required placeholder="chat-room-updates" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" bind:checked={createTopicExternal} />
|
||||||
|
<span>Externally subscribable (allow browser SSE clients to subscribe)</span>
|
||||||
|
</label>
|
||||||
|
{#if createTopicExternal}
|
||||||
|
<fieldset class="auth-mode">
|
||||||
|
<legend>Auth mode</legend>
|
||||||
|
<label class="radio-row">
|
||||||
|
<input type="radio" value="public" bind:group={createTopicAuthMode} />
|
||||||
|
<span><strong>public</strong> — anyone with the URL can subscribe</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-row">
|
||||||
|
<input type="radio" value="token" bind:group={createTopicAuthMode} />
|
||||||
|
<span><strong>token</strong> — requires a valid subscriber token</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
{/if}
|
||||||
|
{#if createTopicError}
|
||||||
|
<div class="error">{createTopicError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" disabled={creatingTopic || !createTopicName.trim()}>
|
||||||
|
{creatingTopic ? 'Creating…' : 'Register topic'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if topics.length === 0}
|
||||||
|
<p class="muted">No registered topics in this app yet.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="list">
|
||||||
|
{#each topics as t (t.name)}
|
||||||
|
<li class="domain-row">
|
||||||
|
<div>
|
||||||
|
<code>{t.name}</code>
|
||||||
|
{#if t.external_subscribable}
|
||||||
|
<span class="badge badge-external" title="Browser clients can subscribe over SSE">
|
||||||
|
external
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-auth">{t.auth_mode}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="badge badge-internal" title="Internal-only: scripts subscribe via triggers">
|
||||||
|
internal
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="muted small">· {shortDate(t.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="topic-actions">
|
||||||
|
<button type="button" class="secondary" onclick={() => openEditTopic(t)}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary danger"
|
||||||
|
onclick={() => (topicToRemove = t)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
{:else if activeTab === 'settings' && canAdmin}
|
{:else if activeTab === 'settings' && canAdmin}
|
||||||
<section>
|
<section>
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
@@ -1113,6 +1305,65 @@
|
|||||||
</p>
|
</p>
|
||||||
</ConfirmModal>
|
</ConfirmModal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if topicToEdit}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Edit topic “{topicToEdit.name}”"
|
||||||
|
confirmLabel="Save changes"
|
||||||
|
busyLabel="Saving…"
|
||||||
|
busy={savingTopic}
|
||||||
|
onConfirm={confirmEditTopic}
|
||||||
|
onCancel={() => (topicToEdit = null)}
|
||||||
|
>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" bind:checked={editTopicExternal} />
|
||||||
|
<span>Externally subscribable</span>
|
||||||
|
</label>
|
||||||
|
{#if editTopicExternal}
|
||||||
|
<fieldset class="auth-mode">
|
||||||
|
<legend>Auth mode</legend>
|
||||||
|
<label class="radio-row">
|
||||||
|
<input type="radio" value="public" bind:group={editTopicAuthMode} />
|
||||||
|
<span><strong>public</strong> — anyone with the URL can subscribe</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-row">
|
||||||
|
<input type="radio" value="token" bind:group={editTopicAuthMode} />
|
||||||
|
<span><strong>token</strong> — requires a valid subscriber token</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
{/if}
|
||||||
|
{#if editFlipToExternal}
|
||||||
|
<div class="warning">
|
||||||
|
Marking <strong>{topicToEdit.name}</strong> externally-subscribable means
|
||||||
|
anyone with the URL can subscribe to this topic (if auth_mode is
|
||||||
|
<code>public</code>) or anyone with a valid token can subscribe (if
|
||||||
|
auth_mode is <code>token</code>). Are you sure?
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if editTopicError}
|
||||||
|
<p class="modal-error">{editTopicError}</p>
|
||||||
|
{/if}
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if topicToRemove}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete topic “{topicToRemove.name}”"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Delete topic"
|
||||||
|
busyLabel="Deleting…"
|
||||||
|
busy={removingTopic}
|
||||||
|
onConfirm={confirmRemoveTopic}
|
||||||
|
onCancel={() => (topicToRemove = null)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Unregistering <code>{topicToRemove.name}</code> disconnects any live SSE
|
||||||
|
subscribers immediately. Scripts can still <code>publish_durable</code> to
|
||||||
|
it (internal triggers keep working) — it just won't be externally
|
||||||
|
subscribable.
|
||||||
|
</p>
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -1463,4 +1714,64 @@
|
|||||||
.small {
|
.small {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkbox-row,
|
||||||
|
.radio-row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row input,
|
||||||
|
.radio-row input {
|
||||||
|
flex: none;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-mode {
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-mode legend {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 0.45rem;
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-external {
|
||||||
|
background: #064e3b;
|
||||||
|
color: #6ee7b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-internal {
|
||||||
|
background: #334155;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-auth {
|
||||||
|
background: #1e3a5f;
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user