Compare commits

...

115 Commits

Author SHA1 Message Date
MechaCat02
ae98063f87 test(api): pin /version schema assertion to migration 65 (D2/D3)
Some checks failed
CI / Rust — fmt, clippy, test (push) Failing after 18m47s
CI / Dashboard — check (push) Successful in 9m47s
D2/D3 added 0064 + 0065; re-pin the #[ignore]-gated version test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:40:20 +02:00
MechaCat02
c361048022 test/docs(shared-queues): journey + docs (D3.3)
CLI journey (shared_queues): authoring + `pic triggers ls --group` shared
column + the two validation rejections (undeclared queue collection; shared
on an app), mirroring the shared_topics/shared_triggers norm; the live
competing-consumer + dispatcher behaviour is pinned by the deterministic
manager-core tests. Docs: CLAUDE.md + design doc §11.6 record D3 as shipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:35:40 +02:00
MechaCat02
50205e7b7e feat(shared-queues): materialized competing consumers + dispatcher branch (D3.2)
The consumption side of shared durable queues:
- Thread `shared` through BundleTrigger::Queue + QueueTriggerSpec + the
  trigger identity; validate_bundle_for requires a shared queue on a group
  to name a declared kind='queue' collection (a shared queue on an app is
  rejected by the existing app-owner shared guard).
- materialize: a shared queue template materializes a consumer per
  descendant (the M5 one-consumer-slot skip is bypassed for shared —
  competing consumers are intended; each descendant gets one copy).
- dispatcher: ActiveQueueConsumer gains shared_group (from the materialized
  copy's source template via LEFT JOIN); dispatch_one_queue +
  handle_queue_failure route claim/ack/nack/terminal to the group store
  when shared_group is Some, via q_claim/q_ack/q_nack/q_terminal helpers. A
  group claim is normalized to a ClaimedMessage under the consuming app so
  the handler path is unchanged; the reclaim task also drains the group
  store. Exhausted shared messages are dropped (no group dead-letter store
  yet — documented deferral).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:33:11 +02:00
MechaCat02
ccd3644aa4 feat(shared-queues): group-keyed queue store + enqueue service + SDK (D3.1)
The producer side of shared durable queues:
- migration 0065 group_queue_messages (mirrors 0034, keyed by (group_id,
  collection); CASCADE on group delete).
- GroupQueueRepo/PostgresGroupQueueRepo: enqueue + the competing-consumer
  claim (FOR UPDATE SKIP LOCKED) + ack/nack/drop_exhausted/reclaim/depth.
- GroupQueueService trait (shared) + GroupQueueServiceImpl: resolve owning
  group (kind='queue') from cx.app_id's chain, require editor+
  (GroupQueueEnqueue, fails closed on anon), size-cap, enqueue.
- SDK: `queue::shared_collection("name")` -> GroupQueueHandle with
  `.enqueue(msg[, opts])` / `.depth()` / `.depth_pending()`; wired through
  Services + the picloud binary.
- authz: Capability::GroupQueueEnqueue(GroupId), editor+ / script:write.

Deterministic test proves competing consumers claim each message exactly
once. Consumption wiring (materialized consumers + dispatcher branch) lands
in D3.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:08:21 +02:00
MechaCat02
19ba6dbfb4 test/docs(shared-topics): dispatch test + journey + docs (D2)
Deterministic manager-core test (tests/shared_topics.rs) proves the
namespace boundary both ways: a shared publish fans out only to the
group's shared pubsub trigger (not a per-app or non-shared group
template), and a per-app publish never hits the shared trigger. CLI
journey (shared_topics) covers authoring + `pic triggers ls --group`
shared column + the two validation rejections (undeclared topic; shared
on an app), mirroring the shared_triggers norm. Docs: CLAUDE.md + design
doc §11.6 record D1 + D2 as shipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:54:32 +02:00
MechaCat02
9626d15863 feat(shared-topics): GroupPubsubService + shared publish fan-out + SDK handle (D2)
The runtime for shared topics:
- pubsub_repo: fan_out_publish gains `AND t.shared = FALSE` (a shared
  trigger never fires on a per-app publish); new fan_out_shared_publish
  matches `shared = true` pubsub triggers on the resolved owning group,
  each delivery stamped with the writer app_id (M2 model).
- GroupPubsubService trait (shared) + GroupPubsubServiceImpl: resolve the
  owning group (kind='topic') from cx.app_id's chain, require editor+
  (GroupPubsubPublish, fails closed for anon), size-cap, fan out.
- SDK: `pubsub::shared_topic("name")` -> GroupTopicHandle with
  `.publish(subtopic, msg)` / `.publish(msg)`; wired through Services +
  the picloud binary (reuses the one PubsubRepo).
- authz: Capability::GroupPubsubPublish(GroupId), editor+ / script:write.

The owning-group chain walk is the isolation boundary; a sibling-subtree
app never resolves the topic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:48:59 +02:00
MechaCat02
1c7e8886d8 feat(shared-topics): thread the shared flag through pubsub triggers (D2)
Add `shared` to BundleTrigger::Pubsub + PubsubTriggerSpec + the trigger
identity (so toggling re-diffs) + current_trigger_identity. validate_bundle_for
now allows a `shared` pubsub trigger on a group and requires the topic
pattern's ROOT segment (events.* -> events) be a declared kind='topic'
collection; a wildcard root is a runtime match. Apply-side plumbing only —
the shared publish path + dispatch boundary land next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:37:31 +02:00
MechaCat02
ae8f0be748 feat(shared-collections): admit 'topic' and 'queue' collection kinds (D2/D3)
Widen the group_collections kind allow-list (migration 0064) + the
manifest CollectionKind enum + apply_service COLLECTION_KINDS to include
'topic' (D2, storeless publish namespace) and 'queue' (D3, group-keyed
durable queue — store lands in 0065). Foundation shared by both
milestones; routes through the existing owner-generic reconcile +
resolve_owning_group path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:32:41 +02:00
MechaCat02
137103a429 feat(triggers): show a materialized column in pic triggers ls --app (D1)
A materialized copy of an M5 group stateful template (cron/queue/email) now
reads as `materialized = true` — inherited, read-only — distinct from a
hand-authored trigger. Threads a derived `materialized` bool (=
`materialized_from IS NOT NULL`) row → domain → API → CLI, mirroring the
`sealed`/`shared` columns; `list_for_app` SELECTs `materialized_from`. No
migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:30:32 +02:00
MechaCat02
849bd367b9 test(triggers): mirror the app_id-not-null inbound guard in the mock (review)
Adversarial review NIT: the #[cfg(test)] InMemoryTriggerRepo's
email_inbound_target `.expect()`s app_id, which would panic if a future
unit test fetched a group-owned email TEMPLATE. Filter `app_id.is_some()`
like the production query's `AND t.app_id IS NOT NULL` so the mock stays
faithful. Test-only; no production change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:05:23 +02:00
MechaCat02
6b1831aea2 test(api): pin /version schema assertion to migration 63
The #[ignore]-gated version_includes_public_base_url pinned schema=42 but
migrations accreted to 63 across the v1.2 template track (…0060–0063), so
it failed under `--include-ignored`. Re-pin to current reality per the
test's own intent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:38:05 +02:00
MechaCat02
186b6f1dcc docs(stateful-templates): group email materialization is shipped (M5.5)
Update the design doc §4.5 and CLAUDE.md current-focus: email joins
cron+queue as a materialized stateful template via the shared-group-secret
model; drop the email deferral and the stale "email rejected on a group"
note; retarget "Next" to multi-node / multi-repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:31:23 +02:00
MechaCat02
da83222ea1 test(stateful-templates): group email template journey + load group secrets (M5.5)
CLI journey: set a group secret, apply a group [[triggers.email]] template
referencing it, create an app under the group → `pic triggers ls --app`
shows a materialized email trigger; re-apply is a NoOp.

Required loading a group's own set-secret names into CurrentState (was
hardcoded empty) so the apply plan-time email-secret check resolves the
ref against the GROUP store. Informational only — the secrets diff is
never executed on apply (secrets are set out-of-band via `pic secret
set`); the state token now folds in group secrets, matching apps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:29:23 +02:00
MechaCat02
e2457efcbe test(stateful-templates): group email template materialization (M5.5)
Drive rematerialize directly with a group email template carrying a fake
sealed secret: a descendant app gets one copy whose inbound-secret
ciphertext + nonce byte-equal the template's (verbatim copy, no reseal),
the reconcile is idempotent, and reparenting the app out of the subtree
de-materializes the copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:25:22 +02:00
MechaCat02
9dc9191cb2 feat(stateful-templates): materialize group email templates (M5.5)
Close the last M5 gap: a [group] may now declare an email trigger
template, materialized into a per-descendant-app row like cron/queue.

Uses the shared-group-secret model: the template resolves its
inbound_secret_ref against the GROUP's own secret store once at apply and
seals it (resolve_and_seal + insert_email_trigger_tx generalized to a
SecretOwner / ScriptOwner). Email secrets are v0/no-AAD, so the sealed
blob is portable across rows — materialize copies the bytes verbatim into
each descendant (no master key, no reseal, no new schema column, no
threading into the CRUD hooks). Each copy has its own trigger_id / inbound
webhook URL.

- apply_service/manifest: drop the two group-email rejections; a group
  email template with an unset group secret still fails apply hard.
- materialize: add "email" to MATERIALIZED_KINDS + a byte-copy detail arm.
- trigger_repo: email_inbound_target gains AND t.app_id IS NOT NULL so a
  group TEMPLATE row is never directly invocable via its own webhook URL
  (mirrors the cron/queue app_id guards).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:24:10 +02:00
MechaCat02
05373814b2 fix(stateful-templates): serialize the materialization reconciler (review)
The reconciler read `should` and `existing` in separate statements under READ
COMMITTED, and its queue one-consumer check wasn't locked — so two concurrent
reconcilers (e.g. two parallel app-creates) could (a) spuriously DELETE a valid
materialized cron copy when one committed a copy between the other's two reads
(a lost copy self-healing only on the next mutation), or (b) both pass the
queue slot check and double-fill a consumer slot. Take a fixed xact-scoped
advisory lock (REMATERIALIZE_LOCK_KEY) at the top of the reconcile so each pass
is atomic w.r.t. the others. Reconciles are infrequent + fast, so serializing
is cheap; the ON CONFLICT guard stays as defense in depth.

Found by the M5 review. Pinned by the existing stateful_templates test + the
full journey suite (which previously flaked on the race).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:06:11 +02:00
MechaCat02
aa5bb3e8cc feat(stateful-templates): journey + docs + concurrency fix; email deferred (M5.6)
- 0063: partial unique index on (app_id, materialized_from) + ON CONFLICT DO
  NOTHING in the reconciler, so a materialized copy is idempotent under
  concurrent reconciles (two parallel app-creates no longer duplicate a copy).
- stateful_templates journey: a group cron template + a descendant app → a
  materialized cron copy in `pic triggers ls --app`; re-apply is a NoOp.
- docs (§4.5 + CLAUDE.md): cron+queue materialization implemented; group EMAIL
  templates deferred (per-descendant inbound-secret reseal needs the master key
  threaded through the hooks + a secret-ref schema) and cleanly rejected at
  apply for now, alongside a `materialized` ls column.

Completes the v1.2 near-term batch (M1-M5, cron+queue); group email is the one
scoped follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:54:34 +02:00
MechaCat02
ddb3589c49 feat(stateful-templates): materialize cron+queue templates + hooks (M5.2-M5.4)
materialize::rematerialize_stateful_templates reconciles a group cron/queue
template into one app-owned copy per descendant (materialized_from = template),
via the all-apps app_chain CTE ⋈ group-owned stateful templates. A precise
create/delete diff preserves cron last_fired_at on unchanged copies; a queue
copy is skipped-with-warning when the app already fills that queue's consumer
slot (the one-consumer invariant). Called full-live at the route-rebuild
chokepoints: apply (single + tree), app create/delete (apps_api), group
reparent (groups_api) — AppsState/GroupsState gain a pool. Pinned by
tests/stateful_templates.rs (per-app copy, idempotent, new-app materializes,
reparent de-materializes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:47:27 +02:00
MechaCat02
456e972336 feat(stateful-templates): schema + dispatch guards + allow cron/queue on group (M5.1)
0062 adds `materialized_from` on triggers (a managed app-owned copy links back
to its group template; CASCADE). The scheduler + queue-consumer queries gain
`AND t.app_id IS NOT NULL` so a group-owned stateful TEMPLATE is never
dispatched directly — only the per-descendant materialized app rows are.
validate_bundle_for + manifest parse now allow cron/queue on a group (email
stays rejected pending its per-app inbound-secret handling in M5.5). The
materialization reconcile that expands templates into app rows lands in M5.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:34:14 +02:00
MechaCat02
24d834a102 feat(group-blobs): pic kv ls/get --group + journey + docs (M4.3+M4.4)
- `pic kv ls --group` / `pic kv get --group` (mutually exclusive with --app)
  hit the M4 admin API; client group_kv_list/group_kv_get.
- collections journey: a script writes a shared KV value, the operator lists +
  fetches it via the admin API with no script.
- docs: §11.6 + CLAUDE.md record M3 quotas + M4 read-only admin API.

Completes M4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:26:12 +02:00
MechaCat02
c21e82fb9f feat(group-blobs): read-only operator admin API for shared collections (M4.1+M4.2)
group_blobs_api mirrors the per-app kv_api/files_api for groups: GET
/groups/{id}/kv[/{c}/{k}], /docs[/{c}/{id}], /files[/{c}/{id}] — list +
fetch/download a group's §11.6 shared KV/docs/files. Authz GroupKvRead/
GroupDocsRead/GroupFilesRead (viewer tier, reads-open trust model). Read-only
by design (writes stay script-only). The host hoists the three group repos so
the SDK services and this router share one instance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:22:07 +02:00
MechaCat02
2bc3fb677b feat(group-quota): per-group shared-collection quotas (M3)
Global env-var ceilings enforced in the group write path (mirrors the
per-value caps): PICLOUD_GROUP_KV_MAX_ROWS / _DOCS_MAX_ROWS (per-group row
count, checked only on a NEW key/doc — updates exempt) and
_FILES_MAX_TOTAL_BYTES (per-group total blob bytes). New group_quota env
helpers; count_rows/total_bytes repo methods (trait-default 0 for non-Postgres);
QuotaExceeded error variants on the three group error enums; a with_max_rows
test builder. Pinned by a group_kv_service unit test (3rd key rejected, update
of an existing key at the cap allowed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:17:12 +02:00
MechaCat02
9a4b83dfcf feat(shared-triggers): visibility + tests + docs (M2.5)
- `shared` column in `pic triggers ls --group` (TriggerTemplateInfo + DTO +
  report + renderer).
- manager-core/tests/shared_triggers.rs: a shared write matches the group's
  shared trigger and NOT a same-named per-app trigger; a per-app write matches
  the app trigger and NOT the shared one (the `shared` flag is the boundary).
- shared_triggers journey: declarative authoring applies, ls --group shows
  shared, an undeclared-collection shared trigger + an app shared trigger are
  rejected.
- docs: §11.6 + CLAUDE.md move shared-collection triggers from Deferred to
  implemented.

Completes M2. (Pre-existing async-dispatcher timing flakes pass in isolation.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:05:30 +02:00
MechaCat02
f04eaed0b7 feat(shared-triggers): author + persist + validate shared templates (M2.4)
`shared = true` on a group [[triggers.kv|docs|files]] flows manifest → Bundle
→ insert_trigger_tx (new shared column), mirroring sealed: shared lives on the
Trigger DTO + is part of the apply diff identity (bundle + current sides) so a
toggle re-applies. validate_bundle_for rejects shared on an app owner, on a
non-collection kind (pubsub/cron/etc.), and on a concrete collection the group
does not declare as a shared collection of that kind. Emission (M2.3) now has
authored triggers to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:55:49 +02:00
MechaCat02
0210ace406 feat(shared-triggers): emit shared events from group services (M2.3)
ServiceEventEmitter gains emit_shared(cx, owning_group, event) (default no-op);
OutboxEventEmitter implements it — matches shared triggers on the owning group
and writes outbox rows stamped with the WRITER app_id (dispatch runs under the
writer). GroupKv/Docs/FilesServiceImpl gain an events field (Noop default +
with_events builder) and emit on set/create/update/delete; the host wires the
outbox emitter via with_events. Closes the "group trigger has no app to watch"
gap. Authoring + validation land in M2.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:33:09 +02:00
MechaCat02
cf296cd2fd feat(shared-triggers): match-query split for shared vs per-app (M2.2)
The per-app list_matching_kv/docs/files gain `AND t.shared = FALSE` (a shared
trigger never matches a per-app write). New list_matching_shared_{kv,docs,files}
select enabled `shared = TRUE` triggers on the OWNING group (glob+op filtered
in Rust), returning the same match types — no chain, no suppression anti-join.
Trait defaults return empty so non-Postgres impls degrade. Emission wiring in
M2.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:21:24 +02:00
MechaCat02
50d7a0a501 feat(shared-triggers): shared column on triggers (M2.1)
A group-owned trigger marked `shared = true` watches a §11.6 shared
collection instead of per-app collections — the namespace boundary that lets
a shared-collection write fire a trigger. Mirrors the sealed column; existing
rows default false (per-app, unchanged). Match-query split + emission land in
M2.2/M2.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:17:01 +02:00
MechaCat02
16267cec06 feat(suppress): group-suppress warnings + ls + journey + docs (M1.5)
- dangling_suppress_warnings takes an ApplyOwner and walks the group's own
  chain (GROUP_CHAIN_LEVELS_CTE) for a group node; runs for both owners.
- GET /groups/{id}/suppressions + `pic suppress ls --group` (client + cmd +
  main.rs; --app/--group mutually exclusive).
- suppress journey: a child group declines a parent template for its subtree;
  suppress ls --group shows the marker. Docs §4.5 + CLAUDE.md move group-level
  suppression from Deferred to implemented.

Completes M1. (An unrelated pre-existing email_inbound dispatch-timing flake
passes in isolation.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:16:22 +02:00
MechaCat02
f60fa36b0b feat(suppress): consume group suppressions via the chain (M1.3+M1.4)
Both suppression filters now match an owner's own OR any ancestor group's
suppression on the firing app's chain:
- trigger anti-join joins the `chain` CTE (ts.app_id = sc.app_owner OR
  ts.group_id = sc.group_owner) instead of ts.app_id = $1;
- list_route_suppressions expands group-owned suppressions across descendants
  via the all-apps app_chain CTE, yielding (effective_app_id, path) the rebuild
  consumes unchanged.

A child group declining a parent template opts out its whole subtree; a sibling
subtree still inherits; sealed still overrides. Pinned by
tests/group_suppression.rs; per-app suppression + sealed regressions green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:07:06 +02:00
MechaCat02
86c57e2e43 feat(suppress): owner-polymorphic suppression repo + reconcile (M1.2)
suppression_repo takes a ScriptOwner (app or group), binding the matching
owner column. apply_service load_current / create / prune / suppression_report
all thread owner.as_script_owner(); the validate_bundle_for group-suppress
rejection and the manifest parse guard are dropped — a [group] may now declare
[suppress] to decline a template it inherits from a higher ancestor. No
runtime consumption effect yet (the chain filters land in M1.3/M1.4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:04:32 +02:00
MechaCat02
cdef97a634 feat(suppress): polymorphic owner on template_suppressions (M1.1)
Reshape the app-only suppression marker to a group/app polymorphic owner
(mirrors 0056/0057 + the 0051/0052 config markers): nullable group_id
(CASCADE), nullable app_id, exactly-one CHECK, per-owner partial unique
indexes. Lets a [group] decline a template it inherits from a higher
ancestor for its whole subtree; consumption filters land in M1.3/M1.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:00:44 +02:00
MechaCat02
bfa4b48930 feat(sealed): sealed journey + docs (§11 tail M5)
- tests/sealed.rs: a group declares a sealed [[routes]] template; a descendant
  that suppresses it still serves the path, apply warns "sealed — no effect",
  routes ls --group shows sealed=true, and sealing an [app] route is rejected.
- docs: §4.5 trust-model callout + CLAUDE.md move `sealed` from Deferred to
  implemented — group templates are advisory-by-default *unless* sealed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 19:42:18 +02:00
MechaCat02
fa0702870a feat(sealed): sealed visibility + ineffective-suppress warning (§11 tail M4)
- `pic triggers ls --group` / `routes ls --group` gain a `sealed` column
  (threaded through TriggerTemplateInfo/RouteTemplateInfo + the two DTOs).
- dangling_suppress_warnings now also warns when a suppress reference matches
  only SEALED inherited templates ("... is sealed — the suppression has no
  effect") via `bool_or(NOT sealed)` per handler/path — making the mandatory
  guarantee observable at plan/apply time, alongside the existing typo guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 19:38:18 +02:00
MechaCat02
247e2836b8 feat(sealed): consume sealed at the two suppression gates (§11 tail M3)
The runtime effect: a sealed group template ignores a descendant's opt-out.
- Trigger dispatch: `AND t.sealed = FALSE` inside TRIGGER_SUPPRESSION_ANTIJOIN
  — a sealed row is never excluded, so it fires through the suppression (one
  edit covers list_matching_kv/docs/files + pubsub fan-out).
- Route rebuild: compile_effective_routes gates its suppression `continue` on
  `!er.route.sealed`, so a sealed inherited route stays in the app's slice.

Pinned by tests/sealed_templates.rs (live-DB): a sealed trigger + route
survive an app's suppression of both; an unsealed sibling with the same
suppression is still declined — the gate is exactly `sealed`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 19:34:22 +02:00
MechaCat02
95f20b4add feat(sealed): author + persist sealed group templates (§11 tail M2)
Thread `sealed` from the manifest through to the DB column:
- manifest: `sealed` on ManifestRoute + the four event-kind trigger specs
  (Kv/Docs/Files/Pubsub), flowing to Bundle via serde.
- persist: NewRoute.sealed + insert_route_tx; insert_trigger_tx `sealed`
  param; the reconcile passes br.sealed / bt.sealed().
- validate_bundle_for rejects `sealed` on an app owner (meaningless — an
  app route/trigger is never inherited).
- diff: `sealed` joins the route Update comparison and the trigger identity
  (both bundle + current sides), so toggling it re-applies rather than NoOp.

`sealed` lives on shared Route + manager-core Trigger (both pure DTOs) so
the diff can see the current value; RouteRow/TriggerRow default it so
RETURNING clauses that omit it still hydrate. No runtime read effect yet —
the two suppression gates land in M3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 19:32:20 +02:00
MechaCat02
483e4cb116 feat(sealed): sealed column on triggers + routes (§11 tail M1)
A group template can be marked `sealed = true` so the per-app suppression
filters skip it — closing the advisory-by-default compliance footgun the
suppression review flagged. Only meaningful on a group-owned template;
existing rows default unsealed. Consumption gates land in M3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 19:12:34 +02:00
MechaCat02
3df37b2b85 docs(suppress): flag advisory-by-default trust model; test dangling-suppress warning (§11 tail review)
Two review findings.

1. Per-app opt-out changes the group-template guarantee from "runs on every
   descendant" to "runs unless the descendant declines" — a footgun for
   compliance hooks (an audit/security trigger a tenant can silently opt out
   of). There is no non-suppressible flag. Document this in design §4.5 +
   CLAUDE.md, and record the deferred mitigation: a `sealed`/`mandatory`
   group-template marker the trigger anti-join + route filter skip (cheap —
   both filters are centralized).

2. The dangling-suppress warning path had no coverage. The suppress journey
   now applies a `[suppress] triggers=["does-not-exist"]`, asserts the apply
   still succeeds and the report warns "no effect".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:59:20 +02:00
MechaCat02
b74e1a401b feat(suppress): suppression journey + docs (§11 tail S5)
- Journey tests/suppress.rs: a group declares a `[[routes]]` template; a
  descendant app applies `[suppress] routes=["/ghello"]` → stops serving it
  (via `routes match`), a sibling still serves it, `pic suppress ls --app`
  shows it, re-apply is a NoOp; pruning the block re-inherits. The
  trigger-side filter + isolation are pinned at the repo layer by
  template_suppression.rs.
- Docs: design §4.5 (per-app opt-out closing the deferred gap for both
  templates — coarse-by-reference, inheritance-only, the two consumption
  points; group-level suppress still deferred), CLAUDE.md current-focus.

Full journey suite 121/121; workspace tests 34 suites/0 failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 08:05:06 +02:00
MechaCat02
9601046e29 feat(suppress): pic suppress ls --app + dangling-suppress warning (§11 tail S4)
Visibility + typo guard.

- `pic suppress ls --app <a>` — read-only (kind, reference):
  ApplyService::suppression_report(App) → GET /apps/{id}/suppressions
  (viewer-tier AppRead), client + cmd + main.rs wiring.
- Dangling-suppress warning: at apply, a suppress reference that matches no
  inherited template on the app's chain (no ancestor-group trigger bound to
  that script name / no ancestor-group route at that path) emits an
  ApplyReport warning — the suppression silently does nothing otherwise.
  Soft (never fails the apply), reusing the existing warnings channel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 08:02:04 +02:00
MechaCat02
4b5bf72a66 feat(suppress): consume per-app suppressions at trigger + route dispatch (§11 tail S3)
The runtime half — suppressions now actually decline inherited templates.

- Triggers (live, per-event): a correlated NOT EXISTS anti-join
  (TRIGGER_SUPPRESSION_ANTIJOIN) appended to all four dispatch match
  queries (list_matching_kv/docs/files + the pubsub fan-out). It excludes a
  group-owned trigger whose handler script name the firing app suppresses;
  `$1` is the firing app (already bound), and the `t.group_id IS NOT NULL`
  guard keeps an app's OWN trigger unsuppressable.
- Routes (rebuild-time): compile_effective_routes takes a
  `suppressed_paths: &HashSet<(AppId, path)>` and drops an inherited
  (`depth > 0`) route at a suppressed path — the binding 404s.
  rebuild_route_table loads the set via a new
  RouteRepository::list_route_suppressions, so every existing rebuild edge
  (route CRUD, apply, tree mutations) already applies it. No new
  invalidation edges; the marker CASCADEs on app delete.

Pinned by tests/template_suppression.rs (live DB): a suppressing app
matches NEITHER the inherited trigger NOR route; a sibling that did not
suppress still inherits both; the app's OWN trigger on the suppressed
handler still fires (suppression is inheritance-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 07:39:06 +02:00
MechaCat02
32cb6c1f1f feat(suppress): author + persist per-app template suppressions (§11 tail S2)
The declarative half — a `[suppress]` block persists as markers; no runtime
effect yet (S3 consumes them).

- manifest: `[suppress]` table on an app with `triggers = [...]` (handler
  script names) + `routes = [...]` (paths), `deny_unknown_fields`; rejected
  on a `[group]` (a group just wouldn't declare the template).
- suppression_repo.rs: app-keyed `list_for_app` / `insert` / `delete_tx`
  over `(app_id, target_kind, reference)`, mirroring extension_point_repo
  minus the owner polymorphism.
- apply_service: the extension-point marker-reconcile pattern —
  `Bundle.suppress_triggers/_routes`, `Plan.suppressions`,
  `CurrentState.suppressions`, `load_current(App)` load, `diff_suppressions`
  (key `"{kind}:{reference}"`, split on the first `:` so a route param path
  survives), create + prune reconcile blocks, `validate_bundle_for` group
  reject, `ApplyReport` counters.
- CLI: `build_bundle` carries the two vecs; `pic plan` + apply report gain a
  suppressions row (DTOs + display).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 07:32:26 +02:00
MechaCat02
18ac9f5afa feat(suppress): template_suppressions marker for per-app opt-out (§11 tail S1)
A group TRIGGER/ROUTE template inherits to every descendant app; until now
a descendant could shadow one but not decline it. This marker records that
an app opts OUT of a specific inherited template — coarse by REFERENCE (a
handler script name for triggers, a path for routes), not row id (template
ids churn on re-apply, references are stable so re-apply is a NoOp).

App-only (a group would just not declare the template) → app_id NOT NULL,
no polymorphic owner; pure app config → ON DELETE CASCADE. A target_kind
discriminator ('trigger'|'route') keeps it one table, one reconcile loop.

Schema blessed at 58 migrations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 07:20:46 +02:00
MechaCat02
b048daa700 refactor(routes): drop prod-dead compile_routes; document disabled-shadow (§11 tail review)
Two review findings.

1. After R3 every production rebuild path uses `compile_effective_routes`;
   `compile_routes` (the old app-only compile) was called only by its own
   two unit tests, and its "runs in build_app" doc was stale — so the
   lenient-skip + disabled-drop regression guards no longer covered the
   live path. Remove `compile_routes` + its re-export, fold the lenient-skip
   rationale onto `compile_one`, and port both guards to
   `compile_effective_routes` (app-only rows are just depth-0 effective
   rows). Add a third guard pinning nearest-owner shadowing at the pure-
   compile layer. Fix the stale `compile_routes` mention in apply_service.

2. Document the disabled + inherited semantic: the `enabled` filter runs
   before the shadow check, so a disabled own-route does not claim its
   binding and an enabled ancestor template at the same path falls through
   ("disabled = absent", §4.3). Recorded on `compile_effective_routes` and
   in design §4.5 — the corollary (can't 404 an inherited route by
   disabling a same-path own route) points at the deferred per-app opt-out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 22:14:09 +02:00
MechaCat02
a977ff6a32 feat(cli): pic routes ls --group + group-route journey + docs (§11 tail R5)
Visibility + end-to-end coverage + docs for group route templates.

- ApplyService::route_report(Group) → GET /api/v1/admin/groups/{id}/routes
  (viewer-tier GroupScriptsRead), surfacing a group's own route templates
  (method, host, path, handler script, dispatch, enabled).
- CLI: `pic routes ls --group <g>` (the script-id positional now conflicts
  with --group), client `group_routes_list` + RouteTemplateDto.
- Journey `group_routes.rs`: a group declares a `[[routes]]` template
  binding a group handler; a DESCENDANT app serves it (via `routes match`
  against the live RouteTable), a SIBLING-subtree app does not, `routes ls
  --group` shows it, re-apply is a NoOp. Shadowing + isolation are
  additionally pinned at the repo layer by group_route_templates.rs.
- Update the manifest unit test: a [group] now accepts route templates and
  rejects only the stateful trigger kinds.
- Docs: design §4.5 (the live route-template model + full-live
  invalidation + nearest-wins shadowing + deferrals), CLAUDE.md focus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 22:03:59 +02:00
MechaCat02
8f1ba39a8a feat(routes): author group route templates in manifest + reconcile (§11 tail R4)
Open the declarative path so a [group] can declare route TEMPLATES.

- manifest.rs: drop the blanket "[group] cannot declare [[routes]]"
  rejection (routes join the event trigger templates a group may own).
- apply_service:
  - validate_bundle_for: remove the group routes rejection. A group
    route's `script` is validated as binding a group-owned ENDPOINT via
    `resolve_inherited_targets_for(Group)`, now extended to scan
    bundle.routes as well as bundle.triggers — so a template binding the
    group's own (declared or pre-existing) endpoint resolves, and one
    pointing at a module or a missing name is a 422.
  - insert_bundle_route takes a ScriptOwner (was app-only): a group node
    writes a group_id route TEMPLATE, an app node an app-owned route.
  - load_current(Group) loads the group's own routes via list_for_group
    so re-apply is a NoOp and the template is prune-able.

Host-claim validation stays App-only (already guarded) — a template has
no single app; a descendant serves it on whatever host it claims (so
templates use host_kind = any in practice).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:54:55 +02:00
MechaCat02
96277e1e7c feat(routes): live group-route expansion + full-live invalidation (§11 tail R3)
The runtime half. Group route TEMPLATES now expand into every descendant
app's in-memory match slice, live, with no per-app materialization.

- `compile_effective_routes` consumes the `list_effective` expansion and
  applies NEAREST-OWNER-WINS shadowing: for one app, identical binding
  tuples (method+host+path) owned at multiple chain levels collapse to the
  nearest (an app's own route shadows an ancestor-group template); a nearer
  group beats a farther one. Non-identical bindings coexist and the
  existing matcher precedence resolves each request. `compile_route` now
  takes the effective app_id, so the matcher + dispatch are unchanged.
- `rebuild_route_table` is the single refresh chokepoint (list_effective →
  compile_effective_routes → replace_all). Route CRUD, the apply reconcile,
  and startup all route through it; the apply guards drop the "a group node
  touches no routes" assumption (a group apply may now create templates).
- FULL-LIVE INVALIDATION: app create/delete (apps_api) and group reparent
  (groups_api) rebuild the snapshot, so inherited routes take effect the
  instant the tree changes — matching the live model of triggers/vars.
  Best-effort, mirroring apply: a failed rebuild self-heals on the next
  write or restart, never failing the committed mutation.

The isolation boundary is the chain expansion: a template lands only in the
slices of apps whose ancestor chain contains the owning group. Pinned by a
deterministic live-DB integration test (descendant inherits, sibling
subtree does not, app shadows by identical binding, non-identical coexists).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:49:58 +02:00
MechaCat02
469e8526d7 feat(routes): owner-polymorphic Route + list_effective expansion query (§11 tail R2)
Make the route layer owner-aware ahead of the live rebuild. `Route.app_id`
becomes Option + a `group_id` (mirrors `Trigger`); `NewRoute` carries a
`ScriptOwner` so the interactive API passes App and the reconcile can pass
a group. `insert_route_tx` writes both owner columns; `RouteRow` selects
`group_id` everywhere (the interactive delete/list 404 a group template,
which is apply-managed, not app-addressable).

Adds the two repo methods the live rebuild + apply need:
- `list_for_group` — a group's own route templates (apply diff, ls).
- `list_effective` — the all-apps generalization of CHAIN_LEVELS_CTE: for
  every app, walk its ancestor chain and join routes owned at each level,
  tagging each with the effective (firing) app + the owner's chain depth.
  Runs only on a RouteTable rebuild, never per request; validated with
  EXPLAIN against the live schema.

`compile_route` now takes the effective app_id explicitly (reused next
commit for the per-app expansion); `compile_routes` skips group templates
(no single app) — they materialize via the effective path. Runtime
behavior is unchanged this commit: the table still rebuilds from
`list_all`, and no group routes can exist yet (authoring lands in R4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:39:07 +02:00
MechaCat02
0a583aa467 feat(routes): polymorphic owner on routes for group templates (§11 tail R1)
A group-owned route is a TEMPLATE, expanded into every descendant app's
in-memory RouteTable slice at rebuild (live, no materialization). The
schema reshape mirrors 0056_group_triggers exactly: a nullable group_id
FK→groups ON DELETE RESTRICT (a route template is a binding, not data),
app_id made nullable, an exactly-one owner CHECK, and the per-app unique
binding index split into per-owner partials. A group can't declare two
identical templates; an app declaring an identical binding is a
deliberate shadow resolved at rebuild (nearest-owner-wins), not a DB
conflict. Adds routes_group_id_idx for the expansion join.

Schema blessed at 57 migrations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:25:22 +02:00
MechaCat02
95007c124e fix(triggers): SELECT group_id in TriggerRepo::get for template fidelity (§11 tail review)
`get` can fetch any trigger by id, including a group-owned TEMPLATE. It
omitted `group_id` and leaned on `#[sqlx(default)]`, so a template would
hydrate with both owners None — silently breaking the exactly-one-owner
invariant in memory. No caller hits this today (interactive trigger
management is app-scoped; prune deletes by id), but the fetch now
round-trips the owner faithfully and stays honest for future callers.
`list_for_app` is left as-is: its rows are all app-owned, so group_id is
legitimately NULL there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:11:56 +02:00
MechaCat02
c492a08775 feat(cli): pic triggers ls --group + group-template journeys + docs (§11 tail T4)
- apply_service: trigger_report(group) → TriggerTemplateInfo
  (kind/target/script/enabled); resolve_inherited_targets_for(Group) now
  surfaces the group's OWN endpoint scripts so a template's handler
  validates (fixes "binds to unknown script" when the handler is a
  pre-existing group script, not declared in the same manifest).
- apply_api: GET /groups/{id}/triggers (GroupScriptsRead).
- CLI: `pic triggers ls --group <g>` (--app/--group mutually exclusive)
  + the client method + DTO.
- tests/group_trigger_templates.rs (manager-core, live DB): the chain
  union matches a descendant app's kv insert against the group template
  and NOT a sibling subtree — the isolation boundary, deterministic.
- tests/group_triggers.rs (journey): apply a kv template, ls --group
  shows it, re-apply NoOp, cron-on-group rejected.
- docs: design §4.5 (live-event-kinds decision + deferrals), CLAUDE.md.

Full journey suite 119/119; workspace tests 0 failures; clippy -D clean;
schema unchanged (blessed in T1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:02:03 +02:00
MechaCat02
72802de644 feat(modules): live chain-union dispatch for group trigger templates (§11 tail T3)
The runtime heart: a descendant app's event now fires its ancestor
groups' trigger templates, resolved LIVE (no materialization).

- trigger_repo: list_matching_kv/docs/files prepend CHAIN_LEVELS_CTE and
  `JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner)`
  (bind $1 = app_id), so each returns the app's own triggers PLUS
  ancestor-group templates in one statement. NULL-comparison semantics
  keep app/group rows from cross-matching; each row matches at exactly one
  chain depth (no dup fan-out).
- pubsub_repo: same union on the publish fan-out query.
- The outbox row stamps the firing app_id + the (group-owned) script_id;
  the dispatcher's existing script_invocable() (app-owned OR invocable via
  chain membership) already runs a group handler under a descendant app —
  the Phase-4 inheriting-app boundary, unchanged.

Glob/ops/topic filtering in Rust is untouched. 404 manager-core unit
tests green; clippy -D clean. Cross-subtree firing is journey-proven in T4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:43:01 +02:00
MechaCat02
547004e32d feat(modules): author group trigger templates (event kinds) (§11 tail T2)
A [group] node may now declare EVENT trigger templates (kv/docs/files/
pubsub) that bind a group-owned handler. cron/queue/email stay app-only
(rejected on a group at both the manifest and the reconcile layer).

- Trigger is now owner-polymorphic (app_id: Option, group_id: Option),
  mirroring Script's Phase-4 reshape; TriggerRow carries group_id
  (#[sqlx(default)] so interactive RETURNING clauses still hydrate).
- insert_trigger_tx takes a ScriptOwner (App XOR Group) and writes the
  group_id column; the queue advisory-lock branch is app-only.
- TriggerRepo::list_for_group loads a group's templates; load_current's
  Group arm uses it so the diff is NoOp on re-apply and prune-able.
- apply_service reconcile drops the "triggers are app-only" assumption;
  validate_bundle_for rejects non-event kinds on a group. The semantic-
  identity diff is already per-owner.
- CLI manifest allows event-kind [[triggers]] on [group], rejects
  cron/queue/email there.

Templates don't fire yet — the live dispatch union lands in T3. All
trigger/apply/manifest unit tests green; clippy -D clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:38:28 +02:00
MechaCat02
7f51087d5d feat(modules): polymorphic owner on triggers for group templates (§11 tail T1)
Reshape the triggers table to allow a GROUP owner, mirroring
0050_group_scripts exactly. A group-owned trigger is a TEMPLATE: never
dispatched directly, but unioned into every descendant app's match
queries via the ancestor-chain CTE (live, no per-app materialization).

- 0056_group_triggers.sql: add nullable group_id (FK→groups ON DELETE
  RESTRICT), make app_id nullable, add the exactly-one owner CHECK, and
  split the name-unique + dispatch-hot indexes into per-owner partials
  (idx_triggers_group_kind_enabled serves the chain-union lookups).
- Detail tables unchanged (they hang off trigger_id).

Schema snapshot blessed (56 migrations); existing trigger tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:20:21 +02:00
MechaCat02
e885ed5412 test(modules): pin orphan-sweep coverage of group-shared blobs (§11.6 files review)
Some checks failed
CI / Rust — fmt, clippy, test (push) Failing after 18m46s
CI / Dashboard — check (push) Successful in 9m46s
The §11.6 files slice relies on the orphan sweeper covering the new
`files/groups/<group_id>/...` subtree "for free" via its recursive walk
of `<root>/files/`, with no sweeper code change. Lock that guarantee
with an explicit test so a future sweeper refactor can't silently drop
group-shared tmp files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:04:09 +02:00
MechaCat02
9c6d690792 test(cli): shared-files journey + .gitignore + docs (§11.6 files C5)
- collections.rs: shared_files_collection_is_read_write_across_the_subtree
  — a group declares kv+docs+files in one string-or-table manifest; authed
  app A creates a blob via files::shared_collection("assets").create(...),
  app B lists + gets the SAME bytes back across the subtree, a
  sibling-subtree app gets CollectionNotShared, collections ls shows all
  three kinds. Live-verified the bytes land under
  files/groups/<group_id>/assets/<id[0:2]>/<id>.
- .gitignore: ignore /crates/picloud-cli/data (the CLI journey harness
  spawns the server from that dir; the files journey is the first CLI test
  to write blobs).
- docs: design §11.6 (KV+DOCS+FILES shipped; topics/queue still deferred),
  sdk-shape (files::shared_collection), CLAUDE.md current-focus.

Full journey suite 117/117; schema blessed (55 migrations); workspace
clippy -D clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:56:47 +02:00
MechaCat02
17221a2683 feat(cli): files kind in manifest + reconcile (§11.6 files C4)
- manifest: add CollectionKind::Files (+ "files" in as_str()); the
  string-or-table `collections` form now accepts { name, kind = "files" }.
- apply_service: add "files" to COLLECTION_KINDS. The rest of the
  reconcile (CollectionSpec, diff_collections, validate_bundle,
  state_token, the app-owner rejection) is already kind-generic.
- `pic collections ls` shows the files kind with no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:48:16 +02:00
MechaCat02
e7c0485dbf feat(modules): GroupFilesService + files::shared_collection handle (§11.6 files C3)
- shared/group_files: GroupFilesService trait (mirror FilesService, no
  group id arg — owner resolved from cx.app_id), GroupFilesError (clone of
  FilesError + CollectionNotShared) with From<FilesError>, Noop.
- services: group_files field + with_group_files setter (default Noop).
- group_files_service: GroupFilesServiceImpl — owning_group via the
  registry resolver (kind=files) → CollectionNotShared; reads open
  (script_gate), writes fail closed (script_gate_require_principal);
  reuses validate_files_collection + the NewFile/FileUpdate validators +
  sanitize_stored_content_type + the per-file size cap; no events.
- sdk/files: GroupFilesHandle + files::shared_collection(name) + the
  create/head/get/update/delete/list group methods (Blob in/out).
- picloud: construct FsGroupFilesRepo (sharing the files root + size cap)
  + GroupFilesServiceImpl, .with_group_files(...).

Unit tests: off-chain → CollectionNotShared; anon read OK / anon write
Forbidden; oversize → TooLarge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:46:39 +02:00
MechaCat02
3479afc56b feat(authz): GroupFilesRead/GroupFilesWrite capabilities (§11.6 files C2)
Clone of the GroupKv*/GroupDocs* arms at all five wiring sites
(Capability variants, app_id()⇒None, required_scope()⇒ScriptRead/Write,
the group_member_grants route, group_role_satisfies: Read=viewer+,
Write=editor+). Unit test mirrors group_docs_caps_resolve_by_role_up_the_chain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:38:46 +02:00
MechaCat02
779d906d75 feat(modules): group-files schema + owner-relative path helpers + repo (§11.6 files C1)
Extends the §11.6 shared-collection machinery to the filesystem-backed
`files` blob store (after KV/0053 and docs/0054).

- 0055_group_files.sql: widen the group_collections kind CHECK to admit
  'files'; add a group-keyed `group_files` metadata table mirroring
  `files` (0018), CASCADE on group delete.
- files_repo: generalize the four path/IO free functions
  (shard_dir_at / final_path_at / write_atomic_at / read_verify_at) to
  take an owner-relative dir instead of `app_id`, and make them (plus the
  cursor helpers) pub(crate). The security-sensitive atomic-write +
  checksum-on-read mechanics now have a single source shared by the app
  and group repos. App behavior is unchanged (apps shard at `<app_id>/`).
- group_files_repo: FsGroupFilesRepo keyed by group_id, blobs at
  `<root>/files/groups/<group_id>/<collection>/<id[0:2]>/<id>` — a
  `groups/` infix disjoint from the per-app subtree, so the existing
  recursive orphan sweeper covers it with zero change.

Schema snapshot blessed (55 migrations); app-files fs tests still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:35:34 +02:00
MechaCat02
f552238586 refactor(modules): drop dead group_collection_repo::list_for_owner (§11.6 review)
The single-kind `list_for_owner` was superseded by `list_all_for_owner`
(the apply diff and `collection_report` both moved to it during the docs
slice). It had no callers but, being `pub`, escaped the dead-code lint —
removing it drops two unexercised SQL queries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:19:48 +02:00
MechaCat02
a00454e5de test(cli): shared-docs journey + docs (§11.6 docs C5)
End-to-end docs journey: a group declares a kv AND a docs collection via the
string-or-table manifest; an authenticated app A docs::shared_collection(
"articles").create(#{...}), app B finds it back across the subtree (exercising
the shared find DSL); a sibling-subtree app gets CollectionNotShared;
collections ls shows both kinds; re-apply NoOp. Full journey suite 116/116.

Docs: groups-and-project-tool §11.6 (KV+docs slices shipped; files/topics/queue
deferred), sdk-shape.md (docs::shared_collection + string-or-table), CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:09:41 +02:00
MechaCat02
5efb068b9f feat(cli): kind-aware shared-collection reconcile + string-or-table manifest (§11.6 docs C4)
Make the declarative collection surface carry a `kind` so docs (and future
kinds) are declarable. Back-compatible: the shipped `collections = ["catalog"]`
form still means kv.

- manifest: `collections` entries are now `CollectionDecl` — an untagged enum of
  a bare string (kv shorthand) or a `{ name, kind }` table (kind ∈ kv/docs).
  `Manifest::collections()` normalizes to `(name, kind)` pairs; build_bundle
  emits `[{name, kind}]`. Still `[group]`-only. Unit test covers the
  string-or-table mix + app rejection.
- apply: `Bundle.collections: Vec<CollectionSpec>` ({name, kind=default "kv"});
  `CurrentState.collections: Vec<(name,kind)>` via list_all_for_owner;
  `diff_collections` keys each change by name with `detail = kind` and identity
  `(LOWER(name), kind)` (so a kv and a docs collection of the same name are
  distinct); reconcile reads the kind from `detail`; state_token folds
  `coll|{kind}|{name}`; validate_bundle rejects unknown kinds + dups by
  (name,kind); `collection_report`/CollectionInfo + `pic collections ls` gain a
  kind column.

Existing kv journeys + nearest-wins still green (the bare-string form normalizes
to kind=kv end to end).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:04:43 +02:00
MechaCat02
78a468de83 feat(modules): GroupDocsService + docs::shared_collection handle (§11.6 docs C3)
The runtime for shared group docs collections, mirroring the group-KV vertical
with the docs surface (filter parsing, JSON-object validation, value-size cap).

- shared: GroupDocsService trait + GroupDocsError (+ CollectionNotShared) +
  NoopGroupDocsService; group_docs field on Services + with_group_docs().
- manager-core: GroupDocsServiceImpl — owning_group resolves kind='docs' from
  cx.app_id's chain (CollectionNotShared off-chain); reads-open (script_gate
  GroupDocsRead) / writes-authed (script_gate_require_principal GroupDocsWrite);
  reuses parse_filter + docs value-size cap; no events. Unit tests cover
  off-chain CollectionNotShared, anon-read-OK/anon-write-Forbidden, non-object
  data rejected.
- executor-core: GroupDocsHandle + docs::shared_collection constructor + the
  create/get/find/find_one/update/delete/list methods (dispatch by receiver
  type), reusing doc_to_map/parse_doc_id.
- picloud: wire PostgresGroupDocsRepo + GroupDocsServiceImpl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 08:04:30 +02:00
MechaCat02
61352c7e5e feat(authz): GroupDocsRead/GroupDocsWrite capabilities (§11.6 docs C2)
The shared-scope authz refinement for group docs collections — same trust shape
as the GroupKv* caps: GroupDocsRead = viewer+, GroupDocsWrite = editor+,
resolved via the group ancestor walk. Wired into app_id() (None),
required_scope() (ScriptRead/ScriptWrite), the Member→group_member_grants
route, and group_role_satisfies. Unit test mirrors the group-kv cap test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:35:37 +02:00
MechaCat02
5d1d4b3ff6 feat(modules): group-docs schema + kind-generalized registry (§11.6 docs C1)
Data layer for extending shared group collections from KV to docs. KV behavior
unchanged (callers pass kind="kv").

- 0054_group_docs.sql: widen the group_collections kind CHECK to ('kv','docs')
  and add the group-keyed group_docs store (clone of docs/0013, keyed by
  group_id, CASCADE on group delete) + its (group_id,collection) and data GIN
  indexes.
- group_collection_repo: thread a `kind` parameter through list_for_owner,
  resolve_owning_group, insert/delete_collection_tx; add list_all_for_owner ->
  (name,kind) for the kind-aware diff (D4). Relocate the injectable
  GroupCollectionResolver trait + Postgres impl here (now shared by kv+docs;
  resolve takes kind) — was in group_kv_service.
- group_docs_repo: a near-clone of docs_repo keyed by group_id; find reuses the
  generalized build_find_query.
- docs_repo: generalize build_find_query(table, owner_col, owner_id, …) — both
  are compile-time literals (no injection); app docs passes ("docs","app_id"),
  group docs ("group_docs","group_id"). row_to_doc/build_find_query are now
  pub(crate) for reuse.

Schema snapshot re-blessed (55 migrations).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:33:39 +02:00
MechaCat02
af525e71bd fix(apply): guard app-owned collections + test nearest-group-wins (§11.6 review)
Two review findings:

- F2 (defense-in-depth): validate_bundle_for now rejects `collections` on an
  app node — the inverse of the existing group route/trigger guard. The CLI
  already prevents it (ManifestApp has no field + deny_unknown_fields), but a
  hand-rolled wire Bundle POSTed to /apps/{id}/apply would otherwise insert an
  inert app_id-owned group_collections row that no resolver reads.

- F1 (coverage): a journey for nearest-ancestor-group-wins, the CoW-shadowing
  guarantee the resolver's `ORDER BY depth LIMIT 1` provides. A parent and a
  child group both declare `catalog`; an app under the child writes, and a
  second app directly under the parent reads its (separate, empty) store —
  proving the deep write stayed in the nearest (child) store. Previously only
  the FakeResolver and flat sibling groups were exercised, so the ordering was
  untested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:15:37 +02:00
MechaCat02
0973344515 feat(cli): collections manifest + ls + journeys; rename to kv::shared_collection (§11.6 C5)
Finish the §11.6 KV slice: declarative authoring, read-only inspection, the
runtime journey, and docs — plus a forced SDK-name fix.

- SDK name: `shared` is a Rhai RESERVED keyword, so `kv::shared(...)` is a parse
  error. Renamed the handle constructor to `kv::shared_collection(...)`
  (keeps the user's "shared" intent; unambiguous). Updated everywhere.
- CLI manifest: `collections = [...]` on `[group]` only (ManifestApp has no such
  field + deny_unknown_fields → an app manifest carrying it is a hard error);
  Manifest::collections() accessor; build_bundle emits it.
- plan/apply: a `collection` row group in `pic plan`; created/deleted counts in
  the apply summary; collections threaded through PlanDto/NodePlanDto/
  ApplyReportDto.
- read-only `pic collections ls --group` → GET /admin/groups/{id}/collections
  (viewer+), backed by ApplyService::collection_report + CollectionInfo.
- journey: a group declares `catalog`; app A writes, app B (same subtree) reads
  it back; a sibling-subtree app gets CollectionNotShared; `collections ls` +
  re-apply NoOp. Full suite 114/114.
- docs: groups-and-project-tool §11.6 (KV-only MVP shipped + deferrals),
  sdk-shape.md (the shared-collection handle + trust model), CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:26:43 +02:00
MechaCat02
b79d8ef47d feat(apply): declarative shared-collection reconcile (§11.6 C4)
Thread group-collection markers through the apply engine — a name-only
resource modeled field-for-field on extension_points (§5.5): Bundle.collections,
CurrentState.collection_names, Plan.collections (+ is_noop), diff_collections
(declared→Create / live-undeclared→Delete / else NoOp), load_current loads the
names, reconcile_node_tx inserts on Create + deletes on prune via
group_collection_repo, state_token folds in coll|<name>, validate_bundle
rejects empty/duplicate names (no reserved-name guard — a collection is a data
namespace, not an importable module), ApplyReport gains collections_created/
deleted. Pruning a marker removes only the declaration; the group_kv_entries
data survives until the owning group is deleted.

The apply engine stays owner-generic; restricting declarations to [group] nodes
is enforced at the CLI manifest layer (next commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:51:53 +02:00
MechaCat02
d9766bcbc7 feat(modules): GroupKvService + kv::shared SDK handle (§11.6 C3)
The runtime for shared group collections. A script reaches a shared store via
the explicit kv::shared("name") handle (distinct GroupKvHandle Rhai type, so
private-vs-shared scope is a deliberate choice). The service resolves the
owning group from cx.app_id's ancestor chain — the script never names a group,
and that walk is the isolation boundary (a foreign app's chain never contains
the owning group → CollectionNotShared).

- shared: GroupKvService trait + GroupKvError + NoopGroupKvService; threaded
  into the Services bundle as a field defaulted to noop, opted into via a new
  with_group_kv() (keeps the ~14 Services::new call sites unchanged).
- manager-core: GroupKvServiceImpl with the reads-open/writes-authed split —
  reads use script_gate (anonymous public scripts skip), writes use the new
  script_gate_require_principal (anonymous fails closed). Owner resolution
  behind a GroupCollectionResolver trait (Postgres impl + injectable fake);
  unit tests cover off-chain CollectionNotShared, anonymous-read-OK/
  anonymous-write-Forbidden, authed-no-role-Forbidden.
- executor-core: GroupKvHandle + kv::shared constructor + get/set/has/delete/
  list (Rhai dispatches by receiver type, reusing the block_on bridge).
- picloud: wire PostgresGroupKvRepo + resolver + GroupKvServiceImpl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:44:53 +02:00
MechaCat02
d1de43e919 feat(authz): GroupKvRead/GroupKvWrite capabilities (§11.6 C2)
The shared-scope authz refinement for group collections. Two group-scoped caps
resolved via the existing group ancestor walk (effective_group_role):
GroupKvRead = viewer+, GroupKvWrite = editor+. Wired into app_id() (None —
group caps carry no app_id, so a bound API key is denied at the binding layer),
required_scope() (ScriptRead / ScriptWrite), the Member→group_member_grants
route, and group_role_satisfies. Unit test covers viewer-reads-not-writes,
editor-writes, outsider-denied, bound-key-denied up the chain.

The reads-open/writes-authed trust split (anonymous read bypass; anonymous
write fail-closed) is enforced at the service layer in the next commit — these
caps are the authenticated-principal refinement on top of the structural
subtree boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:34:49 +02:00
MechaCat02
f1d5f5c34e feat(modules): group-collection registry + group-KV storage schema (§11.6 C1)
Data layer for shared cross-app group collections (KV-only MVP), nothing wired
yet. Two migrations + two repos, modeled on the extension_points (0051) marker
and the kv_entries (0007) store:

- 0052_group_collections.sql: owner-polymorphic marker table declaring a
  collection name group-shared, with a `kind` discriminator (CHECK 'kv' for
  now; generalizes to docs/files/topics/queue later) and per-owner
  partial-unique (owner, LOWER(name), kind) indexes. CASCADE — a marker is
  config, not code.
- 0053_group_kv_entries.sql: the shared store, keyed by (group_id, collection,
  key) — NO app_id (a shared row belongs to the group). CASCADE on group delete
  (data dies with its group, like vars/secrets config; an app delete leaves it).
- group_collection_repo: list_for_owner, insert/delete_collection_tx, and the
  load-bearing resolve_owning_group — walks the reading app's chain
  (CHAIN_LEVELS_CTE) for the nearest ancestor group declaring the name
  (nearest-wins via ORDER BY depth LIMIT 1). That walk IS the isolation
  boundary; the join is on group_owner only.
- group_kv_repo: a near-clone of kv_repo keyed by group_id.

Schema snapshot re-blessed (53 migrations).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:30:42 +02:00
MechaCat02
e53e2f583d chore(apply): log deferred provider checks on the tree path (§5.5 review)
The single-node path runs `check_imports_resolve` +
`check_extension_points_provided`; the tree path can't (an in-tree,
uncommitted node may legitimately provide a module, so a pool-based check
would false-positive) and leans on the runtime backstop. Emit a debug log
when a tree apply contains app nodes so the deferral isn't a silent gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:09:57 +02:00
MechaCat02
529725ebb6 fix(cli): reject misplaced/unknown manifest keys (§5.5 review)
`extension_points` is an `[app]`/`[group]` node key. Placed at top level
(before any table header) TOML reads it as a root key — and `Manifest`
silently dropped unknown root keys, the same silent-drop class that bit in
C5 (a key absorbed under `[group]` and discarded). Add
`deny_unknown_fields` to `Manifest`, `ManifestApp`, and `ManifestGroup` so a
misplaced or typo'd key is a hard parse error instead of a no-op apply.
Regression test covers top-level placement, an in-`[app]` typo, and the
correct node-key form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:09:57 +02:00
MechaCat02
a049397bb0 test(cli): extension-point journeys + node-key manifest fix + docs (§5.5 C5)
- Move the manifest `extension_points` from a top-level key to an `[app]`/
  `[group]` node key (`ManifestApp`/`ManifestGroup` + a `Manifest::extension_points()`
  accessor). A bare top-level array placed after the node header was silently
  re-nested by TOML into that table and dropped; as a node key it parses
  unambiguously wherever it sits. build_bundle/pull/init updated.
- Journeys (live server + Postgres): a group default body resolves for an
  inheriting app; the app provides its own module and OVERRIDES the EP (the
  inverse of the Phase 4b sealed import); `extension-points ls` shows the
  provider; a body-less EP with no provider fails `pic plan`.
- Re-bless the schema snapshot (new `extension_points` table).
- Docs: §5.5 marked complete (extension points ) + CLAUDE.md current-focus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:53:18 +02:00
MechaCat02
82b4579317 feat(modules): no-provider plan check + read-only extension-points ls (§5.5 C4)
- apply: `check_extension_points_provided` (app nodes) — every EP visible on
  the app's chain (its own + inherited) must have a provider: a same-apply
  module, or one resolvable up-chain (override or default body). Else a clean
  `pic plan` error instead of a runtime failure. Single-node only (the tree
  path leans on the runtime backstop, like the dangling-import check).
- read-only surface: `extension_point_report` + `ExtensionPointInfo`;
  `GET /{apps|groups}/{id}/extension-points` (AppRead / GroupScriptsRead);
  `pic extension-points ls --app|--group` showing declared-vs-inherited + the
  resolved provider (`app override` / `inherited default` / `unset`).
- `pic pull` round-trips an app's OWN declared EPs (filtered `declared_here`),
  so pull→plan stays idempotent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:31:14 +02:00
MechaCat02
e62e073970 feat(apply): declarative extension_points reconcile (§5.5 C3)
Thread extension-point markers through the manifest and the apply engine —
a name-only resource modeled on `secrets` (shape) with `vars`-style
create/prune write behavior.

- manifest: top-level `extension_points = ["theme", …]` (allowed on app and
  group; overlay stays base-only via deny_unknown_fields); `build_bundle`
  emits the names.
- apply_service: `Bundle.extension_points`, `CurrentState.extension_point_names`,
  `Plan.extension_points` (+ is_noop), `diff_extension_points`
  (declared→Create / live-undeclared→Delete / else NoOp), `load_current`
  loads them, `reconcile_node_tx` inserts on Create + deletes on prune,
  `state_token_with_names` folds in `ep|<name>`, `validate_bundle` rejects
  duplicates + reserved names, `ApplyReport` gains created/deleted counts.
- CLI client + plan/apply rendering: `extension_points` in PlanDto/NodePlanDto/
  ApplyReportDto, an `extension_point` row group in `pic plan`, a count in the
  apply summary.

pull sets it empty for now (wired to the read endpoint in C4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:22:05 +02:00
MechaCat02
8f966783fe feat(modules): extension-point-aware resolver policy (§5.5 C2)
Add the runtime heart of extension points: `ModuleSource::resolve_policy`
decides lexical vs dynamic resolution by the NEAREST declaration of a name
walking up the importing origin's chain — a concrete module → lexical (seal
to origin, Phase 4b); an extension-point marker → dynamic, resolved against
the inheriting app (`cx.app_id`) so the app can override, falling back to the
default body up-chain; the EP wins a depth tie.

- shared: `ModuleResolution { Module | NoProvider | NotFound }` + a
  `resolve_policy(origin, inheriting_app, name)` trait method with a
  lexical-only default impl (existing fakes inherit it unchanged).
- PostgresModuleSource: one-round-trip nearest-declaration query (min EP depth
  vs min module depth over the chain CTEs), then the lexical/dynamic branch.
- module_resolver: calls `resolve_policy(origin, cx.app_id, path)`; maps
  NoProvider → a clear "extension point has no provider" error, NotFound →
  module-not-found. Cache + AST-source sealing unchanged.
- executor-core tests: a policy fake + 3 cases (app override binds dynamically,
  no-provider errors, non-EP stays lexical).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:07:59 +02:00
MechaCat02
8b68a7d7e8 feat(modules): extension-point marker schema + repo (§5.5 C1)
An extension point (§5.5) marks a module name a node offers for descendants
to provide/override — resolved dynamically against the inheriting app rather
than lexically sealed. Lay the storage:

- migration 0051: owner-polymorphic `extension_points(id, group_id?, app_id?,
  name)` marker table — exactly-one CHECK + per-owner partial-unique LOWER(name)
  indexes + lookup indexes (mirrors 0050). ON DELETE CASCADE (a marker is
  config, not code). No default-body column — the optional default body is a
  co-located kind=module script (Phase 4b stores/resolves/caches those).
- extension_point_repo: `list_for_owner` + idempotent `insert_extension_point_tx`
  / `delete_extension_point_tx`, keyed by the shared `ScriptOwner` (mirrors the
  var/secret tx-fn style).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:02:22 +02:00
MechaCat02
4e25a142bd fix(modules): validate group module create per kind (Phase 4b review)
Adversarial-review finding: `create_group_script` validated every source
with `validate()` regardless of kind, so an imperatively-created group
module (`pic scripts deploy --group g --name kv --kind module`) skipped the
two gates every other module-write path enforces (app create/update in
api.rs, declarative apply in apply_service): the stricter `validate_module`
shape check and the `RESERVED_MODULE_NAMES` guard. A malformed-shape group
module was accepted at create (only failing later at import-resolve time)
and a reserved name (`kv`, `log`, …) slipped through.

Branch on kind to mirror the app path. Adds a journey asserting a reserved
group-module name is rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:32:34 +02:00
MechaCat02
52da8a8704 test(cli): group-module lexical-resolution journeys + Phase 4b docs (C5)
Some checks failed
CI / Rust — fmt, clippy, test (push) Failing after 17m58s
CI / Dashboard — check (push) Successful in 9m45s
Two journeys against a live server + Postgres:
- `group_module_is_lexically_sealed_under_inheritance`: a group module +
  a group endpoint that imports it, inherited by an app — proves the §5.5
  trust boundary (a leaf's same-named module does NOT shadow the inherited
  endpoint's import) and app-origin CoW (an app endpoint's import resolves
  the app's module).
- `dangling_import_is_rejected_by_plan`: a manifest script importing a
  non-existent module is a `pic plan` error.

Docs: mark §5.5 residual resolved + §11 Phase 4b  in the design doc;
update CLAUDE.md current-focus (extension points are the remaining §5.5
piece).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:39:08 +02:00
MechaCat02
c8acac1d20 feat(modules): enable group modules + dangling-import plan check (Phase 4b C4)
Lift the Phase-4-lite restrictions now that import resolution is lexical:

- `create_group_script` (group_scripts_api): allow `kind = module` and
  group scripts with `import`s; record the import edges. Module-shape and
  sandbox-ceiling validation stay.
- The declarative apply path already reconciles group modules generically
  (`BundleScript.kind` + `owner.script_owner()` flow through
  `reconcile_node_tx`); no change needed beyond stale-comment fixes.

Adds the §5.5 dangling-import plan check (single-node plan/apply):
`check_imports_resolve` rejects an `import "<m>"` that resolves to neither a
bundle-local module nor a module reachable lexically up the node's chain —
caught at plan time instead of as a runtime 404. Roots resolution at the
node's owner (an app node reaches ancestor group modules; a group node walks
its own ancestry). ApplyService gains an injected `ModuleSource`. The tree
path opts out (a node may import a module created by another node in the same
uncommitted tx); the runtime check is the backstop there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:29:18 +02:00
MechaCat02
b53eabb786 feat(modules): lexical import resolver — seal imports to defining node (Phase 4b C3)
Make `import` resolution lexical (§5.5): an inherited group script's
imports resolve against the module set at its OWN defining node, walking
up from there — a leaf app can't shadow them, and a group script behaves
identically under every inheriting app.

Mechanism — Rhai's `_source` carries the importing AST's origin tag:
- The entry script has no source tag → resolve from `default_origin` (its
  defining node, threaded in via C2).
- Each resolved module's AST source is set to `encode(module.owner())`
  before `eval_ast_as_new`, so its nested `import`s carry the module's own
  node as `_source` and resolve from there — lexical chaining.
- `encode_origin`/`decode_origin` map a `ScriptOwner` to/from `app:<uuid>`
  / `group:<uuid>`.

Cache rekeyed from `(app_id, name)` to the resolved `ScriptId`: a compiled
module is lexically self-contained, so its body is a pure function of
`(script_id, updated_at)` — this also dedupes a shared group module across
inheriting apps and keeps same-named cross-app modules distinct.

Adds two executor-core unit tests (origin-keyed fake) proving the trust
boundary: a group module's import binds the group's module even when an
app-owned module of the same name exists; `ScriptOwner` gains `Hash`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:22:25 +02:00
MechaCat02
1844bef132 feat(executor): thread the defining node into ExecRequest (Phase 4b C2)
Carry the resolved script's owner (its defining node) from dispatch into
the executor so `import` resolution can be lexical (§5.5). The executing
app (`app_id`) stays the SDK isolation boundary; `script_owner` is a
separate axis — the lexical origin for imports.

- `ExecRequest.script_owner: Option<ScriptOwner>` (serde default; `None`
  falls back to `App(app_id)` in the engine for old payloads / cluster wire).
- `ScriptOwner` gains `Serialize`/`Deserialize` for the wire.
- The engine computes `default_origin` and hands it to the resolver
  (used fully in C3; the resolve() lookup already uses it).
- Every dispatch site sets it from the resolved `Script.owner()`:
  the 4 dispatcher arms (queue/trigger/http/invoke_async, via a new
  `ResolvedTrigger.script_owner`), the orchestrator id-bypass, and the
  `invoke()` SDK path (new `ResolvedScript.owner`, so a group script
  invoked by an app resolves imports from the group).

Behaviour-preserving: app scripts still resolve `App(app_id)`; group
scripts can't yet carry imports (lifted in C4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:17:33 +02:00
MechaCat02
7fef098b48 feat(modules): owner-aware module lookup — lexical chain walk (Phase 4b C1)
Make the module-loading contract origin-aware so group-owned modules
become resolvable and imports can resolve lexically (§5.5).

- `ModuleScript` gains a polymorphic owner (`app_id`/`group_id` + `owner()`),
  mirroring `Script`.
- `ModuleSource::lookup(&cx, name)` -> `resolve(origin: ScriptOwner, name)`:
  resolution walks the group chain rooted at the importing script's
  defining node (nearest-owner-wins), not the inheriting app's view.
- `PostgresModuleSource::resolve` branches on origin: app-rooted
  (`CHAIN_LEVELS_CTE`) or a new group-rooted `GROUP_CHAIN_LEVELS_CTE`,
  joining `kind='module'` rows.

The executor resolver passes a temporary `App(cx.app_id)` origin (behaviour-
preserving for app scripts; true lexical origin lands in C3). Group scripts
still can't carry imports until C4, so no trust-inversion window opens here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:07:04 +02:00
MechaCat02
3ef3038eb7 test(cli): project-tree journeys + Phase 5 docs
Phase 5 C5. Three `pic ... --dir` tree journeys:
  * a group + a nested app apply atomically as one tree (the app's route binds
    the group's same-apply `shared` script); the group node's script is
    created; re-plan is all-noop (idempotent),
  * one invalid node aborts the whole tree — the valid group node's script is
    NOT created (true all-or-nothing),
  * a `pic groups reparent` between `pic plan --dir` and `pic apply --dir` trips
    the bound-plan (StateMoved) check via the tree's structure version.

Docs: `groups-and-project-tool.md` §11.5 status ( shipped — single-repo
nested tree apply; multi-repo single-owner + per-env approval gating deferred
per §11.1) + CLAUDE.md current-focus.

Gates: fmt, clippy -D warnings, cargo test --workspace, check-versioning (50
migrations — Phase 5 added none), schema snapshot unchanged, full journey suite
108/108. (Pre-existing flake `queue_e2e::queue_receive_acks_on_success` — a
racy immediate `count==0` after the async ack; flaky at the pre-Phase-5
baseline too, unrelated to this work.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:43:20 +02:00
MechaCat02
1662d7806a feat(cli): nested-manifest discovery + pic plan/apply --dir tree mode (Phase 5 C4)
Phase 5 C4. `pic plan --dir <root>` / `pic apply --dir <root>` discover every
`picloud.toml` under a directory (groups + apps), assemble the wire tree
bundle, and drive the C3 `/tree/{plan,apply}` endpoints — a whole project
subtree reconciled and reviewed as one unit.

  * New `discover` module: recursive walk (skips `.picloud`/`.git`/`scripts`/
    `target`), one node per manifest, app/group inferred from the manifest;
    rejects a duplicate (kind, slug). On-disk nesting is organizational — the
    server resolves ancestry from its own group tree.
  * `client`: `plan_tree`/`apply_tree` + `TreePlanDto`/`NodePlanDto`. `pic plan
    --dir` renders a per-node diff table and records ONE tree bound-token under
    a `<tree>` linkstate marker; `pic apply --dir` replays it (force/`--yes`/
    prune apply tree-wide). `--dir` conflicts with `--file`.

Live-validated: a nested tree (root group dir + nested app dir, app route bound
to the group's inherited script) → `pic plan --dir .` shows both nodes' changes
→ `pic apply --dir .` applies all in one tx → re-plan all-noop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:25:29 +02:00
MechaCat02
8a559ea178 feat(apply): atomic project-tree apply with cross-node inherited binding (Phase 5 C3)
Phase 5 C3 — the headline. A whole project subtree (group + app nodes) is
reconciled in ONE Postgres transaction, all-or-nothing.

`POST /api/v1/admin/tree/{plan,apply}` take a `TreeBundle { nodes: [{kind,
slug, bundle}] }`. The actor must hold the relevant read/write caps for EVERY
node (per-kind, widened on prune) — `authz_tree` + reusable
`require_{app,group}_node_writes` (now shared with the single-node handlers).

Engine:
  * The in-tx reconcile body of `apply_owner` is extracted to `reconcile_node_tx`
    (returns the node's post-create `name → script_id`), so single-node and
    tree apply share one implementation — behaviour-preserving (391 unit tests
    + app journeys green).
  * `prepare_tree` resolves each slug→owner, validates (an app's route/trigger
    target may bind to an in-tree ancestor group's script), computes each
    node's plan, and folds a combined bound-plan token = every node's
    `state_token` + every in-scope group's `structure_version` (so a reparent
    or content edit between plan and apply trips StateMoved).
  * `apply_tree`: lock every node key (sorted, deadlock-free); reconcile GROUPS
    first, recording each group's `name → id` in an in-memory index; then APPS,
    resolving inherited route/trigger targets nearest-ancestor-wins across the
    in-tree index (incl. scripts created earlier in THIS tx, invisible to the
    pool resolver) and pre-existing out-of-tree ancestors. One commit, one
    post-commit route refresh.

Live-validated against the dev DB:
  * Headline: a group node creates `shared`; an app node in the SAME apply
    binds `GET /greet` to it — the route ends up bound to the group-owned
    script, and re-plan is all-noop (idempotent).
  * Atomicity: one invalid node → 422 and ZERO rows written (the valid group
    node's script is not created).

CLI tree discovery + `pic plan/apply` tree mode is C4; journeys + docs are C5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:18:07 +02:00
MechaCat02
da03fda1d6 feat(cli): [group] manifest + pic plan/apply for a group node (Phase 5 C2)
Phase 5 C2. A `picloud.toml` can now declare a `[group]` node (instead of
`[app]`): the group's own scripts + `[vars]` (+ secret names). `pic plan` /
`pic apply` reconcile it via the C1 group endpoints — the same plan/apply/
bound-token flow as an app, routed by node kind.

  * `Manifest` is now app-XOR-group: `app`/`group` are both optional with an
    exactly-one check in `parse`, plus a group-node guard that rejects
    `[[routes]]`/`[[triggers]]` (those bind to an app). Accessors `slug()` /
    `is_group()` replace the hardcoded `manifest.app.slug`.
  * `client`: `plan_node`/`apply_node` take a `NodeKind { App | Group }` that
    selects the `apps` vs `groups` API path; the old `plan`/`apply` wrappers
    are gone (callers pass the kind).
  * `pic plan`/`apply` detect the node kind from the manifest and label output
    `group`/`app`; `pic config --effective` cleanly rejects a group manifest
    (effective config is an app's inherited view).

Live-validated: a `[group]` manifest with a script + var → `pic plan` (script
+ var creates) → `pic apply` (group-owned) → idempotent re-plan noop →
`pic scripts ls --group` shows it. Manifest unit test for group parse +
app-only-block rejection; init/pull/plan/apply/overlay journeys all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:57:58 +02:00
MechaCat02
65049a6262 feat(apply): owner-generic apply engine — group-node plan/apply (Phase 5 C1)
Phase 5 C1. Generalize the reconcile engine from app-only to an `ApplyOwner
{ App | Group }`, so a GROUP's own config + scripts can be declaratively
reconciled — the foundation for the tree-spanning project tool.

A group node is a subset of an app node (scripts + vars only; no routes /
triggers / secrets-values), so the diff, script/route/trigger/var CRUD, prune,
idempotency, and the Phase-4 inherited-target resolution are all reused
unchanged. Only the owner-varying bits switch on `ApplyOwner`:
  * `load_current` — `list_for_group` + `VarOwner::Group` for a group (empty
    routes/triggers/secrets); `list_for_app` for an app, exactly as before.
  * script create owner (`NewScript{app_id|group_id}`), var writes (new
    `set_group_var_tx`/`delete_group_var_tx` mirroring the app variants at
    scope `*`), and the advisory lock (owner-tagged).
  * `validate_bundle_for` rejects routes/triggers on a group bundle (422);
    route-host validation, inherited-target resolution, the post-commit route
    refresh, and the unreachable-endpoint warning are app-only.

`apply`/`plan` are unchanged thin wrappers over the new `apply_owner`/
`plan_owner`. New endpoints `POST /groups/{id}/{plan,apply}` gated by
`GroupScripts{Read,Write}` / `GroupVarsWrite`. `ApplyService` gains a `groups`
repo (the tree apply in C3 needs it too).

Live-validated: group plan → apply (group-owned script + var) → idempotent
re-plan noop → routes rejected 422 → DB confirms group ownership. App apply
unchanged: 391 manager-core unit tests + the apply/prune/staleness/overlay
journeys all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:46:52 +02:00
MechaCat02
99e7100f0b fix(apply): resolve inherited-bound triggers in diff, state-token, and prune
Adversarial review of Phase 4-lite found two real gaps, both rooted in the
apply engine resolving script `id → name` from `current.scripts` (app-own)
only — so a route/trigger bound to an inherited GROUP script (whose id is not
app-own) couldn't be named:

  * **Prune gap (reachable via pure declarative apply):** a trigger bound to an
    inherited group script, once dropped from the manifest, never diffed to a
    Delete (the diff couldn't resolve its identity once the bundle stopped
    referencing the name), so `apply --prune` silently orphaned it.
  * **Bound-plan false-negative:** `state_token` excluded such a trigger from
    its fingerprint, so the StateMoved check missed a concurrent edit to that
    binding class. (Routes were already safe — they hash the raw `script_id`.)

Fix: `resolve_current_target_names` resolves the names of group scripts the
app's CURRENT routes/triggers are bound to (by id, independent of the bundle),
and that map now feeds `compute_diff_with_names`, `state_token_with_names`, and
the prune trigger-identity map. App-owned bindings are unaffected (their names
are already in `current.scripts`). Also fixes a stale "in this app" conflict
message for group-owned scripts.

New journey `inherited_bound_trigger_is_pruned_when_dropped` proves prune now
removes an inherited-bound cron trigger. Full journey suite 105/105.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:11:59 +02:00
MechaCat02
9598db149b docs: record Phase 4-lite (group-owned scripts) as shipped, live-resolved
Mark §11.4  shipped with the live-resolution decision (no body
materialization / invalidation fan-out, mirroring Phase 3's config), the
shipped surface, and the deliberate Phase-4b deferrals (group modules + lexical
import resolver, invoke-by-id, live auto-rebinding). Note the sharp edge:
routes.script_id ON DELETE CASCADE means deleting a group script drops
descendant routes. Update the §5.1 materialization box and CLAUDE.md
(current-focus + the data-model invariant now covers group-owned scripts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:44:51 +02:00
MechaCat02
c29e4b9c53 test(cli): journey for group-script inheritance, CoW, isolation + apply binding
Phase 4-lite C5. Two `pic` journeys:
  * `group_script_is_inherited_with_cow_and_isolation` — a group endpoint
    `shared` is invoked by name from an app under the group; the app's own
    `shared` then shadows it (CoW); an app outside the group cannot reach it.
  * `manifest_binds_route_to_inherited_group_script_idempotently` — a manifest
    that declares no `greet` script binds a route to the inherited group
    `greet`; plan shows a route create (no script create), apply succeeds, and
    re-plan is a clean no-op.

Adds a `ScriptGuard` (deletes a script by id on drop) since a group-owned
script blocks its group's deletion (ON DELETE RESTRICT) — it must drop before
the GroupGuard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:42:35 +02:00
MechaCat02
701ec90b56 feat(apply): declaratively bind routes/triggers to inherited group scripts
Phase 4-lite C4 — the headline declarative path. An app's manifest can now bind
a `[[routes]]` / `[[triggers]]` to a script it does NOT declare, resolving the
name to an inherited group-owned endpoint on the app's chain (nearest-owner
wins; an app's own script of the same name still shadows it — CoW).

  * `resolve_inherited_targets(app_id, bundle)` resolves every route/trigger
    target name that isn't an app-own manifest script to a group endpoint via
    `get_by_name_inherited`, returning `name → script_id`. Run once before the
    apply transaction (group scripts pre-exist, committed).
  * `validate_bundle` takes the inherited endpoint names so "binds to unknown
    script" / "is a module" checks pass for inherited targets.
  * The apply transaction merges the inherited targets into `name_to_id`
    (app-own keeps precedence), so route/trigger inserts bind to the group
    script's id.
  * `compute_diff_with_inherited` adds the inherited ids → names to the diff's
    name map, so a route bound to a group script resolves its name and
    re-apply is idempotent (without this it read as a perpetual rebind).

Live-validated: an app under group G with a manifest binding `GET /greet` to
the group's inherited `greet` plans as `route create → greet` (no script
create), applies (+1 route, +0 scripts), and re-plans as `noop` (idempotent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:32:24 +02:00
MechaCat02
719a17432f feat(scripts): inherited resolution for invoke() + chain-aware dispatch guards
Phase 4-lite C3. A descendant app can now resolve and run a group-owned script
inherited down its ownership chain — the core of group-script inheritance.

Resolver (repo, reusing config_resolver's CHAIN_LEVELS_CTE):
  * `get_by_name_inherited(app_id, name)` — walk app → ancestor groups
    nearest-first, return the nearest script of that name (an app's own script
    shadows an inherited group one: CoW).
  * `is_invocable_by_app(script_id, app_id)` — EXISTS over the same chain; the
    cross-app isolation predicate generalized to the hierarchy.

invoke(): `invoke("name", …)` resolves via `get_by_name_inherited`, so a script
can call a shared group endpoint by name. A group script always runs under the
*caller's* app context, never its owner.

Dispatch guards (the isolation backstops touched in C1) are now chain-aware via
a `Dispatcher::script_invocable` helper and the chain-aware
`validate_trigger_target`: app-owned scripts keep the in-memory fast path (no
query, behavior byte-identical to the pre-Phase-4 same-app guard); only a
group-owned candidate pays one chain query, and a non-member / query error
fails closed (dead-letter or reject — never run under another app's SdkCallCx).

The orchestrator HTTP route path needs no change: its per-app route trie
already scopes routes to one app, and it dispatches under the route's app
without reading script.app_id — a route bound to a group script just works.

Live-validated against the dev DB: an app under group G invoking the group's
`shared` returns its output; an app NOT under G cannot resolve it (isolation);
and after the app deploys its own `shared`, the same call returns the app's
version (CoW shadowing). Route/trigger binding of inherited scripts via the
declarative engine is C4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:23:07 +02:00
MechaCat02
43ed4e8e7f feat(scripts): group-owned script admin API + pic scripts … --group
Phase 4-lite C2. Lets a group own scripts (templates inherited by descendant
apps), with the create/list/manage surface — but not yet the inherited
resolution (binding + runtime), which is C3.

Capabilities: add `GroupScriptsRead` (viewer+ on the group → script:read) and
`GroupScriptsWrite` (editor+ → script:write), mirroring the group-vars tier and
resolved through the same group-ancestor walk.

API:
  * New `group_scripts_api`: `POST/GET /groups/{id}/scripts` — create a
    group-owned endpoint script and list a group's own (non-inherited) rows.
    Phase 4-lite is endpoint-only and self-contained: `kind=module` and any
    `import` are rejected (group modules + the lexical resolver are Phase 4b).
    Owner resolved first (slug-or-uuid); capability bound to the resolved id.
  * The by-id `/scripts/{id}` get/update/delete/logs handlers are now
    owner-polymorphic via a `script_cap` helper: app-owned scripts gate on
    `App*` exactly as before; group-owned on `GroupScripts*`. This is what
    makes `deploy --group` idempotent (update reuses the by-id PUT) and lets a
    group script be deleted by id. (C1 had these fail closed for groups.)

Repo: `list_for_group(group_id)` (the group's own rows).

CLI: `pic scripts ls --group <g>`, `pic scripts deploy --group <g>` (create or
update by name; `--app`/`--group` are mutually exclusive, exactly one
required). `pic scripts delete <id>` already works for group scripts via the
owner-polymorphic by-id route.

Live-validated against the dev DB: create → update (v2 via the by-id PUT) →
list → delete, plus module rejection and the polymorphic row shape
(`app_id NULL`, `group_id` set). Group scripts still can't be routed/triggered
or invoked — that lands in C3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:10:20 +02:00
MechaCat02
48178c5f60 feat(scripts): polymorphic script owner (app XOR group) — Phase 4 foundation
Phase 4-lite C1. Make script ownership polymorphic so a script can be owned
by a GROUP (a template inherited by descendant apps) instead of an app —
mirroring vars/secrets (0048/0049), but ON DELETE RESTRICT (code is not data).

Schema (0050): `scripts.group_id` (nullable FK→groups RESTRICT), `app_id` made
nullable, `scripts_owner_exactly_one` CHECK, and the per-app name index split
into two per-owner partial-unique indexes. Existing app-owned rows keep their
exact `(app_id, lower(name))` uniqueness.

Type: `Script.app_id` becomes `Option<AppId>`; add `Script.group_id` +
`ScriptOwner` / `is_owned_by_app()`. `NewScript` gains the same polymorphic
owner. The execution-context app (what a script runs *under*) is supplied by
the invoking route/trigger/caller, never read off the script — group scripts
have no single app.

Behavior is fully preserved for app-owned scripts (the only kind creatable
today): every isolation backstop and authz site now uses `is_owned_by_app`,
which is byte-identical for app owners and **fails closed** for group owners.
A group script therefore can't yet be run, route/trigger-bound, invoked, or
managed via the app-script API — those land in C2 (group-script creation) and
C3 (chain-membership resolution + binding). The `/execute/{id}` bypass 404s a
group script (no app context to run under).

Re-blesses expected_schema.txt; note the golden was last blessed at migration
0044, so this also captures the already-committed 0045–0049 schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:53:05 +02:00
MechaCat02
27dc04819f test(cli): journey for declarative app-var reconciliation
`apply_reconciles_app_vars` drives the full cycle through `pic` against a
real server: a manifest `[vars] region = "eu"` is applied (var created +
injected — a script `vars::get("region")` returns "eu"), the value is
changed and re-applied (Update, returns "us"), then the var is dropped and
`apply --prune` removes it (`vars::get` resolves to `()` / JSON null).
Exercises the Bundle.vars wire, diff_vars create/update/delete, the in-tx
writes, and the runtime resolver end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:06:44 +02:00
MechaCat02
181622a84a feat(cli): pic pull exports an app's own vars into [vars]
Completes the vars round-trip: pull now fetches the app's OWN env-agnostic
vars (`vars_list` filtered to scope `*`, non-tombstone) and writes them
into the manifest `[vars]` block, alongside scripts/routes/secrets.
Inherited group vars stay out (pull exports own rows only, §4.6); a value
TOML can't represent (e.g. JSON null) is skipped with a warning. So
`pic pull` → edit → `pic apply` is now lossless for app config vars.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:03:26 +02:00
MechaCat02
34dcae98a2 feat(cli): declare app vars in the manifest + plan/apply them
Adds a `[vars]` block to `picloud.toml` (key → TOML value), reconciled by
`pic plan`/`pic apply` alongside scripts/routes/triggers/secrets. Unlike
`[secrets]` (name-only), var values live inline since they aren't secret.
The overlay (`picloud.<env>.toml`) may carry `[vars]`; overlay keys win
per key (the env-specific value of an env-agnostic default).

`build_bundle` serializes the vars to JSON for the wire (TOML round-trips
via serde); `pic plan` shows a `var` row group and `pic apply` reports
`vars +c ~u -d`. The `PlanDto`/`ApplyReportDto` gain the matching fields.
`pic pull` carries an empty `[vars]` for now (export lands next). Adds a
round-trip + overlay-precedence unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:40:36 +02:00
MechaCat02
e517f6dc8c feat(apply): reconcile app-owned vars in the declarative engine
Extends the Phase-1 apply engine to manage app `vars` declaratively,
alongside scripts/routes/triggers/secrets. The `Bundle` gains a `vars`
map (key → JSON value; values are non-secret, so they live inline, unlike
secret names). `diff_vars` is value-sensitive: a changed value is an
Update, a live var the manifest dropped is a Delete (applied only under
`--prune` — vars are prunable config, unlike never-pruned secrets).

Writes go through `set_app_var_tx`/`delete_app_var_tx` inside the apply
transaction (mirroring `PostgresVarsRepo::set` for `VarOwner::App`, scope
`*`), so they commit atomically with the rest of the apply and roll back
together on failure. `CurrentState` loads the app's OWN scope-`*`,
non-tombstone vars (inherited group vars and tombstones are out of scope
for the manifest); `state_token` folds them in so the bound-plan check
catches an out-of-band `pic vars set` between plan and apply. Keys are
validated against the same kebab rule as the vars admin API, and the
apply handler now requires `AppVarsWrite` when vars are touched or
`--prune` is set.

Scope: app-owned vars at scope `*` (an app *is* an environment). Group
vars via manifest and tombstone-via-manifest wait for the nested-manifest
model. Unit tests cover the create/update/noop/delete diff and the
state-token value sensitivity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:35:12 +02:00
MechaCat02
b2822c8435 Merge: groups Phase 3 — group-inherited config (vars + group secrets)
Adds the net-new env-scoped `vars` table + the §3 resolution engine
(env pre-filter, proximity-first, per-key map merge, explicit tombstones),
group-owned environment-scoped secrets with owner-discriminated AAD
(secret:group:{group_id}:{name}, disjoint from the unchanged app AAD so
existing ciphertexts still decrypt), masked group-secret reads (app devs
see exists, never plaintext; only owning-group principals read values;
runtime injection bypasses the human gate), the vars:: SDK, and
pic vars / pic secrets --group / config --effective. Migrations 0048
(vars) and 0049 (group secrets reshape).

Verified before merge: fmt/clippy(-D warnings) clean; manager-core +
executor-core 523 unit tests pass (incl. AAD cross-owner-swap rejection
and the pure §3 resolver semantics); check-versioning OK (49 migrations);
all 101 CLI journeys pass against a live DB, including masked-read
(group_secret_value_is_masked_from_app_devs), runtime injection +
override, and var inheritance. Commit 79f8c9d also fixes the 6
long-standing stale logs/scripts/roles journey assertions, so the suite
is now fully green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:19:08 +02:00
MechaCat02
b46667c309 docs: reconcile the groups/project-tool initiative into the roadmap
The groups/project-tool work had no home in the blueprint's version
scheme, two "Phase N" numbering schemes overlapped, and CLAUDE.md's
"current focus" was stale (claimed v1.1.0 SDK foundation; we're at v1.1.9
with the SDK line complete). Reconcile all three:

* blueprint §12 Phase 5 (v1.2, already titled "Advanced Workflows &
  Hierarchies") — split into a Hierarchies track (the groups + project
  tool initiative, with §11 Phases 1–3 marked shipped) and a Workflows
  track, with sequencing flagged as an open call. This gives the work an
  explicit version home: the Hierarchies half of v1.2.
* design doc header — Status Draft → Active, "scheduled as the Hierarchies
  track of v1.2", and an explicit warning that §11's local Phase 1–6
  numbering is distinct from the blueprint product-phase numbering.
* design doc §11.1 — a post-Phase-3 re-sequencing review: resolve Phase 4
  scripts LIVE (drop the fan-out-invalidation sub-project, per the Phase 3
  result); consider a "Phase 5-lite" (declarative apply of vars/secrets +
  app scripts) before the hard group-scripts work; and force the two
  deferred decisions (trigger/route templates vs tenant cardinality;
  multi-repo ownership vs a single-repo start).
* CLAUDE.md — refresh "current focus" to v1.2 Hierarchies, and correct the
  now-broken "every v1.1+ table is app_id NOT NULL" invariant: the new
  group-inheritable config tables (vars, secrets) use a polymorphic owner
  (group_id XOR app_id), not app_id NOT NULL.

Doc-only; no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:12:25 +02:00
MechaCat02
ca360d84cb docs(design): record Phase 3 shipped + the live-resolution decision
Phase 3 (group-inherited config) is implemented, so fold the outcome back
into the design doc (per its own "before it drifts" note) — and flag the
one deliberate divergence from the plan.

§5.1 / §11.3 / §12 prescribed a *materialized* per-app effective view with
a generation counter and fan-out invalidation. We shipped **live,
resolve-on-read inheritance** instead: one recursive CTE per
`vars::get`/`secrets::get`/`config/effective`, no cache. The org tree is
small and there was no pre-existing config cache to fight, so this is
cheap and sidesteps §10's unsolved invalidation SLA.

Records, mirroring the Phase 1/2 status blocks:
* a "Status (Phase 3):  shipped" block in §11.3 with the full surface
  (0048/0049 schema incl. `apps.environment`, the §3 resolver, vars SDK +
  CRUD, group-secret AAD + inherited read, the two secret gates, masked
  `config/effective`, `--explain`) and the live-vs-materialized rationale;
* §5.1 runtime-model bullet annotated as the deferred (Phase-4 script
  inheritance) design, not Phase-3 runtime;
* §10 "invalidation SLA" and "inherited-membership revocation lag" marked
  moot/resolved — no cache means immediate propagation;
* §12 resolver contract + the header framing updated to live resolution.

Doc-only; no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:53:58 +02:00
MechaCat02
79f8c9d420 test(cli): refresh stale deploy + logs journey assertions
Six journeys asserted against output formats that changed in earlier
commits and had been failing regardless of branch:

* `pic scripts deploy` prints a `name`/`version`/`action` KvBlock, not a
  prose "Created X vN" line. The name-override, version-bump, and the
  editor-can-deploy role check now parse the KvBlock fields (asserting the
  exact version + created/updated action) via a small `kv_field` helper.
* `pic logs` gained a `source` column (now `created_at, source, status,
  summary`); the success/error-status and summary-truncation checks now
  index the status at col 2 and the summary at col 3.

No production code touched — the deploys/logs always succeeded; only the
assertions were stale. Full `--test cli --include-ignored` suite is now
green (101 passed, 0 failed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:37:36 +02:00
MechaCat02
bb68e5e50a fix(secrets): composite keyset for group-secret admin list pagination
`list_meta` orders by `(name, environment_scope)` — a group secret name
can carry several env-scoped rows — but the cursor encoded only `name`
and paginated with `name > $cursor`. When a name's scopes straddled a
page boundary, the remaining scope rows were silently skipped from the
admin listing. Switch to a composite `(name, environment_scope)` keyset
(base64url of `name \x1f scope`) and a Postgres row-comparison predicate.
App secrets (single `*` scope per name) were unaffected; group secrets
with multi-scope names now page completely. Found in final branch review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:13:00 +02:00
MechaCat02
11ac168839 test(secrets): group-secret inheritance + masked-read journeys
Two end-to-end journeys via `pic` against a real server:

* `group_secret_is_injected_then_app_value_overrides` — a group-owned
  secret is injected into a descendant app's script through `secrets::get`
  (decrypted under the group AAD), then an app-owned secret of the same
  name shadows it (decrypted under the app AAD). Drives the resolver + both
  owner-AAD open paths against Postgres.
* `group_secret_value_is_masked_from_app_devs` — the §5.3 boundary: an app
  dev (editor on the app, no group role) is denied the secret VALUE (403)
  yet still sees it EXISTS, masked, in `config/effective` (status set,
  owner group, no value); a group_admin reads the value. Proves an app
  runs with config its own devs cannot read.

Also updates the existing app-secrets `ls` journey for the new `env`
column (group secrets are env-scoped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:09:14 +02:00
MechaCat02
30441549d5 feat(cli): pic secrets --group, value read, and effective vars/config
Mirrors `pic vars` on the secrets surface: `pic secrets ls/set/rm` take an
`--app`/`--group` owner selector (exactly-one) with `--env` for group
secrets. Adds `pic secrets read --group <g> <name> [--env]` — the only
command that reveals a secret value, hitting the group-gated value
endpoint. `pic config --effective` now folds in the resolved vars section
(value + owner + scope) and an `--explain` provenance view, alongside the
existing masked-secrets cross-reference, via `GET /config/effective`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:00:32 +02:00
MechaCat02
c914758c09 feat(secrets): group-secrets admin API, masked read, config/effective
Adds the Phase-3 admin surface on top of the group-secrets storage:

* `secrets_api` gains group routes under `/groups/{id}/secrets`
  (set/list/delete, env-scoped) gated `GroupSecretsWrite` (editor+), plus
  the ONE plaintext endpoint `GET /groups/{id}/secrets/{name}/value` gated
  `GroupSecretsRead` (group_admin only). That is the masked-secret
  boundary: a descendant app's dev sees a group secret EXISTS and consumes
  it at runtime via `secrets::get`, but only a reader at the OWNING group
  gets the value. App secrets stay env-agnostic (a stray `env` is rejected).
  The owner is resolved first, then the capability binds to the resolved
  id — never a path param.

* `config_api`: `GET /apps/{id}/config/effective` (gated `AppVarsRead`)
  returns the resolved view a dev would get — every inherited var with its
  value + provenance, and every inherited secret MASKED (name/owner/scope,
  never the value). Backed by a new `fetch_effective_secret_meta`
  (DISTINCT-ON nearest-wins, same ordering as the per-name resolver).

* authz: `GroupSecretsWrite` moves from `app:admin` to `script:write`
  scope so its API-key scope matches its editor role tier (closing the
  latent scope/role mismatch the checkpoint review flagged); the value
  read `GroupSecretsRead` stays at `app:admin`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:00:32 +02:00
MechaCat02
e6b4792389 feat(secrets): group-owned, env-scoped secrets + inherited resolution
Migration 0049 reshapes `secrets` to the same polymorphic-owner contract
as `vars` (0048): a secret is owned by an app XOR an ancestor group,
carries an `environment_scope`, and the old PK `(app_id, name)` becomes
two partial-unique indexes `(owner, environment_scope, name)`. Existing
rows backfill to `app_id` + scope `'*'`; the v1 AAD does NOT include the
scope, so every current ciphertext keeps decrypting byte-for-byte.

`SecretsRepo` is generalised to be owner+scope keyed (`SecretOwner` moves
down from `secrets_service` and is re-exported for path stability). The
SDK read path now goes through `SecretsRepo::resolve`, which reuses the
shared `CHAIN_LEVELS_CTE` to walk app→ancestor-group→root, env-filters,
and takes the nearest level (`@E` beating `*` within a level) — returning
the winning owner so the value is decrypted under the AAD it was sealed
with. Runtime injection stays anchored to `cx.app_id`: an app only ever
resolves its own and its ancestors' secrets.

All callers updated to owner+scope (`SecretOwner::App(_)`, scope `'*'`):
the app-secrets admin API, the SDK service, and the apply email-secret
path. The 21 secrets unit tests (incl. the app/group AAD-disjointness and
cross-row swap proofs) stay green; the chain-walk was live-verified
against Postgres (nearest-wins + env-filter). Admin API for group secrets
and the masked-read endpoint land next (Step E).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:48:33 +02:00
MechaCat02
b588fc9d35 fix(config): same-level @E config suppresses * instead of merging it
§3 step 1 treats an environment scope as *eligibility*, not a merge tier:
within a single level a value scoped `@E` shadows the same level's `*`
fallback — it never layers on top of it. The resolver's map-run loop
collected every leading object row regardless of (depth, scope), so a
level holding both an `@E` map and a `*` map would deep-merge the two
(and report a bogus `*` layer in `merged_from`) rather than letting the
`@E` map win alone.

Dedup the sorted candidates by depth before the map run: each level is a
single owner (single-parent tree) with at most one `@E` and one `*` row
per key after env-filtering, and `@E` sorts first, so keeping the first
row per level yields the level's winner. Scalar/tombstone cases already
went through the boundary path and were unaffected; only same-level
map-vs-map mis-merged.

Adds two regression tests (same-level suppression; suppressed `*` stays
invisible to cross-level merge). Found in checkpoint review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:32:15 +02:00
MechaCat02
6bd0f4699d feat(secrets): owner-discriminated AAD (SecretOwner) for group secrets
The crypto foundation for group-owned secrets (the §5.3 'single hardest
correctness detail'), done first and proven before the storage/resolution
layer.

- SecretOwner{App(AppId)|Group(GroupId)}; secret_aad/seal/open take the
  owner. The app AAD is byte-identical to the pre-Phase-3
  'secret:{app_id}:{name}', so every existing v1 row decrypts unchanged;
  group secrets use a disjoint 'secret:group:{group_id}:{name}' namespace
  (the 'group:' infix separates app/group AAD even for equal UUIDs).
- All callers (SDK get/set, secrets_api, apply email-secret read) wrapped
  in SecretOwner::App — behavior identical for app secrets.
- New audit test aad_distinguishes_app_and_group_owner proves the two
  namespaces don't collide (both directions, even reusing the UUID); the
  existing cross-app/cross-name swap audit tests stay green.

16 secrets_service tests pass; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:17:18 +02:00
MechaCat02
9ee85993d8 feat(vars): admin CRUD API + pic vars CLI
Completes the vars half of Phase 3 end-to-end:
- vars_repo: VarOwner{Group|App} upsert/delete/list (owner-kind-specific
  SQL; ON CONFLICT restates the partial-index predicate).
- vars_api: GET/PUT/DELETE under /apps/{id}/vars and /groups/{id}/vars,
  resolve-then-require gated on App/GroupVars{Read,Write}, secrets-style
  error mapping + key/env-scope validation.
- pic vars ls/set/rm (--group|--app, --env, --json, --tombstone).
- journey test: a group var is inherited by a descendant app's
  vars::get(), and an app-level value overrides it (proximity) — green.

386 manager-core lib tests + the vars journey pass; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:11:46 +02:00
MechaCat02
343f6d3b4d feat(vars): VarsService + vars:: SDK + config capabilities
Scripts can now read group-inherited config via vars::get(key) / vars::all().

- shared: VarsService trait (read-only get/all) + NoopVarsService; added as
  the 14th field on the Services bundle.
- manager-core: VarsServiceImpl resolves the calling app's config via
  config_resolver (derives app_id from cx.app_id; AppVarsRead-gated for
  authed principals, script-as-gate for anon).
- executor-core: vars:: Rhai bridge (get/all), registered in the SDK.
- authz: AppVarsRead/Write, GroupVarsRead/Write, GroupSecretsRead/Write
  capabilities (group caps resolve via the Phase-2 ancestor walk; the
  GroupSecretsRead human-read gate is group_admin-only, distinct from an
  app's runtime injection).
- wired VarsServiceImpl into the picloud binary; updated the executor-core
  test Services::new call sites.

386 manager-core lib tests green; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:59:23 +02:00
MechaCat02
35dbd9f368 feat(config): vars schema + the §3 resolution engine
Phase 3 foundation (docs §3): env-filtered, proximity-first config
inheritance down the group tree.

- Migration 0048: `vars` (polymorphic group|app owner via two nullable FKs
  + CHECK exactly-one, env scope, JSONB value, explicit tombstone) +
  `apps.environment` (the env marker the resolver filters on — 'an
  environment is an app').
- config_resolver: a shared chain-walk CTE (mirrors effective_app_role) that
  walks app → ancestor groups, env-filters in SQL, plus a pure Rust
  resolution pass implementing the §3 semantics SQL can't — per-key
  proximity-first, @E-over-* within a level, map deep-merge, tombstone
  suppression, and provenance for --explain.

Verified: 8 unit tests (incl. the §3.2 proximity-beats-env-specificity call,
deep-merge, tombstone, boundary) + the candidate-fetch CTE against live
Postgres (env-filter drops a non-matching @production value; nearer level
shadows farther).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:43:16 +02:00
MechaCat02
6db057fb08 Merge: groups Phase 2 — org/RBAC/UI container (migration 0047)
Adds the GitLab-style single-parent group tree above apps: migration
0047 (groups + group_members + apps.group_id NOT NULL/RESTRICT with
single-root backfill), tree repos with an ancestor-walk cycle guard
under a coarse structural advisory lock, slug-freeze and structure_version,
hierarchy-aware RBAC (effective role = MAX across app membership + every
ancestor group, resolved live so revocation is immediate), new
Group{Read,Write,Admin}/InstanceCreateGroup caps that bound API keys
cannot obtain, plus `pic groups` and a dashboard group tree/detail/members.

Verified before merge: fmt/clippy(-D warnings)/check clean; manager-core
378 unit tests pass (incl. hierarchy authz + group repos); dashboard
npm run check 0 errors; check-versioning OK (47 migrations sequential).
New CLI groups journeys pass against a live DB — cycle guard
(reparent-into-descendant rejected), delete=RESTRICT, and
inherited-group-admin deploy + live revoke. The 6 failing logs/scripts/roles
journeys are the same pre-existing format drift on main, not regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:26:21 +02:00
167 changed files with 23354 additions and 1026 deletions

5
.gitignore vendored
View File

@@ -26,8 +26,11 @@ docker-compose.override.yml
config.local.toml
/data
# Files-root blob storage created when integration tests run build_app
# from the picloud crate dir (PICLOUD_FILES_ROOT default ./data).
# from a crate dir (PICLOUD_FILES_ROOT default ./data). The CLI journey
# harness spawns the server from the picloud-cli dir, so it lands there too
# (the §11.6 group-files journey is the first CLI test to write blobs).
/crates/picloud/data
/crates/picloud-cli/data
/postgres-data
# Dashboard

File diff suppressed because one or more lines are too long

View File

@@ -300,11 +300,18 @@ impl Engine {
// installed with the real per-call resolver. The resolver owns
// `cx.clone()` so cross-app isolation derives from this exact
// call's context, not from any script-passed argument.
// The lexical origin for `import` resolution (§5.5): the top-level
// script's defining node. Falls back to the executing app when the
// request predates Phase 4b (old payloads / cluster wire).
let default_origin = req
.script_owner
.unwrap_or(picloud_shared::ScriptOwner::App(req.app_id));
let resolver = PicloudModuleResolver::new(
self.services.modules.clone(),
cx.clone(),
self.module_cache.clone(),
effective_limits.module_import_depth_max,
default_origin,
);
engine.set_module_resolver(resolver);
let self_engine = self.self_arc();

View File

@@ -1,32 +1,42 @@
//! `PicloudModuleResolver` — the v1.1.3 per-app Rhai module resolver.
//! `PicloudModuleResolver` — the Rhai module resolver (v1.1.3; made
//! origin-aware / lexical in v1.2 Phase 4b).
//!
//! Replaces `DummyModuleResolver` in `Engine::build_engine`. Constructed
//! fresh per `Engine::execute` call: holds an `Arc<SdkCallCx>` so every
//! `import "<name>"` request resolves against the calling app
//! (`cx.app_id`). The script-side `name` argument carries no `app_id`
//! — that's the load-bearing cross-app isolation property.
//! fresh per `Engine::execute` call.
//!
//! **Lexical resolution (§5.5).** An `import "<name>"` resolves against the
//! module set visible at the **importing script's own defining node**,
//! walking up its group chain — not the inheriting app's effective view.
//! The importing node is read from Rhai's `_source` (the importing AST's
//! source tag, which the resolver sets to each module's owner) and falls
//! back to `default_origin` (the entry script's defining node) for the
//! top-level AST. So an inherited group script's imports seal to the group
//! (a leaf can't shadow them — the trust boundary), while every SDK call
//! *inside* a module still scopes to the inheriting app via `cx.app_id`.
//!
//! Three runtime invariants are enforced:
//!
//! 1. **Cross-app isolation** — `ModuleSource::lookup` is called with
//! `&cx`; the Postgres impl scopes by `cx.app_id` (never by a
//! script-passed argument).
//! 1. **Lexical isolation** — `ModuleSource::resolve` is keyed by the
//! importing node's owner (a trusted dispatch-derived value), never a
//! script-passed argument.
//! 2. **Cycle detection** — an in-progress-imports stack rejects
//! `A → B → A` with `ErrorInModule(... circular import detected ...)`.
//! 3. **Depth limit** — guards against deep but acyclic chains
//! (default 8, override via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`).
//!
//! Compiled modules are cached per `(app_id, name)` and invalidated by
//! `updated_at` change — no explicit pub/sub. The cache is owned by
//! `Engine` and shared across calls; only the resolver state (stack,
//! depth) is per-call.
//! Compiled modules are cached by resolved `ScriptId` (a compiled module
//! is lexically self-contained) and invalidated by `updated_at` change —
//! no explicit pub/sub. The cache is owned by `Engine` and shared across
//! calls; only the resolver state (stack, depth) is per-call.
use std::num::NonZeroUsize;
use std::sync::{Arc, Mutex};
use chrono::{DateTime, Utc};
use lru::LruCache;
use picloud_shared::{AppId, ModuleSource, ModuleSourceError, SdkCallCx, ValidatedScript};
use picloud_shared::{
ModuleSource, ModuleSourceError, ScriptId, ScriptOwner, SdkCallCx, ValidatedScript,
};
use rhai::module_resolvers::ModuleResolver;
use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST};
@@ -35,10 +45,38 @@ use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST};
/// `sync` feature that the workspace pins.
type SharedRhaiModule = Shared<Module>;
/// Cache key: `(app_id, module name)`. v1.1.3 enforces module names as
/// a conservative identifier shape (migration 0015 `scripts_module_name_shape`
/// CHECK) so the `String` here is bounded by ~64 bytes.
pub type ModuleCacheKey = (AppId, String);
/// Cache key: the resolved module's `ScriptId` (Phase 4b). A compiled
/// module is lexically self-contained (its nested imports resolve from
/// its own defining node, baked in at compile time), so the *body* is a
/// pure function of `(script_id, updated_at)` — independent of which
/// script imported it. Keying by id also dedupes a shared group module
/// across every inheriting app, and keeps cross-app modules of the same
/// name distinct (distinct ids).
pub type ModuleCacheKey = ScriptId;
/// Encode a defining node as an AST `source` tag (`app:<uuid>` /
/// `group:<uuid>`). Set on a resolved module's AST so its own nested
/// `import`s carry *its* node as Rhai's `_source` — the lexical chaining
/// that seals each module's imports to where it was authored (§5.5).
fn encode_origin(owner: ScriptOwner) -> String {
match owner {
ScriptOwner::App(a) => format!("app:{a}"),
ScriptOwner::Group(g) => format!("group:{g}"),
}
}
/// Inverse of [`encode_origin`]. `None` for an unrecognised tag (e.g. a
/// source set by something other than this resolver) — the caller then
/// falls back to the resolver's `default_origin`.
fn decode_origin(s: &str) -> Option<ScriptOwner> {
let (kind, id) = s.split_once(':')?;
let uuid = uuid::Uuid::parse_str(id).ok()?;
match kind {
"app" => Some(ScriptOwner::App(uuid.into())),
"group" => Some(ScriptOwner::Group(uuid.into())),
_ => None,
}
}
/// Cache value: the freshness comparator + the compiled module Rhai
/// hands to importing scripts. Cloning the `Shared<Module>` is an Arc bump.
@@ -65,17 +103,19 @@ pub fn new_module_cache(capacity: usize) -> Arc<ModuleCache> {
/// The v1.1.3 module resolver. One per `Engine::execute` call.
pub struct PicloudModuleResolver {
/// Backend the resolver consults for `(app_id, name)`. The bridge
/// runs Rhai's sync `resolve()` and the async `lookup()` together
/// via `tokio::runtime::Handle::block_on(...)` — safe because
/// Backend the resolver consults to resolve a module name against an
/// origin node's chain. The bridge runs Rhai's sync `resolve()` and the
/// async `ModuleSource::resolve()` together via
/// `tokio::runtime::Handle::block_on(...)` — safe because
/// `LocalExecutorClient` runs `Engine::execute` inside
/// `spawn_blocking`, which puts us on a Tokio blocking thread
/// that still carries a `Handle`.
source: Arc<dyn ModuleSource>,
/// Calling context. `cx.app_id` is the cross-app isolation
/// boundary; the resolver passes `&cx` to every `ModuleSource`
/// call so the backend can scope its queries.
/// Calling context. `cx.app_id` is the execution boundary every SDK
/// call inside a module scopes to; the resolver keeps it for diagnostic
/// logging. (Module *resolution* is keyed by the importing node's owner,
/// not `cx.app_id` — see the module docs.)
cx: Arc<SdkCallCx>,
/// Compiled-module cache. Shared across executions; invalidated
@@ -96,6 +136,12 @@ pub struct PicloudModuleResolver {
/// via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`. Read from `Limits` at
/// resolver construction.
depth_limit: u32,
/// Lexical origin for a top-level `import` — the defining node of the
/// script being executed (Phase 4b, §5.5). Used when Rhai gives no
/// `_source` (the entry AST). Nested imports inside a resolved module
/// override this via the module AST's source (C3).
default_origin: ScriptOwner,
}
impl PicloudModuleResolver {
@@ -105,6 +151,7 @@ impl PicloudModuleResolver {
cx: Arc<SdkCallCx>,
cache: Arc<ModuleCache>,
depth_limit: u32,
default_origin: ScriptOwner,
) -> Self {
Self {
source,
@@ -113,6 +160,7 @@ impl PicloudModuleResolver {
in_progress: Mutex::new(Vec::new()),
depth: Mutex::new(0),
depth_limit,
default_origin,
}
}
@@ -224,7 +272,7 @@ impl ModuleResolver for PicloudModuleResolver {
fn resolve(
&self,
engine: &RhaiEngine,
_source: Option<&str>,
importer_source: Option<&str>,
path: &str,
pos: Position,
) -> Result<SharedRhaiModule, Box<EvalAltResult>> {
@@ -319,17 +367,50 @@ impl ModuleResolver for PicloudModuleResolver {
))
})?;
let lookup_result: Result<Option<picloud_shared::ModuleScript>, ModuleSourceError> =
tokio::task::block_in_place(|| handle.block_on(self.source.lookup(&self.cx, path)));
// Lexical resolution (§5.5): resolve `path` against the *importing
// node's* chain. Rhai gives `_source` = the importing AST's source
// tag — set to the module's defining node for a nested import, or
// unset (`None`) for the top-level entry script, where we fall back
// to `default_origin` (the executing script's node). An app-rooted
// walk reaches ancestor group modules; a group-rooted walk is sealed
// from app modules below it (the trust boundary).
let origin = importer_source
.and_then(decode_origin)
.unwrap_or(self.default_origin);
// §5.5 policy resolution: the source decides lexical (seal to `origin`)
// vs dynamic (an extension point resolves against the inheriting app,
// `cx.app_id`). The result is already the concrete module to bind, or a
// terminal NoProvider/NotFound.
let lookup_result: Result<picloud_shared::ModuleResolution, ModuleSourceError> =
tokio::task::block_in_place(|| {
handle.block_on(self.source.resolve_policy(origin, self.cx.app_id, path))
});
let module_row = match lookup_result {
Ok(Some(m)) => m,
Ok(None) => {
Ok(picloud_shared::ModuleResolution::Module(m)) => m,
Ok(picloud_shared::ModuleResolution::NotFound) => {
return Err(Box::new(EvalAltResult::ErrorModuleNotFound(
path.to_string(),
pos,
)));
}
Ok(picloud_shared::ModuleResolution::NoProvider) => {
// §5.5: an extension point with no provider for this app is a
// hard failure (the app must supply the module or a default
// body must exist up-chain). Distinct, actionable message.
return Err(Box::new(EvalAltResult::ErrorInModule(
path.to_string(),
Box::new(EvalAltResult::ErrorRuntime(
format!(
"extension point {path:?} has no provider for this app — \
define a module named {path:?} or a default body up-chain"
)
.into(),
pos,
)),
pos,
)));
}
Err(e) => {
// v1.1.4 §10a: redact the backend error before it
// reaches a script. In public-HTTP context (principal:
@@ -355,8 +436,10 @@ impl ModuleResolver for PicloudModuleResolver {
};
// Cache lookup: hit only if both key matches AND updated_at
// matches (cache is invalidated lazily on version change).
let cache_key = (self.cx.app_id, path.to_string());
// matches (cache is invalidated lazily on version change). Keyed by
// the resolved module's id (lexically self-contained — see
// `ModuleCacheKey`).
let cache_key = module_row.script_id;
{
let mut cache = self.cache.lock().expect("module cache lock poisoned");
if let Some(cached) = cache.get(&cache_key) {
@@ -389,7 +472,7 @@ impl ModuleResolver for PicloudModuleResolver {
// already been gated at create-time (admin endpoint runs
// `validate_module`), but we revalidate here to catch DB-direct
// inserts that bypass the API surface.
let ast = engine.compile(&module_row.source).map_err(|e| {
let mut ast = engine.compile(&module_row.source).map_err(|e| {
// Wrap as an ErrorRuntime to preserve the parse message
// text without trying to reconstruct rhai's internal
// ParseErrorType variant (which would require matching on
@@ -404,6 +487,15 @@ impl ModuleResolver for PicloudModuleResolver {
))
})?;
// Seal this module's own imports to its defining node (§5.5): tag
// the AST source with the resolved module's owner so that when
// `eval_ast_as_new` evaluates its nested `import`s, Rhai hands us
// that tag as `_source` and we resolve them lexically from *here*,
// not from the original importer. Fall back to the lookup origin
// for a corrupt owner row (never reached under the DB CHECK).
let module_owner = module_row.owner().unwrap_or(origin);
ast.set_source(encode_origin(module_owner));
if let Err(msg) = Self::check_module_shape(&ast, path) {
return Err(Box::new(EvalAltResult::ErrorInModule(
path.to_string(),

View File

@@ -23,7 +23,7 @@
use std::sync::Arc;
use picloud_shared::{DocId, DocRow, DocsService, SdkCallCx, Services};
use picloud_shared::{DocId, DocRow, DocsService, GroupDocsService, SdkCallCx, Services};
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
use uuid::Uuid;
@@ -38,8 +38,20 @@ pub struct DocsHandle {
cx: Arc<SdkCallCx>,
}
/// §11.6 shared-collection handle, returned by `docs::shared_collection(name)`.
/// A distinct Rhai type from `DocsHandle` so a script's choice of
/// private-vs-shared scope is explicit. Routes through `GroupDocsService`, which
/// resolves the owning group from `cx.app_id` (the script never names a group).
#[derive(Clone)]
pub struct GroupDocsHandle {
collection: String,
service: Arc<dyn GroupDocsService>,
cx: Arc<SdkCallCx>,
}
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
let docs_service = services.docs.clone();
let group_docs_service = services.group_docs.clone();
let mut module = Module::new();
{
@@ -59,6 +71,23 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
},
);
}
{
let group_docs_service = group_docs_service.clone();
let cx = cx.clone();
module.set_native_fn(
"shared_collection",
move |name: &str| -> Result<GroupDocsHandle, Box<EvalAltResult>> {
if name.is_empty() {
return Err("docs::shared_collection name must not be empty".into());
}
Ok(GroupDocsHandle {
collection: name.to_string(),
service: group_docs_service.clone(),
cx: cx.clone(),
})
},
);
}
engine.register_static_module("docs", module.into());
engine.register_type_with_name::<DocsHandle>("DocsHandle");
@@ -70,6 +99,17 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
register_update(engine);
register_delete(engine);
register_list(engine);
// Same method names on GroupDocsHandle — Rhai dispatches by receiver type.
engine.register_type_with_name::<GroupDocsHandle>("GroupDocsHandle");
register_group_create(engine);
register_group_get(engine);
register_group_find(engine);
register_group_find_one(engine);
register_group_update(engine);
register_group_delete(engine);
register_group_list(engine);
}
fn register_create(engine: &mut RhaiEngine) {
@@ -218,6 +258,153 @@ fn list_call(
Ok(m)
}
// --- GroupDocsHandle methods (§11.6 shared docs collections) ---------------
fn register_group_create(engine: &mut RhaiEngine) {
engine.register_fn(
"create",
|handle: &mut GroupDocsHandle, data: Map| -> Result<String, Box<EvalAltResult>> {
let h = handle.clone();
let json = dynamic_to_json(&Dynamic::from(data));
let id = block_on("docs", async move {
h.service.create(&h.cx, &h.collection, json).await
})?;
Ok(id.to_string())
},
);
}
fn register_group_get(engine: &mut RhaiEngine) {
engine.register_fn(
"get",
|handle: &mut GroupDocsHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
let h = handle.clone();
let parsed_id = parse_doc_id(id)?;
let row = block_on("docs", async move {
h.service.get(&h.cx, &h.collection, parsed_id).await
})?;
Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d))))
},
);
}
fn register_group_find(engine: &mut RhaiEngine) {
engine.register_fn(
"find",
|handle: &mut GroupDocsHandle, filter: Map| -> Result<Array, Box<EvalAltResult>> {
let h = handle.clone();
let json = dynamic_to_json(&Dynamic::from(filter));
let rows = block_on("docs", async move {
h.service.find(&h.cx, &h.collection, json).await
})?;
Ok(rows
.iter()
.map(|d| Dynamic::from(doc_to_map(d)))
.collect::<Vec<Dynamic>>())
},
);
}
fn register_group_find_one(engine: &mut RhaiEngine) {
engine.register_fn(
"find_one",
|handle: &mut GroupDocsHandle, filter: Map| -> Result<Dynamic, Box<EvalAltResult>> {
let h = handle.clone();
let json = dynamic_to_json(&Dynamic::from(filter));
let row = block_on("docs", async move {
h.service.find_one(&h.cx, &h.collection, json).await
})?;
Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d))))
},
);
}
fn register_group_update(engine: &mut RhaiEngine) {
engine.register_fn(
"update",
|handle: &mut GroupDocsHandle, id: &str, data: Map| -> Result<(), Box<EvalAltResult>> {
let h = handle.clone();
let parsed_id = parse_doc_id(id)?;
let json = dynamic_to_json(&Dynamic::from(data));
block_on("docs", async move {
h.service
.update(&h.cx, &h.collection, parsed_id, json)
.await
})
},
);
}
fn register_group_delete(engine: &mut RhaiEngine) {
engine.register_fn(
"delete",
|handle: &mut GroupDocsHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
let h = handle.clone();
let parsed_id = parse_doc_id(id)?;
block_on("docs", async move {
h.service.delete(&h.cx, &h.collection, parsed_id).await
})
},
);
}
fn register_group_list(engine: &mut RhaiEngine) {
engine.register_fn(
"list",
|handle: &mut GroupDocsHandle| -> Result<Map, Box<EvalAltResult>> {
group_list_call(handle, None, 0)
},
);
engine.register_fn(
"list",
|handle: &mut GroupDocsHandle, args: Map| -> Result<Map, Box<EvalAltResult>> {
let cursor = match args.get("cursor") {
Some(d) if !d.is_unit() => {
Some(d.clone().into_string().map_err(|_| -> Box<EvalAltResult> {
"docs::list: 'cursor' must be a string or ()".into()
})?)
}
_ => None,
};
let limit = match args.get("limit") {
Some(d) if !d.is_unit() => {
let n = d.as_int().map_err(|_| -> Box<EvalAltResult> {
"docs::list: 'limit' must be an integer".into()
})?;
u32::try_from(n.max(0)).unwrap_or(0)
}
_ => 0,
};
group_list_call(handle, cursor, limit)
},
);
}
fn group_list_call(
handle: &GroupDocsHandle,
cursor: Option<String>,
limit: u32,
) -> Result<Map, Box<EvalAltResult>> {
let h = handle.clone();
let page = block_on("docs", async move {
h.service
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
.await
})?;
let mut m = Map::new();
let docs: Array = page
.docs
.iter()
.map(|d| Dynamic::from(doc_to_map(d)))
.collect();
m.insert("docs".into(), docs.into());
m.insert(
"next_cursor".into(),
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
);
Ok(m)
}
/// Build the `{ id, data, created_at, updated_at }` envelope per
/// Decision D. Scripts read user fields via `doc.data.<field>`; `id`
/// and timestamps are direct children of the envelope.

View File

@@ -24,7 +24,9 @@
use std::sync::Arc;
use super::bridge::block_on;
use picloud_shared::{FileMeta, FileUpdate, FilesService, NewFile, SdkCallCx, Services};
use picloud_shared::{
FileMeta, FileUpdate, FilesService, GroupFilesService, NewFile, SdkCallCx, Services,
};
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
@@ -36,8 +38,20 @@ pub struct FilesHandle {
cx: Arc<SdkCallCx>,
}
/// §11.6 shared-collection handle, returned by `files::shared_collection(name)`.
/// Same method surface as `FilesHandle`, but routes through the
/// `GroupFilesService` — the owning group is resolved from `cx.app_id`'s
/// ancestor chain inside the service (the isolation boundary).
#[derive(Clone)]
pub struct GroupFilesHandle {
collection: String,
service: Arc<dyn GroupFilesService>,
cx: Arc<SdkCallCx>,
}
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
let files_service = services.files.clone();
let group_files_service = services.group_files.clone();
let mut module = Module::new();
{
@@ -57,6 +71,23 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
},
);
}
{
let group_files_service = group_files_service.clone();
let cx = cx.clone();
module.set_native_fn(
"shared_collection",
move |name: &str| -> Result<GroupFilesHandle, Box<EvalAltResult>> {
if name.is_empty() {
return Err("files::shared_collection name must not be empty".into());
}
Ok(GroupFilesHandle {
collection: name.to_string(),
service: group_files_service.clone(),
cx: cx.clone(),
})
},
);
}
engine.register_static_module("files", module.into());
engine.register_type_with_name::<FilesHandle>("FilesHandle");
@@ -67,6 +98,15 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
register_update(engine);
register_delete(engine);
register_list(engine);
// Same method names on GroupFilesHandle — Rhai dispatches by receiver type.
engine.register_type_with_name::<GroupFilesHandle>("GroupFilesHandle");
register_group_create(engine);
register_group_head(engine);
register_group_get(engine);
register_group_update(engine);
register_group_delete(engine);
register_group_list(engine);
}
fn register_create(engine: &mut RhaiEngine) {
@@ -220,6 +260,163 @@ fn list_call(
Ok(m)
}
// --- GroupFilesHandle methods (§11.6 shared files collections) -------------
// Bodies mirror the app handle's; only the receiver type and service differ
// (Rhai dispatches `create`/`head`/... by the handle's concrete type).
fn register_group_create(engine: &mut RhaiEngine) {
engine.register_fn(
"create",
|handle: &mut GroupFilesHandle, meta: Map| -> Result<String, Box<EvalAltResult>> {
let name = require_string(&meta, "name")?;
let content_type = require_string(&meta, "content_type")?;
let data = require_blob(&meta, "data")?;
let h = handle.clone();
let new = NewFile {
name,
content_type,
data,
};
let id = block_on("files", async move {
h.service.create(&h.cx, &h.collection, new).await
})?;
Ok(id.to_string())
},
);
}
fn register_group_head(engine: &mut RhaiEngine) {
engine.register_fn(
"head",
|handle: &mut GroupFilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
let h = handle.clone();
let id = id.to_string();
let meta = block_on("files", async move {
h.service.head(&h.cx, &h.collection, &id).await
})?;
Ok(meta.map_or(Dynamic::UNIT, |m| file_meta_to_map(&m).into()))
},
);
}
fn register_group_get(engine: &mut RhaiEngine) {
engine.register_fn(
"get",
|handle: &mut GroupFilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
let h = handle.clone();
let id = id.to_string();
let bytes = block_on("files", async move {
h.service.get(&h.cx, &h.collection, &id).await
})?;
Ok(bytes.map_or(Dynamic::UNIT, Dynamic::from_blob))
},
);
}
fn register_group_update(engine: &mut RhaiEngine) {
engine.register_fn(
"update",
|handle: &mut GroupFilesHandle, id: &str, meta: Map| -> Result<(), Box<EvalAltResult>> {
let data = require_blob(&meta, "data")?;
let name = optional_string(&meta, "name")?;
let content_type = optional_string(&meta, "content_type")?;
let h = handle.clone();
let id = id.to_string();
let upd = FileUpdate {
data,
name,
content_type,
};
block_on("files", async move {
h.service.update(&h.cx, &h.collection, &id, upd).await
})
},
);
}
fn register_group_delete(engine: &mut RhaiEngine) {
engine.register_fn(
"delete",
|handle: &mut GroupFilesHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
let h = handle.clone();
let id = id.to_string();
block_on("files", async move {
h.service.delete(&h.cx, &h.collection, &id).await
})
},
);
}
fn register_group_list(engine: &mut RhaiEngine) {
engine.register_fn(
"list",
|handle: &mut GroupFilesHandle| -> Result<Map, Box<EvalAltResult>> {
group_list_call(handle, None, 0)
},
);
engine.register_fn(
"list",
|handle: &mut GroupFilesHandle, cursor: &str| -> Result<Map, Box<EvalAltResult>> {
group_list_call(handle, Some(cursor.to_string()), 0)
},
);
engine.register_fn(
"list",
|handle: &mut GroupFilesHandle,
cursor: &str,
limit: i64|
-> Result<Map, Box<EvalAltResult>> {
let limit = u32::try_from(limit.max(0)).unwrap_or(0);
group_list_call(handle, Some(cursor.to_string()), limit)
},
);
engine.register_fn(
"list",
|handle: &mut GroupFilesHandle, opts: Map| -> Result<Map, Box<EvalAltResult>> {
let cursor = match opts.get("cursor") {
Some(v) if !v.is_unit() => {
Some(v.clone().into_string().map_err(|_| -> Box<EvalAltResult> {
"files: list cursor must be a string".into()
})?)
}
_ => None,
};
let limit = match opts.get("limit") {
Some(v) if !v.is_unit() => {
u32::try_from(v.as_int().unwrap_or(0).max(0)).unwrap_or(0)
}
_ => 0,
};
group_list_call(handle, cursor, limit)
},
);
}
fn group_list_call(
handle: &GroupFilesHandle,
cursor: Option<String>,
limit: u32,
) -> Result<Map, Box<EvalAltResult>> {
let h = handle.clone();
let page = block_on("files", async move {
h.service
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
.await
})?;
let mut m = Map::new();
let files: Array = page
.files
.iter()
.map(|meta| Dynamic::from(file_meta_to_map(meta)))
.collect();
m.insert("files".into(), files.into());
m.insert(
"next_cursor".into(),
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
);
Ok(m)
}
/// Render a `FileMeta` into the Rhai map shape scripts see from
/// `head` / `list`.
fn file_meta_to_map(meta: &FileMeta) -> Map {

View File

@@ -179,6 +179,10 @@ fn invoke_blocking(
rest: String::new(),
sandbox_overrides: picloud_shared::ScriptSandbox::default(),
app_id: cx.app_id,
// Lexical origin (§5.5): the callee's defining node — a group
// script's imports resolve from the group even when invoked by an
// app. `None` falls back to `App(cx.app_id)` in the engine.
script_owner: resolved.owner,
// Same-app invoke is a function call, not a re-auth boundary —
// inherit the caller's principal.
principal: cx.principal.clone(),

View File

@@ -28,7 +28,7 @@
use std::sync::Arc;
use picloud_shared::{KvService, SdkCallCx, Services};
use picloud_shared::{GroupKvService, KvService, SdkCallCx, Services};
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
use super::bridge::{block_on, dynamic_to_json, json_to_dynamic};
@@ -42,11 +42,25 @@ pub struct KvHandle {
cx: Arc<SdkCallCx>,
}
/// §11.6 shared-collection handle, returned by `kv::shared_collection(name)`. A distinct
/// Rhai type from `KvHandle` so the method set can diverge (e.g. shared
/// collections never grow triggers) and a script's choice of private-vs-shared
/// scope is explicit. Routes through the `GroupKvService`, which resolves the
/// owning group from `cx.app_id` (the script never names a group).
#[derive(Clone)]
pub struct GroupKvHandle {
collection: String,
service: Arc<dyn GroupKvService>,
cx: Arc<SdkCallCx>,
}
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
let kv_service = services.kv.clone();
let group_kv_service = services.group_kv.clone();
// `kv::collection(name)` — handle constructor lives in the `kv`
// static module so the script-visible call is `kv::collection(...)`.
// `kv::collection(name)` / `kv::shared_collection(name)` — both constructors live in
// the `kv` static module so the script-visible calls are `kv::collection`
// and `kv::shared`.
let mut module = Module::new();
{
let kv_service = kv_service.clone();
@@ -65,6 +79,23 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
},
);
}
{
let group_kv_service = group_kv_service.clone();
let cx = cx.clone();
module.set_native_fn(
"shared_collection",
move |name: &str| -> Result<GroupKvHandle, Box<EvalAltResult>> {
if name.is_empty() {
return Err("kv::shared_collection name must not be empty".into());
}
Ok(GroupKvHandle {
collection: name.to_string(),
service: group_kv_service.clone(),
cx: cx.clone(),
})
},
);
}
engine.register_static_module("kv", module.into());
// Methods on KvHandle — `register_fn` with `&mut KvHandle` first
@@ -77,6 +108,15 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
register_has(engine);
register_delete(engine);
register_list(engine);
// Same method names on GroupKvHandle — Rhai dispatches by receiver type.
engine.register_type_with_name::<GroupKvHandle>("GroupKvHandle");
register_group_get(engine);
register_group_set(engine);
register_group_has(engine);
register_group_delete(engine);
register_group_list(engine);
}
fn register_get(engine: &mut RhaiEngine) {
@@ -174,3 +214,98 @@ fn list_call(
);
Ok(m)
}
// --- GroupKvHandle methods (§11.6 shared collections) ----------------------
fn register_group_get(engine: &mut RhaiEngine) {
engine.register_fn(
"get",
|handle: &mut GroupKvHandle, key: &str| -> Result<Dynamic, Box<EvalAltResult>> {
let h = handle.clone();
block_on("kv", async move {
h.service.get(&h.cx, &h.collection, key).await
})
.map(|opt| opt.map_or(Dynamic::UNIT, json_to_dynamic))
},
);
}
fn register_group_set(engine: &mut RhaiEngine) {
engine.register_fn(
"set",
|handle: &mut GroupKvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
let h = handle.clone();
let json = dynamic_to_json(&value);
block_on("kv", async move {
h.service.set(&h.cx, &h.collection, key, json).await
})
},
);
}
fn register_group_has(engine: &mut RhaiEngine) {
engine.register_fn(
"has",
|handle: &mut GroupKvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
let h = handle.clone();
block_on("kv", async move {
h.service.has(&h.cx, &h.collection, key).await
})
},
);
}
fn register_group_delete(engine: &mut RhaiEngine) {
engine.register_fn(
"delete",
|handle: &mut GroupKvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
let h = handle.clone();
block_on("kv", async move {
h.service.delete(&h.cx, &h.collection, key).await
})
},
);
}
fn register_group_list(engine: &mut RhaiEngine) {
engine.register_fn(
"list",
|handle: &mut GroupKvHandle| -> Result<Map, Box<EvalAltResult>> {
group_list_call(handle, None, 0)
},
);
engine.register_fn(
"list",
|handle: &mut GroupKvHandle, cursor: &str| -> Result<Map, Box<EvalAltResult>> {
group_list_call(handle, Some(cursor.to_string()), 0)
},
);
engine.register_fn(
"list",
|handle: &mut GroupKvHandle, cursor: &str, limit: i64| -> Result<Map, Box<EvalAltResult>> {
let limit = u32::try_from(limit.max(0)).unwrap_or(0);
group_list_call(handle, Some(cursor.to_string()), limit)
},
);
}
fn group_list_call(
handle: &GroupKvHandle,
cursor: Option<String>,
limit: u32,
) -> Result<Map, Box<EvalAltResult>> {
let h = handle.clone();
let page = block_on("kv", async move {
h.service
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
.await
})?;
let mut m = Map::new();
let keys: Array = page.keys.into_iter().map(Dynamic::from).collect();
m.insert("keys".into(), keys.into());
m.insert(
"next_cursor".into(),
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
);
Ok(m)
}

View File

@@ -26,6 +26,7 @@ pub mod retry;
pub mod secrets;
pub mod stdlib;
pub mod users;
pub mod vars;
pub use bridge::{dynamic_to_json, json_to_dynamic};
pub use cx::SdkCallCx;
@@ -62,6 +63,7 @@ pub fn register_all(
queue::register(engine, services, cx.clone());
retry::register(engine, services, cx.clone());
secrets::register(engine, services, cx.clone());
vars::register(engine, services, cx.clone());
email::register(engine, services, cx.clone());
users::register(engine, services, cx.clone());
invoke::register(engine, services, cx, limits, self_engine);

View File

@@ -69,7 +69,71 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
},
);
}
// §11.6 D2: `pubsub::shared_topic("name")` → a handle whose `.publish(...)`
// publishes to the group's shared topic namespace (fans out to `shared`
// group pubsub triggers on the owning group). The owning group resolves from
// `cx.app_id` inside the service — never a script arg.
{
let group_svc = services.group_pubsub.clone();
let cx = cx.clone();
module.set_native_fn(
"shared_topic",
move |name: &str| -> Result<GroupTopicHandle, Box<EvalAltResult>> {
if name.is_empty() {
return Err("pubsub::shared_topic name must not be empty".into());
}
Ok(GroupTopicHandle {
namespace: name.to_string(),
service: group_svc.clone(),
cx: cx.clone(),
})
},
);
}
engine.register_static_module("pubsub", module.into());
engine.register_type_with_name::<GroupTopicHandle>("GroupTopicHandle");
register_shared_publish(engine);
}
/// §11.6 D2 handle returned by `pubsub::shared_topic("name")`. `.publish(sub,
/// msg)` publishes `name.sub` to the group shared topic; `.publish(msg)`
/// publishes to `name` directly.
#[derive(Clone)]
pub struct GroupTopicHandle {
namespace: String,
service: Arc<dyn picloud_shared::GroupPubsubService>,
cx: Arc<SdkCallCx>,
}
fn shared_publish(
h: &mut GroupTopicHandle,
subtopic: &str,
message: Dynamic,
) -> Result<(), Box<EvalAltResult>> {
let json = message_to_json(&message);
let service = h.service.clone();
let cx = h.cx.clone();
let namespace = h.namespace.clone();
let subtopic = subtopic.to_string();
block_on("pubsub", async move {
service.publish(&cx, &namespace, &subtopic, json).await
})
// The fan-out count isn't surfaced to scripts (fire-and-forget, like the
// per-app publish).
.map(|_n: u32| ())
}
fn register_shared_publish(engine: &mut RhaiEngine) {
engine.register_fn(
"publish",
|h: &mut GroupTopicHandle, subtopic: &str, message: Dynamic| {
shared_publish(h, subtopic, message)
},
);
// `.publish(msg)` with no subtopic → publish to the namespace directly.
engine.register_fn("publish", |h: &mut GroupTopicHandle, message: Dynamic| {
shared_publish(h, "", message)
});
}
/// Interpret the optional `ttl` argument: `()` → use the default,

View File

@@ -20,7 +20,9 @@ use std::sync::Arc;
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;
use picloud_shared::{EnqueueOpts, QueueError, QueueService, SdkCallCx, Services};
use picloud_shared::{
EnqueueOpts, GroupQueueService, QueueError, QueueService, SdkCallCx, Services,
};
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
use serde_json::Value as Json;
use tokio::runtime::Handle as TokioHandle;
@@ -87,7 +89,106 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
);
}
// §11.6 D3: `queue::shared_collection("name")` → a handle over the group
// shared queue (any subtree app enqueues into one group-owned store;
// competing per-descendant consumers drain it). The owning group resolves
// from `cx.app_id` inside the service — never a script arg.
{
let group_svc = services.group_queue.clone();
let cx = cx.clone();
module.set_native_fn(
"shared_collection",
move |name: &str| -> Result<GroupQueueHandle, Box<EvalAltResult>> {
if name.is_empty() {
return Err("queue::shared_collection name must not be empty".into());
}
Ok(GroupQueueHandle {
collection: name.to_string(),
service: group_svc.clone(),
cx: cx.clone(),
})
},
);
}
engine.register_static_module("queue", module.into());
engine.register_type_with_name::<GroupQueueHandle>("GroupQueueHandle");
register_shared_queue_methods(engine);
}
/// §11.6 D3 handle returned by `queue::shared_collection("name")`.
#[derive(Clone)]
pub struct GroupQueueHandle {
collection: String,
service: Arc<dyn GroupQueueService>,
cx: Arc<SdkCallCx>,
}
fn group_enqueue(
h: &mut GroupQueueHandle,
message: Dynamic,
opts: EnqueueOpts,
) -> Result<(), Box<EvalAltResult>> {
let json = message_to_json(&message)?;
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
format!("queue: no tokio runtime available: {e}").into(),
rhai::Position::NONE,
)
.into()
})?;
let service = h.service.clone();
let cx = h.cx.clone();
let collection = h.collection.clone();
handle
.block_on(async move { service.enqueue(&cx, &collection, json, opts).await })
.map(|_id| ())
.map_err(|err| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(format!("queue: {err}").into(), rhai::Position::NONE).into()
})
}
fn group_depth<F, Fut>(h: &mut GroupQueueHandle, f: F) -> Result<i64, Box<EvalAltResult>>
where
F: FnOnce(Arc<dyn GroupQueueService>, Arc<SdkCallCx>, String) -> Fut,
Fut: std::future::Future<Output = Result<u64, picloud_shared::GroupQueueError>>,
{
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
format!("queue: no tokio runtime available: {e}").into(),
rhai::Position::NONE,
)
.into()
})?;
let fut = f(h.service.clone(), h.cx.clone(), h.collection.clone());
let n = handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(format!("queue: {err}").into(), rhai::Position::NONE).into()
})?;
Ok(i64::try_from(n).unwrap_or(i64::MAX))
}
fn register_shared_queue_methods(engine: &mut RhaiEngine) {
engine.register_fn("enqueue", |h: &mut GroupQueueHandle, message: Dynamic| {
group_enqueue(h, message, EnqueueOpts::default())
});
engine.register_fn(
"enqueue",
|h: &mut GroupQueueHandle, message: Dynamic, opts: Map| {
let opts = parse_opts(&opts)?;
group_enqueue(h, message, opts)
},
);
engine.register_fn("depth", |h: &mut GroupQueueHandle| {
group_depth(
h,
|svc, cx, coll| async move { svc.depth(&cx, &coll).await },
)
});
engine.register_fn("depth_pending", |h: &mut GroupQueueHandle| {
group_depth(h, |svc, cx, coll| async move {
svc.depth_pending(&cx, &coll).await
})
});
}
/// Run the async enqueue inside the synchronous Rhai context. Mirrors

View File

@@ -0,0 +1,57 @@
//! `vars::` Rhai bridge — read-only access to the app's resolved,
//! group-inherited config (Phase 3).
//!
//! ```rhai
//! let region = vars::get("region"); // value or ()
//! let all = vars::all(); // #{ key: value, ... }
//! ```
//!
//! Values are inherited down the group tree and env-filtered (§3); the
//! resolution happens server-side in manager-core. Writes go through the
//! admin API, not the SDK. `app_id` is derived from `cx.app_id` in the
//! service — never a script argument — preserving cross-app isolation.
use std::sync::Arc;
use picloud_shared::{SdkCallCx, Services};
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
use super::bridge::{block_on, json_to_dynamic};
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
let svc = services.vars.clone();
let mut module = Module::new();
// vars::get(key) — resolved value, or () if no level defines it.
{
let svc = svc.clone();
let cx = cx.clone();
module.set_native_fn(
"get",
move |key: &str| -> Result<Dynamic, Box<EvalAltResult>> {
let svc = svc.clone();
let cx = cx.clone();
let opt = block_on("vars", async move { svc.get(&cx, key).await })?;
Ok(opt.map_or(Dynamic::UNIT, json_to_dynamic))
},
);
}
// vars::all() — the fully-resolved config map.
{
let svc = svc.clone();
let cx = cx.clone();
module.set_native_fn("all", move || -> Result<Map, Box<EvalAltResult>> {
let svc = svc.clone();
let cx = cx.clone();
let resolved = block_on("vars", async move { svc.all(&cx).await })?;
let mut m = Map::new();
for (k, v) in resolved {
m.insert(k.into(), json_to_dynamic(v));
}
Ok(m)
});
}
engine.register_static_module("vars", module.into());
}

View File

@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use picloud_shared::{
AppId, ExecutionId, ExecutionLog, ExecutionSource, ExecutionStatus, Principal, RequestId,
ScriptId, ScriptSandbox, TriggerEvent,
ScriptId, ScriptOwner, ScriptSandbox, TriggerEvent,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -69,6 +69,16 @@ pub struct ExecRequest {
/// Internal-only; not surfaced via `ctx` (which the script sees).
pub app_id: AppId,
/// The **defining node** of the top-level script being executed —
/// the resolved `Script`'s owner (Phase 4b). This is the lexical
/// origin its `import` statements resolve against (§5.5): an inherited
/// group endpoint's imports seal to the **group**, not the inheriting
/// app, so a leaf can't shadow them. Distinct from `app_id`, which is
/// always the execution boundary (where SDK calls scope). `None` (old
/// payloads / cluster wire) falls back to `App(app_id)` in the engine.
#[serde(default)]
pub script_owner: Option<ScriptOwner>,
/// Caller identity, when authenticated. `None` for unauthenticated
/// data-plane HTTP requests (the common case for public scripts);
/// `Some` when a bearer token or session cookie was resolved.

View File

@@ -23,6 +23,7 @@ fn req(body: serde_json::Value) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id: AppId::new(),
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -16,7 +16,7 @@ use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
use picloud_shared::{
AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService,
NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId,
ScriptSandbox, SdkCallCx, Services,
ScriptOwner, ScriptSandbox, Services,
};
use serde_json::Value;
use tracing_subscriber::fmt::MakeWriter;
@@ -27,9 +27,9 @@ struct FailingSource;
#[async_trait]
impl ModuleSource for FailingSource {
async fn lookup(
async fn resolve(
&self,
_cx: &SdkCallCx,
_origin: ScriptOwner,
_name: &str,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
@@ -74,6 +74,7 @@ fn req(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,
@@ -107,6 +108,7 @@ async fn original_backend_error_is_logged_at_error_level() {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
let engine = Engine::new(Limits::default(), services);

View File

@@ -8,7 +8,7 @@
//! verify the same code path the `picloud` binary runs at request
//! time.
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
@@ -16,9 +16,9 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
use picloud_shared::{
AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService,
NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId,
ScriptSandbox, SdkCallCx, Services,
AppId, ExecutionId, GroupId, ModuleScript, ModuleSource, ModuleSourceError,
NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService,
RequestId, ScriptId, ScriptOwner, ScriptSandbox, Services,
};
use tokio::sync::Mutex;
@@ -55,7 +55,8 @@ impl CountingModuleSource {
(app_id, name.to_string()),
ModuleScript {
script_id,
app_id,
app_id: Some(app_id),
group_id: None,
name: name.to_string(),
source: source.to_string(),
updated_at,
@@ -71,20 +72,27 @@ impl CountingModuleSource {
#[async_trait]
impl ModuleSource for CountingModuleSource {
async fn lookup(
async fn resolve(
&self,
cx: &SdkCallCx,
origin: ScriptOwner,
name: &str,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
self.lookups.fetch_add(1, Ordering::SeqCst);
if let Some(err) = self.fail_with.lock().await.as_ref() {
return Err(ModuleSourceError::Backend(err.clone()));
}
// This fake is flat/app-scoped — the inheritance + lexical
// resolution semantics are covered by the CLI journey tests
// against real Postgres. A group origin has no entries here.
let app_id = match origin {
ScriptOwner::App(a) => a,
ScriptOwner::Group(_) => return Ok(None),
};
Ok(self
.table
.lock()
.await
.get(&(cx.app_id, name.to_string()))
.get(&(app_id, name.to_string()))
.cloned())
}
}
@@ -104,6 +112,7 @@ fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
)
}
@@ -128,6 +137,7 @@ fn req(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,
@@ -599,3 +609,277 @@ fn validate_endpoint_skips_dynamic_imports_in_imports_list() {
v.imports
);
}
// ---------------------------------------------------------------------------
// Phase 4b — lexical (origin-aware) resolution.
//
// `LexicalModuleSource` keys modules by their *exact* defining node, with no
// chain walk. That's enough to prove the resolver passes the RIGHT origin:
// `default_origin` for the entry AST, and each module's own owner (decoded
// from Rhai's `_source`) for its nested imports. The chain-walk + group-tree
// semantics are covered end-to-end by the CLI journey tests against Postgres.
// ---------------------------------------------------------------------------
#[derive(Default)]
struct LexicalModuleSource {
table: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
}
impl LexicalModuleSource {
fn new() -> Arc<Self> {
Arc::new(Self::default())
}
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) {
let (app_id, group_id) = match owner {
ScriptOwner::App(a) => (Some(a), None),
ScriptOwner::Group(g) => (None, Some(g)),
};
self.table.lock().await.insert(
(owner, name.to_string()),
ModuleScript {
script_id: ScriptId::new(),
app_id,
group_id,
name: name.to_string(),
source: source.to_string(),
updated_at: Utc::now(),
},
);
}
}
#[async_trait]
impl ModuleSource for LexicalModuleSource {
async fn resolve(
&self,
origin: ScriptOwner,
name: &str,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
Ok(self
.table
.lock()
.await
.get(&(origin, name.to_string()))
.cloned())
}
}
fn req_with_owner(app_id: AppId, owner: ScriptOwner) -> ExecRequest {
let mut r = req(app_id);
r.script_owner = Some(owner);
r
}
/// A group module's `import` resolves from the GROUP (its own defining
/// node), not the inheriting app — even when an app-owned module of the
/// same name exists. This is the §5.5 trust boundary: a leaf can't shadow
/// a module an inherited group script depends on.
#[tokio::test(flavor = "multi_thread")]
async fn lexical_nested_import_seals_to_module_owner() {
let source = LexicalModuleSource::new();
let app = AppId::new();
let group = GroupId::new();
// Two "inner" modules: the group's (real) and the app's (a trap).
source
.put(ScriptOwner::Group(group), "inner", "fn v() { 42 }")
.await;
source
.put(ScriptOwner::App(app), "inner", "fn v() { 999 }")
.await;
// The group's "outer" imports "inner" — must bind the group's inner.
source
.put(
ScriptOwner::Group(group),
"outer",
r#"import "inner" as i; fn val() { i::v() }"#,
)
.await;
let engine = engine_with(source.clone());
// Entry script runs as the inherited GROUP script (default_origin = group).
let resp = engine
.execute(
r#"import "outer" as o; o::val()"#,
req_with_owner(app, ScriptOwner::Group(group)),
)
.expect("should execute");
assert_eq!(
resp.body,
serde_json::json!(42),
"group module's import must seal to the group's `inner`, not the app's trap"
);
}
/// The same registry, entered as an APP-owned script, reaches the app's
/// module — proving the fake distinguishes origins and the entry uses
/// `default_origin`.
#[tokio::test(flavor = "multi_thread")]
async fn lexical_entry_origin_selects_app_module() {
let source = LexicalModuleSource::new();
let app = AppId::new();
let group = GroupId::new();
source
.put(ScriptOwner::Group(group), "inner", "fn v() { 42 }")
.await;
source
.put(ScriptOwner::App(app), "inner", "fn v() { 999 }")
.await;
let engine = engine_with(source.clone());
let resp = engine
.execute(
r#"import "inner" as i; i::v()"#,
req_with_owner(app, ScriptOwner::App(app)),
)
.expect("should execute");
assert_eq!(resp.body, serde_json::json!(999));
}
// ---------------------------------------------------------------------------
// §5.5 extension points — resolver handling of the policy outcomes.
//
// `PolicyModuleSource` is a flat fake that overrides `resolve_policy`: an EP
// name resolves dynamically against the inheriting app; a non-EP name resolves
// lexically from the importing origin. This verifies the resolver maps
// Module/NoProvider/NotFound correctly. The full chain semantics (default body
// up-chain, nearest-declaration tie) are covered by the CLI journey tests.
// ---------------------------------------------------------------------------
#[derive(Default)]
struct PolicyModuleSource {
modules: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
eps: Mutex<HashSet<String>>,
}
impl PolicyModuleSource {
fn new() -> Arc<Self> {
Arc::new(Self::default())
}
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) {
let (app_id, group_id) = match owner {
ScriptOwner::App(a) => (Some(a), None),
ScriptOwner::Group(g) => (None, Some(g)),
};
self.modules.lock().await.insert(
(owner, name.to_string()),
ModuleScript {
script_id: ScriptId::new(),
app_id,
group_id,
name: name.to_string(),
source: source.to_string(),
updated_at: Utc::now(),
},
);
}
async fn mark_ep(self: &Arc<Self>, name: &str) {
self.eps.lock().await.insert(name.to_string());
}
}
#[async_trait]
impl ModuleSource for PolicyModuleSource {
async fn resolve(
&self,
origin: ScriptOwner,
name: &str,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
Ok(self
.modules
.lock()
.await
.get(&(origin, name.to_string()))
.cloned())
}
async fn resolve_policy(
&self,
origin: ScriptOwner,
inheriting_app: AppId,
name: &str,
) -> Result<picloud_shared::ModuleResolution, ModuleSourceError> {
use picloud_shared::ModuleResolution;
if self.eps.lock().await.contains(name) {
// Extension point → dynamic, resolved against the inheriting app.
return Ok(
match self.resolve(ScriptOwner::App(inheriting_app), name).await? {
Some(m) => ModuleResolution::Module(m),
None => ModuleResolution::NoProvider,
},
);
}
Ok(match self.resolve(origin, name).await? {
Some(m) => ModuleResolution::Module(m),
None => ModuleResolution::NotFound,
})
}
}
/// An extension-point import binds the inheriting APP's module even though the
/// importing script's defining node is the group (dynamic override — the
/// inverse of the Phase 4b sealed/lexical import).
#[tokio::test(flavor = "multi_thread")]
async fn extension_point_resolves_app_override() {
let source = PolicyModuleSource::new();
let app = AppId::new();
let group = GroupId::new();
source.mark_ep("theme").await;
// App provides its own `theme`; group has none.
source
.put(ScriptOwner::App(app), "theme", r#"fn color() { "red" }"#)
.await;
let engine = engine_with(source.clone());
// Entry runs as the inherited GROUP endpoint importing the EP `theme`.
let resp = engine
.execute(
r#"import "theme" as t; t::color()"#,
req_with_owner(app, ScriptOwner::Group(group)),
)
.expect("should execute");
assert_eq!(resp.body, serde_json::json!("red"));
}
/// An extension point with no provider for the app is a hard error.
#[tokio::test(flavor = "multi_thread")]
async fn extension_point_without_provider_errors() {
let source = PolicyModuleSource::new();
let app = AppId::new();
let group = GroupId::new();
source.mark_ep("theme").await; // declared, but no module anywhere.
let engine = engine_with(source.clone());
let err = engine
.execute(
r#"import "theme" as t; t::color()"#,
req_with_owner(app, ScriptOwner::Group(group)),
)
.expect_err("missing provider must error");
let msg = format!("{err:?}").to_lowercase();
assert!(
msg.contains("no provider") || msg.contains("extension point"),
"expected a no-provider error, got {msg}"
);
}
/// A non-extension-point name still resolves lexically from the origin.
#[tokio::test(flavor = "multi_thread")]
async fn non_extension_point_stays_lexical() {
let source = PolicyModuleSource::new();
let app = AppId::new();
let group = GroupId::new();
source
.put(ScriptOwner::Group(group), "util", r#"fn v() { 7 }"#)
.await;
let engine = engine_with(source.clone());
let resp = engine
.execute(
r#"import "util" as u; u::v()"#,
req_with_owner(app, ScriptOwner::Group(group)),
)
.expect("should execute");
assert_eq!(resp.body, serde_json::json!(7));
}

View File

@@ -51,6 +51,7 @@ fn baseline_request() -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id: AppId::new(),
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -235,6 +235,7 @@ fn make_engine() -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -256,6 +257,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -44,6 +44,7 @@ fn engine_with(rec: Arc<RecordingEmail>) -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -65,6 +66,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -172,6 +172,7 @@ fn make_engine() -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -193,6 +194,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -95,6 +95,7 @@ fn engine_with(http: Arc<dyn HttpService>) -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -116,6 +117,7 @@ fn baseline_request(app_id: AppId, script_id: ScriptId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -58,6 +58,7 @@ impl InvokeService for FakeInvokeService {
Ok(ResolvedScript {
script_id: id,
app_id: app,
owner: None,
source,
updated_at: Utc::now(),
name,
@@ -90,6 +91,7 @@ fn build_engine(svc: Arc<FakeInvokeService>) -> Arc<Engine> {
Arc::new(NoopUsersService),
Arc::new(NoopQueueService),
svc,
Arc::new(picloud_shared::NoopVarsService),
);
let engine = Arc::new(Engine::new(Limits::default(), services));
engine.set_self_weak(Arc::downgrade(&engine));
@@ -113,6 +115,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -114,6 +114,7 @@ fn make_engine() -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -135,6 +136,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -52,6 +52,7 @@ fn make_engine(svc: Arc<RecordingPubsub>) -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -73,6 +74,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -66,6 +66,7 @@ fn make_engine(svc: Arc<RecordingQueue>) -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
svc,
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -87,6 +88,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -30,6 +30,7 @@ fn build_engine() -> Arc<Engine> {
Arc::new(NoopUsersService),
Arc::new(NoopQueueService),
Arc::new(NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -51,6 +52,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -105,6 +105,7 @@ fn make_engine() -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -126,6 +127,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -99,6 +99,7 @@ fn make_engine() -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -120,6 +121,7 @@ fn request(app_id: AppId, with_principal: bool) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: with_principal.then(|| Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,

View File

@@ -37,6 +37,7 @@ fn baseline_request() -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id: AppId::new(),
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -0,0 +1,47 @@
-- Phase 3: group-inherited config — the `vars` table + an app environment
-- marker. See docs/design/groups-and-project-tool.md §3, §5.1.
--
-- `vars` is the net-new, env-scoped configuration layer (greenfield — there
-- is no env-agnostic config today, only `secrets`). A var is owned by
-- exactly one group OR one app, optionally scoped to an environment, and
-- resolved down the tree (§3): env-filter first, then nearest level wins.
--
-- "An environment is an app": each env is already a distinct app row. To
-- env-filter group-level `@E` values, the resolver must know which env an
-- app represents — recorded here on `apps.environment` (NULL = env-agnostic,
-- set at app-create from the CLI's known env).
ALTER TABLE apps ADD COLUMN environment TEXT;
CREATE TABLE vars (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Polymorphic owner: exactly one of the two FKs is set. Real FKs (not a
-- bare owner_id) so ON DELETE CASCADE works per owner kind and a dangling
-- owner is impossible.
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
CONSTRAINT vars_owner_exactly_one
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
-- '*' = env-agnostic; otherwise an env name matched against
-- apps.environment. NOT NULL with a '*' sentinel so the uniqueness
-- indexes are total (a NULL scope would break UNIQUE).
environment_scope TEXT NOT NULL DEFAULT '*',
key TEXT NOT NULL,
-- JSONB per the v1.1 data-plane convention. A tombstone (deletion of an
-- inherited key, §3) is the explicit boolean below, NOT value = 'null'
-- — JSON null is a legitimate value and must stay distinguishable.
value JSONB NOT NULL,
is_tombstone BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- One row per (owner, scope, key). Partial unique indexes because the owner
-- is split across two nullable columns.
CREATE UNIQUE INDEX vars_group_uidx
ON vars (group_id, environment_scope, key) WHERE group_id IS NOT NULL;
CREATE UNIQUE INDEX vars_app_uidx
ON vars (app_id, environment_scope, key) WHERE app_id IS NOT NULL;
CREATE INDEX vars_group_id_idx ON vars (group_id) WHERE group_id IS NOT NULL;
CREATE INDEX vars_app_id_idx ON vars (app_id) WHERE app_id IS NOT NULL;

View File

@@ -0,0 +1,51 @@
-- Phase 3 (v1.1.9): group-owned, environment-scoped secrets.
--
-- Until now `secrets` was strictly per-app: PK `(app_id, name)`, one
-- envelope per app. Phase 3 makes secrets inheritable down the group tree
-- (blueprint §11 bullet 3 / docs/design §3), exactly like `vars` (0048):
-- a secret may be owned by an app OR an ancestor group, and a descendant
-- app resolves the nearest one, environment-filtered.
--
-- Reshape (mirrors `vars`):
-- * `group_id` — nullable FK→groups, CASCADE (a deleted group drops its
-- secrets, same as its vars).
-- * `app_id` — made nullable (was NOT NULL); the exactly-one CHECK now
-- enforces "owned by an app XOR a group".
-- * `environment_scope` — `'*'` (env-agnostic) or a concrete env name;
-- the resolver filters on it. Existing rows backfill to `'*'`, so every
-- current app secret stays env-agnostic and resolves unchanged.
-- * PK `(app_id, name)` → two PARTIAL unique indexes, one per owner, both
-- keyed `(owner, environment_scope, name)`.
--
-- CRYPTO INVARIANT (audit 2026-06-11 H-D1): the v1 AAD is
-- `secret:{app_id}:{name}` for app secrets and `secret:group:{group_id}:{name}`
-- for group secrets — it does NOT include `environment_scope`. Adding the
-- column therefore leaves every existing ciphertext decryptable byte-for-byte;
-- the app-owner AAD is unchanged and the group namespace is disjoint.
ALTER TABLE secrets
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
ADD COLUMN environment_scope TEXT NOT NULL DEFAULT '*';
-- Drop the old composite PK first: `app_id` cannot lose NOT NULL while it
-- is a primary-key column.
ALTER TABLE secrets
DROP CONSTRAINT secrets_pkey;
ALTER TABLE secrets
ALTER COLUMN app_id DROP NOT NULL;
ALTER TABLE secrets
ADD CONSTRAINT secrets_owner_exactly_one
CHECK ((group_id IS NULL) <> (app_id IS NULL));
-- One secret per (owner, env, name). Partial so each owner column only
-- constrains its own rows; the resolver and every upsert restate the
-- predicate as the ON CONFLICT arbiter.
CREATE UNIQUE INDEX secrets_app_uidx
ON secrets (app_id, environment_scope, name) WHERE app_id IS NOT NULL;
CREATE UNIQUE INDEX secrets_group_uidx
ON secrets (group_id, environment_scope, name) WHERE group_id IS NOT NULL;
-- Owner lookup index for the group side (the app side keeps idx_secrets_app).
CREATE INDEX idx_secrets_group ON secrets (group_id) WHERE group_id IS NOT NULL;

View File

@@ -0,0 +1,40 @@
-- Phase 4 (v1.2 Hierarchies): group-owned scripts.
--
-- Until now every script was owned by exactly one app (`app_id NOT NULL`,
-- ON DELETE RESTRICT). Phase 4 lets a script be owned by a GROUP instead, so
-- it is shared by every descendant app — resolved by name, nearest-owner
-- wins (CoW: an app's own script of the same name shadows the inherited one),
-- exactly like `vars`/`secrets` (0048/0049).
--
-- Phase 4-LITE scope: group-owned ENDPOINT scripts only. Group modules and
-- the origin-aware (lexical) import resolver are deferred to Phase 4b, so a
-- group-owned script must be self-contained (enforced in the service layer).
--
-- Reshape (mirrors vars/secrets, but RESTRICT not CASCADE — code is not data,
-- a group can't be deleted out from under the scripts it owns, matching the
-- existing app_id RESTRICT and the §5.6 delete=RESTRICT rule):
-- * `group_id` — nullable FK→groups, ON DELETE RESTRICT.
-- * `app_id` — made nullable (was NOT NULL); the exactly-one CHECK now
-- enforces "owned by an app XOR a group".
-- * the per-app unique name index becomes two partial indexes, one per owner.
ALTER TABLE scripts
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE RESTRICT;
ALTER TABLE scripts
ALTER COLUMN app_id DROP NOT NULL;
ALTER TABLE scripts
ADD CONSTRAINT scripts_owner_exactly_one
CHECK ((group_id IS NULL) <> (app_id IS NULL));
-- Per-owner, case-insensitive name uniqueness. Partial so each owner column
-- only constrains its own rows; existing app rows (group_id NULL) keep the
-- exact same (app_id, LOWER(name)) uniqueness they had under the old index.
DROP INDEX scripts_name_uidx;
CREATE UNIQUE INDEX scripts_app_name_uidx
ON scripts (app_id, LOWER(name)) WHERE app_id IS NOT NULL;
CREATE UNIQUE INDEX scripts_group_name_uidx
ON scripts (group_id, LOWER(name)) WHERE group_id IS NOT NULL;
CREATE INDEX scripts_group_id_idx ON scripts (group_id) WHERE group_id IS NOT NULL;

View File

@@ -0,0 +1,40 @@
-- §5.5 (v1.2 Hierarchies): extension-point markers — opt-in module polymorphism.
--
-- An extension point marks a module NAME at a node (app or group) as one
-- descendants are *expected* to provide or override. Imports of an EP name
-- resolve DYNAMICALLY against the inheriting app's view (the app can supply
-- its own module), instead of the Phase 4b lexical default (sealed to the
-- importing script's defining node). See docs §5.5.
--
-- This table holds only the MARKER (owner, name) — it is pure declaration,
-- structurally identical to a `secrets` name. The optional DEFAULT BODY is
-- just a co-located `kind = 'module'` script of the same name at this node
-- (Phase 4b already stores/resolves/caches those); there is no body column
-- here. So a marker is config, not code → ON DELETE CASCADE (unlike the
-- module body's RESTRICT in 0050).
--
-- Ownership is polymorphic (mirrors vars/secrets/scripts): exactly one of
-- (app_id, group_id) is set, with per-owner partial-unique LOWER(name)
-- indexes for case-insensitive uniqueness.
CREATE TABLE extension_points (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
CONSTRAINT extension_points_owner_exactly_one
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- One marker per (owner, name), case-insensitive. Partial because the owner
-- is split across two nullable columns.
CREATE UNIQUE INDEX extension_points_group_uidx
ON extension_points (group_id, LOWER(name)) WHERE group_id IS NOT NULL;
CREATE UNIQUE INDEX extension_points_app_uidx
ON extension_points (app_id, LOWER(name)) WHERE app_id IS NOT NULL;
-- Lookup indexes for the resolver's chain join + list-by-owner.
CREATE INDEX extension_points_group_id_idx ON extension_points (group_id) WHERE group_id IS NOT NULL;
CREATE INDEX extension_points_app_id_idx ON extension_points (app_id) WHERE app_id IS NOT NULL;

View File

@@ -0,0 +1,47 @@
-- §11.6 (v1.2 Hierarchies): group-collection registry — shared cross-app data.
--
-- A row marks a collection NAME at a node as group-SHARED: every app in the
-- owning group's subtree may read it (and, with an authenticated editor+,
-- write it) via the explicit `kv::shared_collection("name")` SDK handle. Resolution is
-- nearest-ancestor-group-wins, walking the reading app's chain — that ancestry
-- walk is the isolation boundary (a foreign app's chain never contains the
-- owning group, so the name simply does not resolve). See docs §11.6.
--
-- This table holds only the MARKER (owner, name, kind) — the data itself lives
-- in a per-kind storage table (`group_kv_entries`, 0053, for kind='kv'). It is
-- pure declaration, structurally identical to an `extension_points` marker
-- (0051). A marker is config, not code → ON DELETE CASCADE.
--
-- Ownership is polymorphic (mirrors vars/secrets/scripts/extension_points):
-- exactly one of (app_id, group_id) is set. MVP authoring is restricted to
-- GROUP owners at the manifest layer (an app-declared shared collection is
-- degenerate — only that app would read it); the polymorphic shape keeps the
-- owner-generic reconcile path uniform with the other inheritable kinds.
--
-- `kind` is the storage discriminator. Only 'kv' ships in the MVP; the column
-- + CHECK exist now so docs/files/topics/queue slot in later without a
-- migration, and so a future `docs` collection of the same name is a distinct
-- row (the unique index covers `kind`).
CREATE TABLE group_collections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
CONSTRAINT group_collections_owner_exactly_one
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
name TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'kv' CHECK (kind IN ('kv')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- One marker per (owner, name, kind), case-insensitive. Partial because the
-- owner is split across two nullable columns.
CREATE UNIQUE INDEX group_collections_group_uidx
ON group_collections (group_id, LOWER(name), kind) WHERE group_id IS NOT NULL;
CREATE UNIQUE INDEX group_collections_app_uidx
ON group_collections (app_id, LOWER(name), kind) WHERE app_id IS NOT NULL;
-- Lookup indexes for the resolver's chain join + list-by-owner.
CREATE INDEX group_collections_group_id_idx ON group_collections (group_id) WHERE group_id IS NOT NULL;
CREATE INDEX group_collections_app_id_idx ON group_collections (app_id) WHERE app_id IS NOT NULL;

View File

@@ -0,0 +1,30 @@
-- §11.6 (v1.2 Hierarchies): group-KV storage — the shared read/write data for
-- a collection declared group-shared in `group_collections` (0052, kind='kv').
--
-- Identity tuple `(group_id, collection, key)`. Unlike per-app `kv_entries`
-- (0007) this is keyed by the OWNING GROUP, not by app — the whole point is
-- that many apps in the group's subtree share one store. There is no `app_id`
-- column: a shared row belongs to the group, not to whichever app wrote it.
--
-- `value` is JSONB, mirroring `kv_entries`. The reading/writing app is resolved
-- to its owning group at the service layer (walking the app's ancestor chain);
-- this table only ever sees a concrete `group_id`.
--
-- ON DELETE CASCADE on group_id: the shared store is DATA, so it dies with its
-- owning group — like `vars`/`secrets` config CASCADE, deliberately UNLIKE the
-- `scripts` (0050) RESTRICT (code is not data). Deleting an app does NOT touch
-- this table (the data is the group's, and survives the app's departure).
CREATE TABLE group_kv_entries (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
collection TEXT NOT NULL,
key TEXT NOT NULL,
value JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (group_id, collection, key)
);
-- Supports list-by-collection (keyset pagination); the PK already covers
-- (group_id, collection) as a prefix but the explicit index makes intent clear.
CREATE INDEX idx_group_kv_entries_group_collection ON group_kv_entries (group_id, collection);

View File

@@ -0,0 +1,33 @@
-- §11.6 (cont., v1.2 Hierarchies): group-shared DOCS collections.
--
-- Extends the shared-collection machinery (0052 registry + 0053 group_kv_entries)
-- from KV to the queryable-JSON `docs` store. The registry's `kind`
-- discriminator was built for exactly this — widen its CHECK to admit 'docs',
-- and add a group-keyed storage table mirroring `docs` (0013).
-- Widen the marker kind allow-list. The constraint was an inline column CHECK,
-- so Postgres auto-named it `group_collections_kind_check`.
ALTER TABLE group_collections
DROP CONSTRAINT group_collections_kind_check,
ADD CONSTRAINT group_collections_kind_check CHECK (kind IN ('kv', 'docs'));
-- Group-keyed docs store. Identity tuple `(group_id, collection, id)` — keyed
-- by the OWNING GROUP, not an app (the shared rows belong to the group). `id`
-- is a server-generated UUID, as in `docs`. CASCADE on group delete (data dies
-- with its group; an app delete leaves it), matching group_kv_entries (0053).
CREATE TABLE group_docs (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
collection TEXT NOT NULL,
id UUID NOT NULL,
data JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (group_id, collection, id)
);
-- "all docs in group X / collection Y" — mirrors idx_docs_app_collection (0013).
CREATE INDEX idx_group_docs_group_collection ON group_docs (group_id, collection);
-- GIN on JSONB (jsonb_path_ops) for the find DSL's equality/containment, same
-- as idx_docs_data_gin (0013).
CREATE INDEX idx_group_docs_data_gin ON group_docs USING GIN (data jsonb_path_ops);

View File

@@ -0,0 +1,37 @@
-- §11.6 (cont., v1.2 Hierarchies): group-shared FILES collections.
--
-- Extends the shared-collection machinery (0052 registry + the per-kind stores
-- 0053 group_kv_entries / 0054 group_docs) from KV/docs to the filesystem-backed
-- `files` blob store. The registry's `kind` discriminator was built for exactly
-- this — widen its CHECK to admit 'files', and add a group-keyed metadata table
-- mirroring `files` (0018).
-- Widen the marker kind allow-list (auto-named `group_collections_kind_check`,
-- per 0052's inline column CHECK; 0054 widened it to ('kv','docs')).
ALTER TABLE group_collections
DROP CONSTRAINT group_collections_kind_check,
ADD CONSTRAINT group_collections_kind_check CHECK (kind IN ('kv', 'docs', 'files'));
-- Group-keyed file metadata. Identity tuple `(group_id, collection, id)` — keyed
-- by the OWNING GROUP, not an app (the shared blobs belong to the group). The
-- bytes live on disk at
-- <PICLOUD_FILES_ROOT>/files/groups/<group_id>/<collection>/<id[0:2]>/<id>
-- (a `groups/` infix disjoint from the per-app `files/<app_id>/...` subtree, so
-- the orphan sweeper covers both with one walk). CASCADE on group delete (the
-- metadata dies with its group; an app delete leaves it), matching 0053/0054.
CREATE TABLE group_files (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
collection TEXT NOT NULL,
id UUID NOT NULL,
name TEXT NOT NULL,
content_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
checksum_sha256 TEXT NOT NULL, -- hex, 64 chars, lowercase
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (group_id, collection, id)
);
-- List + cursor pagination scans by (group_id, collection) — mirrors
-- idx_files_app_collection (0018).
CREATE INDEX idx_group_files_group_collection ON group_files (group_id, collection);

View File

@@ -0,0 +1,49 @@
-- §11 tail (v1.2 Hierarchies): group TRIGGER templates.
--
-- Until now every trigger was owned by exactly one app (`app_id NOT NULL`).
-- A group-owned trigger is a TEMPLATE: it is never dispatched directly, but
-- the dispatcher's hot-path match queries UNION it in for every descendant app
-- via the ancestor-chain CTE (live resolution, like vars/secrets/scripts and
-- §11.6 collections — no per-app materialized rows). It binds a group-owned
-- handler script and fires under each firing app's `app_id`.
--
-- Scope: EVENT kinds only (kv/docs/files/pubsub) — those are stateless at
-- dispatch. cron/queue/email carry per-instance state (last_fired_at, the
-- one-consumer advisory lock, a sealed inbound secret) and would need
-- materialization; they are rejected on a [group] at the manifest layer and
-- deferred. The CHECK here is deliberately NOT narrowed to event kinds: the
-- column allows any kind for forward-compat, the authoring gate is upstream.
--
-- Reshape mirrors 0050_group_scripts exactly (RESTRICT, not CASCADE — a
-- trigger template references a group script; a group can't be deleted out
-- from under it):
-- * `group_id` — nullable FK→groups, ON DELETE RESTRICT.
-- * `app_id` — made nullable; the exactly-one CHECK enforces app XOR group.
-- * per-app unique name index + dispatch index become per-owner partials.
ALTER TABLE triggers
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE RESTRICT;
ALTER TABLE triggers
ALTER COLUMN app_id DROP NOT NULL;
ALTER TABLE triggers
ADD CONSTRAINT triggers_owner_exactly_one
CHECK ((group_id IS NULL) <> (app_id IS NULL));
-- Per-owner name uniqueness (partial so each owner column only constrains its
-- own rows). Existing app rows keep the exact (app_id, name) uniqueness.
DROP INDEX triggers_app_name_uniq;
CREATE UNIQUE INDEX triggers_app_name_uniq
ON triggers (app_id, name) WHERE app_id IS NOT NULL;
CREATE UNIQUE INDEX triggers_group_name_uniq
ON triggers (group_id, name) WHERE group_id IS NOT NULL;
-- Per-owner dispatch hot-path index ("all enabled triggers of kind Y for
-- owner X"). The app index keeps its name + shape (now owner-partial); a
-- parallel group index serves the chain-union's group-template lookups.
DROP INDEX idx_triggers_app_kind_enabled;
CREATE INDEX idx_triggers_app_kind_enabled
ON triggers (app_id, kind) WHERE enabled = TRUE AND app_id IS NOT NULL;
CREATE INDEX idx_triggers_group_kind_enabled
ON triggers (group_id, kind) WHERE enabled = TRUE AND group_id IS NOT NULL;

View File

@@ -0,0 +1,48 @@
-- §11 tail (v1.2 Hierarchies): group ROUTE templates.
--
-- Until now every route was owned by exactly one app (`app_id NOT NULL`).
-- A group-owned route is a TEMPLATE: it is never served directly, but the
-- in-memory RouteTable rebuild EXPANDS it into every descendant app's slice
-- via the ancestor chain (live resolution, like vars/secrets/scripts, §11.6
-- collections, and §11 trigger templates — no per-app materialized rows). It
-- binds a group-owned handler script and runs under each firing app's
-- `app_id`.
--
-- Unlike triggers (a SQL-per-event dispatch), routes are served from an
-- in-memory cache, so the inheritance is resolved at table-rebuild time, not
-- per request. The reshape, however, is identical: a polymorphic owner column.
--
-- Reshape mirrors 0056_group_triggers exactly (RESTRICT, not CASCADE — a route
-- template references a group script; a group can't be deleted out from under
-- it):
-- * `group_id` — nullable FK→groups, ON DELETE RESTRICT.
-- * `app_id` — made nullable; the exactly-one CHECK enforces app XOR group.
-- * the per-app unique binding index becomes per-owner partials.
ALTER TABLE routes
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE RESTRICT;
ALTER TABLE routes
ALTER COLUMN app_id DROP NOT NULL;
ALTER TABLE routes
ADD CONSTRAINT routes_owner_exactly_one
CHECK ((group_id IS NULL) <> (app_id IS NULL));
-- Per-owner binding uniqueness (partial so each owner column only constrains
-- its own rows). Existing app rows keep the exact binding-tuple uniqueness; a
-- group can't declare two identical templates. An app declaring a binding
-- identical to an inherited group template is a deliberate SHADOW, resolved
-- at rebuild (nearest-owner-wins) — not a DB conflict.
DROP INDEX routes_unique_binding_idx;
CREATE UNIQUE INDEX routes_unique_binding_idx
ON routes (app_id, host_kind, host, path_kind, path, COALESCE(method, ''))
WHERE app_id IS NOT NULL;
CREATE UNIQUE INDEX routes_group_binding_idx
ON routes (group_id, host_kind, host, path_kind, path, COALESCE(method, ''))
WHERE group_id IS NOT NULL;
-- The expansion join matches routes by owner (`r.app_id = ac.owner_app OR
-- r.group_id = ac.owner_group`). The app side already has routes_app_id_idx;
-- add the parallel group lookup index.
CREATE INDEX routes_group_id_idx ON routes (group_id) WHERE group_id IS NOT NULL;

View File

@@ -0,0 +1,36 @@
-- §11 tail (v1.2 Hierarchies): per-app opt-out of inherited group templates.
--
-- A group TRIGGER or ROUTE template inherits to every descendant app. Until now
-- a descendant could SHADOW a template (declare its own identical binding) but
-- not DECLINE one. This marker records that a specific app opts OUT of a
-- specific inherited template — the template then stops resolving for that app
-- (and only that app; a sibling subtree is unaffected).
--
-- Coarse by REFERENCE (not row id — template ids churn on re-apply): a
-- suppression names a handler SCRIPT NAME (target_kind='trigger') or a PATH
-- (target_kind='route'). A reference may decline more than one inherited
-- template (a group that bound several to the same script/path). The dispatch
-- filters gate to group-owned rows, so an app can only decline what it
-- INHERITS — never its own trigger/route, never a sibling's.
--
-- App-only: only an app suppresses (a group would just not declare the
-- template). So `app_id NOT NULL` — no polymorphic owner. It is pure app config
-- (like `extension_points`), so ON DELETE CASCADE (not the code-owner RESTRICT).
CREATE TABLE template_suppressions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
target_kind TEXT NOT NULL CHECK (target_kind IN ('trigger', 'route')),
-- A handler script name (trigger) or a path (route). Matched
-- case-insensitively for triggers (as script-name resolution is
-- elsewhere) and exactly for routes; stored as authored.
reference TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- One marker per (app, kind, reference) — re-apply is a NoOp.
CREATE UNIQUE INDEX template_suppressions_uidx
ON template_suppressions (app_id, target_kind, reference);
-- The trigger anti-join / route rebuild both look up by app_id.
CREATE INDEX template_suppressions_app_idx ON template_suppressions (app_id);

View File

@@ -0,0 +1,19 @@
-- §11 tail (v1.2 Hierarchies): `sealed` (mandatory) group templates.
--
-- Per-app opt-out (0058_template_suppressions) made an inherited group TRIGGER
-- or ROUTE template advisory-by-default: it runs on a descendant *unless* that
-- descendant declares a `[suppress]` declining it. That is a compliance footgun
-- — a group's audit / security / rate-limit template can be silently opted out
-- of by the very tenant it is meant to police.
--
-- A `sealed` template closes the gap: the two suppression filters skip a sealed
-- row, so it fires / serves on every descendant regardless of any suppression.
-- The column is only meaningful on a group-owned template (`group_id IS NOT
-- NULL`); an app-owned row is never inherited, so sealing it is meaningless and
-- rejected at apply. Existing rows default to unsealed (the prior behaviour).
--
-- * triggers.sealed — a sealed group trigger template is never suppressible.
-- * routes.sealed — a sealed group route template is never suppressible.
ALTER TABLE triggers ADD COLUMN sealed BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE routes ADD COLUMN sealed BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,38 @@
-- §11 tail (v1.2 Hierarchies): GROUP-level template suppression.
--
-- 0058 let an APP decline an inherited group template (for that app only). A
-- group operator often wants the same opt-out for a whole subtree: "no
-- descendant of this group runs the ancestor's `audit` template." Until now
-- only an app could suppress; a group could not.
--
-- Reshape `template_suppressions` to a POLYMORPHIC owner, exactly like the
-- group triggers/routes reshape (0056/0057) and the config markers (0051/0052):
-- * `group_id` — nullable FK→groups, ON DELETE CASCADE (config, not code).
-- * `app_id` — made nullable; the exactly-one CHECK enforces app XOR group.
-- * the per-app unique index becomes per-owner partials.
--
-- A GROUP suppression declines a template the group INHERITS from a higher
-- ancestor, for the group's whole subtree. Inheritance-only still holds: the
-- dispatch filters gate to group-owned rows, and a suppression only matches on
-- the suppressing owner's ancestor chain — a group can decline what it
-- inherits, never a sibling subtree's, never its own descendants' own rows.
ALTER TABLE template_suppressions
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE;
ALTER TABLE template_suppressions
ALTER COLUMN app_id DROP NOT NULL;
ALTER TABLE template_suppressions
ADD CONSTRAINT template_suppressions_owner_exactly_one
CHECK ((group_id IS NULL) <> (app_id IS NULL));
DROP INDEX template_suppressions_uidx;
CREATE UNIQUE INDEX template_suppressions_app_uidx
ON template_suppressions (app_id, target_kind, reference) WHERE app_id IS NOT NULL;
CREATE UNIQUE INDEX template_suppressions_group_uidx
ON template_suppressions (group_id, target_kind, reference) WHERE group_id IS NOT NULL;
-- The trigger anti-join / route rebuild look up by both owners now.
CREATE INDEX template_suppressions_group_idx
ON template_suppressions (group_id) WHERE group_id IS NOT NULL;

View File

@@ -0,0 +1,21 @@
-- §11.6 / §4.5: SHARED-collection triggers.
--
-- A §11.6 shared collection is group-owned and written by any subtree app via
-- the `kv::shared_collection(...)` handles. Until now such writes fired NO
-- trigger (the "group trigger has no app to watch" deferral): the per-app
-- trigger dispatch keys on the writer app's OWN collections, and a shared
-- collection lives in a distinct group-keyed store.
--
-- A SHARED trigger closes that gap: a group-owned trigger template marked
-- `shared = true` watches the group's shared collection (not per-app
-- collections). On a shared-collection write the emitter matches these triggers
-- by the OWNING GROUP's chain and runs the handler under the WRITER app
-- (`cx.app_id`) — the same "group template runs under the firing app" model.
--
-- The `shared` marker is the namespace boundary: a `shared = false` trigger
-- (the default, incl. every existing row) watches per-app collections exactly
-- as before; a `shared = true` trigger watches shared collections only. The two
-- never cross (the per-app match queries add `AND t.shared = FALSE`, the shared
-- match queries `AND t.shared = TRUE`).
ALTER TABLE triggers ADD COLUMN shared BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,25 @@
-- §4.5 M5: STATEFUL group trigger templates (cron / queue / email) via
-- per-descendant MATERIALIZATION.
--
-- The event kinds (kv/docs/files/pubsub) resolve a group template LIVE at
-- dispatch via the chain CTE — no per-app rows. The stateful kinds can't:
-- * cron needs a per-app `last_fired_at` the scheduler advances;
-- * queue needs the one-consumer-per-(app_id, queue_name) advisory lock;
-- * email needs a per-app SEALED inbound secret.
-- So a group stateful template is instead MATERIALIZED into an app-owned
-- trigger row per descendant app (created/removed as the tree changes), and the
-- unchanged dispatch paths (scheduler, queue consumer, email webhook) only ever
-- see app-owned rows.
--
-- `materialized_from` links a managed app-owned row back to the group template
-- it was expanded from: NULL = a hand-authored trigger; set = a reconciler-owned
-- copy (dropped + re-created as descendants come and go). ON DELETE CASCADE so
-- deleting the group template (RESTRICT-guarded elsewhere) or the group cleans
-- up the copies; a plain app delete CASCADEs the row away via `triggers.app_id`.
ALTER TABLE triggers
ADD COLUMN materialized_from UUID REFERENCES triggers(id) ON DELETE CASCADE;
-- The reconciler looks up existing copies by their source template.
CREATE INDEX idx_triggers_materialized_from
ON triggers (materialized_from) WHERE materialized_from IS NOT NULL;

View File

@@ -0,0 +1,12 @@
-- §4.5 M5: exactly one materialized copy per (descendant app, source template).
--
-- The materialization reconciler (materialize::rematerialize_stateful_templates)
-- runs on every tree/apply mutation, and two of them can run concurrently (e.g.
-- two apps created in parallel each trigger a full reconcile). Both compute the
-- same "missing" (app, template) pair and both try to insert the copy — without
-- a constraint that races into a DUPLICATE. This partial unique index makes the
-- second insert a no-op (the reconciler uses `ON CONFLICT DO NOTHING`), so a
-- copy is idempotent under concurrency.
CREATE UNIQUE INDEX triggers_materialized_uidx
ON triggers (app_id, materialized_from) WHERE materialized_from IS NOT NULL;

View File

@@ -0,0 +1,17 @@
-- §11.6 (cont., v1.2 Hierarchies): admit 'topic' and 'queue' as shared-
-- collection kinds.
--
-- D2 (shared topics) + D3 (shared queues) extend the group shared-collection
-- machinery from data stores (kv/docs/files) to the two event/messaging kinds.
-- A shared TOPIC is storeless — it is a group-scoped publish NAMESPACE watched
-- by `shared = true` group pubsub triggers (no per-kind storage table). A shared
-- QUEUE gets its group-keyed message store in 0065 (D3). This migration only
-- widens the marker allow-list; both kinds route through the same owner-generic
-- reconcile + `resolve_owning_group` path as the data kinds.
-- Widen the marker kind allow-list (auto-named `group_collections_kind_check`,
-- an inline column CHECK), matching 0054/0055.
ALTER TABLE group_collections
DROP CONSTRAINT group_collections_kind_check,
ADD CONSTRAINT group_collections_kind_check
CHECK (kind IN ('kv', 'docs', 'files', 'topic', 'queue'));

View File

@@ -0,0 +1,46 @@
-- §11.6 D3: group-shared durable queues.
--
-- The queue counterpart to the group_kv/docs/files shared stores (0053-0055).
-- A group declares a `queue` shared collection (kind admitted in 0064); any
-- subtree app enqueues into ONE group-owned store via
-- `queue::shared_collection(name).enqueue(...)`. Consumption is by COMPETING
-- CONSUMERS: a group `[[triggers.queue]] shared = true` template materializes a
-- consumer copy per descendant app (like the M5 stateful templates), and all
-- copies claim from this shared store with FOR UPDATE SKIP LOCKED — each message
-- delivered at-most-once across the whole subtree, scaling horizontally.
--
-- Identity tuple: (group_id, collection). Keyed by the OWNING GROUP, not an app
-- (the shared rows belong to the group). CASCADE on group delete (data dies with
-- its group; an app delete leaves it), matching the other group stores. Mirrors
-- queue_messages (0034) column-for-column, swapping (app_id, queue_name) for
-- (group_id, collection).
CREATE TABLE group_queue_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
collection TEXT NOT NULL,
payload JSONB NOT NULL,
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deliver_after TIMESTAMPTZ NULL,
claim_token UUID NULL,
claimed_at TIMESTAMPTZ NULL,
attempt INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 3,
enqueued_by_principal UUID NULL
);
-- Dispatch hot path: scan for unclaimed messages in (group_id, collection)
-- order by enqueued_at (the NOW() deliver_after filter is applied on the small
-- partial result, as in 0034).
CREATE INDEX idx_group_queue_messages_dispatch
ON group_queue_messages (group_id, collection, enqueued_at)
WHERE claim_token IS NULL;
-- depth() / depth_pending() helpers.
CREATE INDEX idx_group_queue_messages_group_collection
ON group_queue_messages (group_id, collection);
-- Reclaim-task scan: currently-leased messages past their visibility timeout.
CREATE INDEX idx_group_queue_messages_claimed
ON group_queue_messages (claimed_at)
WHERE claim_token IS NOT NULL;

View File

@@ -12,8 +12,8 @@ use axum::{
Extension, Json, Router,
};
use picloud_shared::{
AppId, ExecutionLog, ExecutionSource, InstanceRole, Principal, Script, ScriptId, ScriptKind,
ScriptSandbox, ScriptValidator, ValidatedScript, ValidationError,
AppId, ExecutionLog, ExecutionSource, GroupId, InstanceRole, Principal, Script, ScriptId,
ScriptKind, ScriptOwner, ScriptSandbox, ScriptValidator, ValidatedScript, ValidationError,
};
use serde::Deserialize;
@@ -181,6 +181,27 @@ async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppI
Ok(lookup.app.id)
}
/// Capability authorizing an operation on a script of either owner (Phase 4).
/// App-owned scripts map to their `App*` capability exactly as before; a
/// group-owned script maps to the group-scoped capability, resolved against
/// the group ancestor walk. An orphan row (neither owner — impossible under
/// the DB CHECK) reads as a missing id.
///
/// The `app`/`group` arguments are the capability constructors for each
/// owner, so one helper serves read / write / admin / log-read by passing
/// the matching pair.
fn script_cap(
script: &Script,
app: fn(AppId) -> Capability,
group: fn(GroupId) -> Capability,
) -> Result<Capability, ApiError> {
match script.owner() {
Some(ScriptOwner::App(a)) => Ok(app(a)),
Some(ScriptOwner::Group(g)) => Ok(group(g)),
None => Err(ApiError::NotFound(script.id)),
}
}
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
@@ -190,7 +211,7 @@ async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
require(
state.authz.as_ref(),
&principal,
Capability::AppRead(script.app_id),
script_cap(&script, Capability::AppRead, Capability::GroupScriptsRead)?,
)
.await?;
Ok(Json(script))
@@ -234,7 +255,8 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
let created = state
.repo
.create(NewScript {
app_id: input.app_id,
app_id: Some(input.app_id),
group_id: None,
name: input.name,
description: input.description,
source: input.source,
@@ -291,7 +313,11 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteScript(script.app_id),
script_cap(
&script,
Capability::AppWriteScript,
Capability::GroupScriptsWrite,
)?,
)
.await?;
@@ -361,13 +387,15 @@ async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
Path(id): Path<ScriptId>,
) -> Result<StatusCode, ApiError> {
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
// Delete is gated tighter than Save: editors can edit scripts but
// only app_admin / instance admin / owner can remove them. See
// blueprint §11.6.
// Delete is gated tighter than Save for app-owned scripts: editors can
// edit but only app_admin / instance admin / owner can remove them (§11.6).
// A group-owned script (Phase 4) has no group-admin-tier script cap, so it
// is gated at `GroupScriptsWrite` (editor+ on the group) — the same tier
// that created it; group deletion itself stays group_admin-gated elsewhere.
require(
state.authz.as_ref(),
&principal,
Capability::AppAdmin(script.app_id),
script_cap(&script, Capability::AppAdmin, Capability::GroupScriptsWrite)?,
)
.await?;
state.repo.delete(id).await?;
@@ -408,7 +436,11 @@ async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
require(
state.authz.as_ref(),
&principal,
Capability::AppLogRead(script.app_id),
script_cap(
&script,
Capability::AppLogRead,
Capability::GroupScriptsRead,
)?,
)
.await?;
// Cap to keep the dashboard responsive; the data plane writes are

View File

@@ -6,7 +6,7 @@
use std::sync::Arc;
use picloud_shared::{App, AppId, HostKind, PathKind};
use picloud_shared::{App, AppId, HostKind, PathKind, ScriptOwner};
use crate::app_repo::AppRepository;
use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError};
@@ -60,7 +60,8 @@ async fn seed_into(
) -> Result<(), ScriptRepositoryError> {
let script = scripts
.create(NewScript {
app_id: default.id,
app_id: Some(default.id),
group_id: None,
name: "hello".to_string(),
description: Some("Reference example: returns a greeting at GET /hello.".to_string()),
source: HELLO_RHAI_SOURCE.to_string(),
@@ -75,7 +76,7 @@ async fn seed_into(
routes
.create(NewRoute {
app_id: default.id,
owner: ScriptOwner::App(default.id),
script_id: script.id,
host_kind: HostKind::Any,
host: String::new(),
@@ -87,6 +88,7 @@ async fn seed_into(
method: None,
dispatch_mode: picloud_shared::DispatchMode::Sync,
enabled: true,
sealed: false,
})
.await?;

View File

@@ -8,27 +8,177 @@ use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::post,
routing::{get, post},
Extension, Json, Router,
};
use picloud_shared::{AppId, Principal};
use picloud_shared::{AppId, GroupId, Principal};
use serde::Deserialize;
use serde_json::json;
use crate::app_repo::AppRepository;
use crate::apply_service::{
ApplyError, ApplyReport, ApplyService, Bundle, BundleTrigger, PlanResult,
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo,
ExtensionPointInfo, NodeKind, PlanResult, RouteTemplateInfo, SuppressionInfo, TreeBundle,
TreePlanResult, TriggerTemplateInfo,
};
use crate::authz::{require, AuthzDenied, Capability};
use crate::group_repo::GroupRepository;
/// Build the apply/plan router. Mounted under `/api/v1/admin`.
pub fn apply_router(service: ApplyService) -> Router {
Router::new()
.route("/apps/{id}/plan", post(plan_handler))
.route("/apps/{id}/apply", post(apply_handler))
.route("/groups/{id}/plan", post(group_plan_handler))
.route("/groups/{id}/apply", post(group_apply_handler))
.route("/tree/plan", post(tree_plan_handler))
.route("/tree/apply", post(tree_apply_handler))
.route(
"/apps/{id}/extension-points",
get(app_extension_points_handler),
)
.route(
"/groups/{id}/extension-points",
get(group_extension_points_handler),
)
.route("/groups/{id}/collections", get(group_collections_handler))
.route("/groups/{id}/triggers", get(group_triggers_handler))
.route("/groups/{id}/routes", get(group_routes_handler))
.route("/apps/{id}/suppressions", get(app_suppressions_handler))
.route("/groups/{id}/suppressions", get(group_suppressions_handler))
.with_state(service)
}
/// Read-only §11 tail suppression report for an app: the inherited templates it
/// declines (`target_kind`, `reference`). Viewer-tier `AppRead`. Backs
/// `pic suppress ls --app`.
async fn app_suppressions_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<SuppressionInfo>>, ApplyError> {
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
.await
.map_err(map_authz)?;
let report = svc.suppression_report(ApplyOwner::App(app_id)).await?;
Ok(Json(report))
}
/// Read-only §11 tail M1 suppression report for a group: the inherited templates
/// it declines for its whole subtree (`target_kind`, `reference`). Viewer-tier
/// `GroupScriptsRead`. Backs `pic suppress ls --group`.
async fn group_suppressions_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<SuppressionInfo>>, ApplyError> {
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
require(
svc.authz.as_ref(),
&principal,
Capability::GroupScriptsRead(group_id),
)
.await
.map_err(map_authz)?;
let report = svc.suppression_report(ApplyOwner::Group(group_id)).await?;
Ok(Json(report))
}
/// Read-only §11 tail route-template report for a group: its own declared route
/// templates (method, host, path, handler script, dispatch, enabled). Viewer-
/// tier read. Backs `pic routes ls --group`.
async fn group_routes_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<RouteTemplateInfo>>, ApplyError> {
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
require(
svc.authz.as_ref(),
&principal,
Capability::GroupScriptsRead(group_id),
)
.await
.map_err(map_authz)?;
let report = svc.route_report(ApplyOwner::Group(group_id)).await?;
Ok(Json(report))
}
/// Read-only §11 tail trigger-template report for a group: its own declared
/// event trigger templates (kind, target, handler script, enabled). Viewer-tier
/// read. Backs `pic triggers ls --group`.
async fn group_triggers_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<TriggerTemplateInfo>>, ApplyError> {
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
require(
svc.authz.as_ref(),
&principal,
Capability::GroupScriptsRead(group_id),
)
.await
.map_err(map_authz)?;
let report = svc.trigger_report(ApplyOwner::Group(group_id)).await?;
Ok(Json(report))
}
/// Read-only §11.6 shared-collection report for a group: its own declared
/// shared KV collection names. Viewer-tier read.
async fn group_collections_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<CollectionInfo>>, ApplyError> {
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
require(
svc.authz.as_ref(),
&principal,
Capability::GroupScriptsRead(group_id),
)
.await
.map_err(map_authz)?;
let report = svc.collection_report(ApplyOwner::Group(group_id)).await?;
Ok(Json(report))
}
/// Read-only §5.5 extension-point report for an app: every EP visible on its
/// chain, each with `declared_here` + resolved `provider`. Viewer-tier read.
async fn app_extension_points_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<ExtensionPointInfo>>, ApplyError> {
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
.await
.map_err(map_authz)?;
let report = svc.extension_point_report(ApplyOwner::App(app_id)).await?;
Ok(Json(report))
}
/// Read-only §5.5 extension-point report for a group: its own declared names.
async fn group_extension_points_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<ExtensionPointInfo>>, ApplyError> {
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
require(
svc.authz.as_ref(),
&principal,
Capability::GroupScriptsRead(group_id),
)
.await
.map_err(map_authz)?;
let report = svc
.extension_point_report(ApplyOwner::Group(group_id))
.await?;
Ok(Json(report))
}
#[derive(Deserialize)]
pub struct ApplyRequest {
pub bundle: Bundle,
@@ -47,53 +197,7 @@ async fn apply_handler(
Json(req): Json<ApplyRequest>,
) -> Result<Json<ApplyReport>, ApplyError> {
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
// Read is always needed; write caps are required for the resource kinds
// the bundle touches — and for ALL kinds when `prune` is set, since
// pruning deletes resources whose bundle section is empty (and a script
// delete cascades its routes/triggers).
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
.await
.map_err(map_authz)?;
if req.prune || !req.bundle.scripts.is_empty() {
require(
svc.authz.as_ref(),
&principal,
Capability::AppWriteScript(app_id),
)
.await
.map_err(map_authz)?;
}
if req.prune || !req.bundle.routes.is_empty() {
require(
svc.authz.as_ref(),
&principal,
Capability::AppWriteRoute(app_id),
)
.await
.map_err(map_authz)?;
}
if req.prune || !req.bundle.triggers.is_empty() {
require(
svc.authz.as_ref(),
&principal,
Capability::AppManageTriggers(app_id),
)
.await
.map_err(map_authz)?;
}
// Email triggers resolve and decrypt a stored secret by name server-side,
// which the secrets API guards with `AppSecretsRead`. Require it here too
// so apply can't bind a secret a principal couldn't otherwise read — the
// caps aren't strictly nested on the API-key scope path.
if req.bundle.triggers.iter().any(BundleTrigger::is_email) {
require(
svc.authz.as_ref(),
&principal,
Capability::AppSecretsRead(app_id),
)
.await
.map_err(map_authz)?;
}
require_app_node_writes(&svc, &principal, app_id, &req.bundle, req.prune).await?;
let report = svc
.apply(
app_id,
@@ -126,6 +230,254 @@ async fn plan_handler(
Ok(Json(plan))
}
// ----------------------------------------------------------------------------
// Group node apply (Phase 5): a group declares only scripts + vars (+ secret
// names). The bundle reuses the app `Bundle` shape with empty routes/triggers;
// the service rejects routes/triggers on a group node.
// ----------------------------------------------------------------------------
async fn group_plan_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(bundle): Json<Bundle>,
) -> Result<Json<PlanResult>, ApplyError> {
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
// Plan discloses the group's script + var + secret names; viewer-tier reads.
require(
svc.authz.as_ref(),
&principal,
Capability::GroupScriptsRead(group_id),
)
.await
.map_err(map_authz)?;
let plan = svc.plan_owner(ApplyOwner::Group(group_id), &bundle).await?;
Ok(Json(plan))
}
async fn group_apply_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(req): Json<ApplyRequest>,
) -> Result<Json<ApplyReport>, ApplyError> {
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
require_group_node_writes(&svc, &principal, group_id, &req.bundle, req.prune).await?;
let report = svc
.apply_owner(
ApplyOwner::Group(group_id),
&req.bundle,
req.prune,
principal.user_id,
req.expected_token.as_deref(),
)
.await?;
Ok(Json(report))
}
// ----------------------------------------------------------------------------
// Tree apply (Phase 5): a whole project subtree in one transaction. The actor
// must hold the relevant read/write caps for EVERY node touched.
// ----------------------------------------------------------------------------
#[derive(Deserialize)]
struct TreeApplyRequest {
bundle: TreeBundle,
#[serde(default)]
prune: bool,
#[serde(default)]
expected_token: Option<String>,
}
async fn tree_plan_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Json(bundle): Json<TreeBundle>,
) -> Result<Json<TreePlanResult>, ApplyError> {
authz_tree(&svc, &principal, &bundle, None).await?;
Ok(Json(svc.plan_tree(&bundle).await?))
}
async fn tree_apply_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Json(req): Json<TreeApplyRequest>,
) -> Result<Json<ApplyReport>, ApplyError> {
authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?;
let report = svc
.apply_tree(
&req.bundle,
req.prune,
principal.user_id,
req.expected_token.as_deref(),
)
.await?;
Ok(Json(report))
}
/// Per-node capability check for a tree plan/apply. `prune` widens an apply's
/// write requirement to every kind. A plan (`prune` ignored, no writes) needs
/// only the per-node read cap.
async fn authz_tree(
svc: &ApplyService,
principal: &Principal,
bundle: &TreeBundle,
apply_prune: Option<bool>,
) -> Result<(), ApplyError> {
for node in &bundle.nodes {
match node.kind {
NodeKind::App => {
let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?;
match apply_prune {
Some(prune) => {
require_app_node_writes(svc, principal, app_id, &node.bundle, prune)
.await?;
}
None => {
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
.await
.map_err(map_authz)?;
}
}
}
NodeKind::Group => {
let group_id = resolve_group_id(svc.groups.as_ref(), &node.slug).await?;
match apply_prune {
Some(prune) => {
require_group_node_writes(svc, principal, group_id, &node.bundle, prune)
.await?;
}
None => {
require(
svc.authz.as_ref(),
principal,
Capability::GroupScriptsRead(group_id),
)
.await
.map_err(map_authz)?;
}
}
}
}
}
Ok(())
}
/// App-node write caps: per resource kind the bundle touches, widened to all
/// kinds when `prune` (which deletes empty-section resources + cascades). Read
/// is always needed. Shared by the single-app and tree apply paths.
async fn require_app_node_writes(
svc: &ApplyService,
principal: &Principal,
app_id: picloud_shared::AppId,
bundle: &Bundle,
prune: bool,
) -> Result<(), ApplyError> {
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
.await
.map_err(map_authz)?;
if prune || !bundle.scripts.is_empty() {
require(
svc.authz.as_ref(),
principal,
Capability::AppWriteScript(app_id),
)
.await
.map_err(map_authz)?;
}
if prune || !bundle.routes.is_empty() {
require(
svc.authz.as_ref(),
principal,
Capability::AppWriteRoute(app_id),
)
.await
.map_err(map_authz)?;
}
if prune || !bundle.triggers.is_empty() {
require(
svc.authz.as_ref(),
principal,
Capability::AppManageTriggers(app_id),
)
.await
.map_err(map_authz)?;
}
if prune || !bundle.vars.is_empty() {
require(
svc.authz.as_ref(),
principal,
Capability::AppVarsWrite(app_id),
)
.await
.map_err(map_authz)?;
}
// Email triggers resolve + decrypt a stored secret by name server-side
// (`AppSecretsRead`), so require it so apply can't bind a secret the
// principal couldn't otherwise read.
if bundle.triggers.iter().any(BundleTrigger::is_email) {
require(
svc.authz.as_ref(),
principal,
Capability::AppSecretsRead(app_id),
)
.await
.map_err(map_authz)?;
}
Ok(())
}
/// Group-node write caps (editor-tier): scripts → `GroupScriptsWrite`, vars →
/// `GroupVarsWrite`, both on prune.
async fn require_group_node_writes(
svc: &ApplyService,
principal: &Principal,
group_id: GroupId,
bundle: &Bundle,
prune: bool,
) -> Result<(), ApplyError> {
if prune || !bundle.scripts.is_empty() {
require(
svc.authz.as_ref(),
principal,
Capability::GroupScriptsWrite(group_id),
)
.await
.map_err(map_authz)?;
}
if prune || !bundle.vars.is_empty() {
require(
svc.authz.as_ref(),
principal,
Capability::GroupVarsWrite(group_id),
)
.await
.map_err(map_authz)?;
}
Ok(())
}
/// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404.
async fn resolve_group_id(
groups: &dyn GroupRepository,
ident: &str,
) -> Result<GroupId, ApplyError> {
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
groups
.get_by_id(uuid.into())
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
} else {
groups
.get_by_slug(ident)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
};
found
.map(|g| g.id)
.ok_or_else(|| ApplyError::AppNotFound(format!("group {ident}")))
}
/// Resolve a slug-or-id path param to an `AppId`, mapping miss → 404.
/// Mirrors the `triggers_api` helper of the same shape.
async fn resolve_app_id(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApplyError> {

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get, post};
use axum::{Extension, Router};
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain, RouteTable};
use picloud_shared::{App, AppDomain, AppId, AppRole, InstanceRole, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -43,11 +43,19 @@ pub struct AppsState {
/// Cached host → app_id lookup; replaced after every domain CRUD
/// operation so the orchestrator sees changes immediately.
pub domain_table: Arc<AppDomainTable>,
/// §11 tail: the route match snapshot. Creating/deleting an app changes
/// which group route TEMPLATES are inherited, so it is rebuilt here
/// (full-live invalidation) — mirroring the domain_table refresh.
pub route_table: Arc<RouteTable>,
/// Capability gate — Phase 3.5.
pub authz: Arc<dyn AuthzRepo>,
/// Group tree — resolves an app's parent group at create time
/// (defaults to the instance root).
pub groups: Arc<dyn GroupRepository>,
/// §4.5 M5: creating/deleting an app changes which ancestor-group stateful
/// templates it inherits, so its materialized cron/queue copies are
/// reconciled here (full-live, mirroring the route_table rebuild).
pub pool: sqlx::PgPool,
}
pub fn apps_router(state: AppsState) -> Router {
@@ -226,6 +234,9 @@ async fn create_app(
)
.await?
};
// The new app may sit under a group that owns route TEMPLATES — make them
// servable immediately (full-live invalidation).
refresh_route_cache(&s).await;
Ok((StatusCode::CREATED, Json(created)))
}
@@ -368,6 +379,8 @@ async fn delete_app(
s.apps.delete(app.id).await?;
}
refresh_domain_cache(&s).await?;
// Drop the deleted app's slice (and any routes it owned) from the snapshot.
refresh_route_cache(&s).await;
Ok(StatusCode::NO_CONTENT)
}
@@ -545,6 +558,24 @@ fn validate_slug(slug: &str) -> Result<(), AppsApiError> {
/// Rebuild the in-memory host → app_id cache used by the orchestrator.
/// Called after every domain CRUD operation.
/// §11 tail: rebuild the route match snapshot after a tree mutation that
/// changes route inheritance (an app gained/lost its place in the group tree,
/// so the set of ancestor-group route TEMPLATES it serves changed). Best-effort
/// to mirror the apply path — a failure leaves a stale-but-self-healing table
/// rather than failing the committed mutation.
async fn refresh_route_cache(state: &AppsState) {
if let Err(e) =
crate::route_admin::rebuild_route_table(state.routes.as_ref(), &state.route_table).await
{
tracing::warn!(error = %e, "apps: route table refresh failed; it will self-heal");
}
// §4.5 M5: reconcile materialized stateful-template copies for the changed
// app set (best-effort; self-heals on the next mutation).
if let Err(e) = crate::materialize::rematerialize_stateful_templates(&state.pool).await {
tracing::warn!(error = %e, "apps: stateful-template materialization failed; it will self-heal");
}
}
pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError> {
let all = state.domains.list_all().await?;
let compiled = all

View File

@@ -116,6 +116,64 @@ pub enum Capability {
/// Write (set/delete) a secret in this app's secrets store (v1.1.7).
/// Granted to `editor`+, maps to `script:write` on API keys.
AppSecretsWrite(AppId),
/// Read this app's resolved config vars (Phase 3). Same trust shape as
/// secrets-read — granted to `viewer`+, maps to `script:read`.
AppVarsRead(AppId),
/// Write (set/delete) an app-owned config var (Phase 3). Granted to
/// `editor`+, maps to `script:write`.
AppVarsWrite(AppId),
/// Read a group's config vars (Phase 3). Resolved via the group
/// ancestor walk; viewer+ on the group.
GroupVarsRead(GroupId),
/// Write (set/delete) a group-owned config var (Phase 3). editor+ on
/// the group.
GroupVarsWrite(GroupId),
/// Read a group-owned secret's VALUE (Phase 3, the human-read gate).
/// group_admin on the owning group — distinct from runtime injection,
/// which an inheriting app does without this check.
GroupSecretsRead(GroupId),
/// Write (set/delete) a group-owned secret (Phase 3). editor+ on the
/// group.
GroupSecretsWrite(GroupId),
/// Read (get / list) group-owned scripts (Phase 4). Resolved via the
/// group ancestor walk; viewer+ on the group. Maps to `script:read`.
GroupScriptsRead(GroupId),
/// Create / update / delete a group-owned script (Phase 4). editor+ on
/// the group; maps to `script:write` — the same tier as `AppWriteScript`
/// for an app, lifted to the group owner.
GroupScriptsWrite(GroupId),
/// Read a group-owned shared KV collection (§11.6). Resolved via the group
/// ancestor walk; viewer+ on the owning group. Maps to `script:read`. The
/// reads-open trust model means a script with no principal (anonymous
/// public HTTP) bypasses this check — the structural subtree boundary
/// (the collection only resolves for apps under the owning group) is the
/// hard isolation; this cap refines access for authenticated callers.
GroupKvRead(GroupId),
/// Write a group-owned shared KV collection (§11.6). editor+ on the owning
/// group; maps to `script:write`. Unlike the read cap, a write FAILS CLOSED
/// for an anonymous principal — mutation of shared data always requires an
/// authenticated caller (enforced at the service layer, not by skipping).
GroupKvWrite(GroupId),
/// Read a group-owned shared DOCS collection (§11.6). Viewer+ on the owning
/// group; same reads-open trust model as `GroupKvRead`.
GroupDocsRead(GroupId),
/// Write a group-owned shared DOCS collection (§11.6). editor+ on the owning
/// group; fails closed for an anonymous principal, like `GroupKvWrite`.
GroupDocsWrite(GroupId),
/// Read a group-owned shared FILES collection (§11.6). Viewer+ on the owning
/// group; same reads-open trust model as `GroupKvRead`.
GroupFilesRead(GroupId),
/// Write a group-owned shared FILES collection (§11.6). editor+ on the owning
/// group; fails closed for an anonymous principal, like `GroupKvWrite`.
GroupFilesWrite(GroupId),
/// Publish to a group-owned shared TOPIC (§11.6 D2). editor+ on the owning
/// group; fails closed for an anonymous principal, like `GroupKvWrite` — a
/// publish is a write to shared state.
GroupPubsubPublish(GroupId),
/// Enqueue into a group-owned shared QUEUE (§11.6 D3). editor+ on the owning
/// group; fails closed for an anonymous principal, like `GroupKvWrite` — an
/// enqueue is a write to shared state.
GroupQueueEnqueue(GroupId),
/// Send an outbound email from a script in this app (v1.1.7). Maps
/// to `script:write` on API keys (sending mail is an outbound
/// side-effect like an HTTP request). Granted to `editor`+.
@@ -176,7 +234,21 @@ impl Capability {
// app) is denied group management at the binding layer.
| Self::GroupRead(_)
| Self::GroupWrite(_)
| Self::GroupAdmin(_) => None,
| Self::GroupAdmin(_)
| Self::GroupVarsRead(_)
| Self::GroupVarsWrite(_)
| Self::GroupSecretsRead(_)
| Self::GroupSecretsWrite(_)
| Self::GroupScriptsRead(_)
| Self::GroupScriptsWrite(_)
| Self::GroupKvRead(_)
| Self::GroupKvWrite(_)
| Self::GroupDocsRead(_)
| Self::GroupDocsWrite(_)
| Self::GroupFilesRead(_)
| Self::GroupFilesWrite(_)
| Self::GroupPubsubPublish(_)
| Self::GroupQueueEnqueue(_) => None,
Self::AppRead(id)
| Self::AppWriteScript(id)
| Self::AppWriteRoute(id)
@@ -194,6 +266,8 @@ impl Capability {
| Self::AppQueueEnqueue(id)
| Self::AppSecretsRead(id)
| Self::AppSecretsWrite(id)
| Self::AppVarsRead(id)
| Self::AppVarsWrite(id)
| Self::AppEmailSend(id)
| Self::AppManageTriggers(id)
| Self::AppDeadLetterManage(id)
@@ -223,7 +297,13 @@ impl Capability {
| Self::AppFilesRead(_)
| Self::AppSecretsRead(_)
| Self::AppUsersRead(_)
| Self::GroupRead(_) => Scope::ScriptRead,
| Self::AppVarsRead(_)
| Self::GroupRead(_)
| Self::GroupVarsRead(_)
| Self::GroupScriptsRead(_)
| Self::GroupKvRead(_)
| Self::GroupDocsRead(_)
| Self::GroupFilesRead(_) => Scope::ScriptRead,
Self::AppWriteScript(_)
| Self::AppKvWrite(_)
| Self::AppDocsWrite(_)
@@ -235,6 +315,18 @@ impl Capability {
| Self::AppEmailSend(_)
| Self::AppUsersWrite(_)
| Self::AppUsersAdmin(_)
| Self::AppVarsWrite(_)
// Group-secret WRITE is editor-role-gated (same tier as
// GroupVarsWrite), so its API-key scope matches: script:write,
// not app:admin. Only the VALUE READ (GroupSecretsRead) sits at
// the admin tier below.
| Self::GroupSecretsWrite(_)
| Self::GroupScriptsWrite(_)
| Self::GroupKvWrite(_)
| Self::GroupDocsWrite(_)
| Self::GroupFilesWrite(_)
| Self::GroupPubsubPublish(_)
| Self::GroupQueueEnqueue(_)
| Self::AppInvoke(_) => Scope::ScriptWrite,
Self::AppWriteRoute(_) => Scope::RouteWrite,
Self::AppManageDomains(_) => Scope::DomainManage,
@@ -243,7 +335,9 @@ impl Capability {
| Self::AppDeadLetterManage(_)
| Self::AppTopicManage(_)
| Self::GroupWrite(_)
| Self::GroupAdmin(_) => Scope::AppAdmin,
| Self::GroupAdmin(_)
| Self::GroupVarsWrite(_)
| Self::GroupSecretsRead(_) => Scope::AppAdmin,
Self::AppLogRead(_) => Scope::LogRead,
}
}
@@ -395,6 +489,34 @@ pub async fn script_gate<E>(
}
}
/// Like [`script_gate`], but **fails closed on an anonymous principal**: a
/// script with `cx.principal == None` is rejected with `forbidden()` rather
/// than skipped. Used for actions that must always be performed by an
/// authenticated caller even though the surrounding service skips authz for
/// public scripts — e.g. §11.6 group-collection WRITES (reads stay open via
/// `script_gate`, writes require an authenticated editor+ on the owning group).
///
/// # Errors
///
/// Returns `forbidden()` when the principal is absent or the capability is
/// denied, or `backend(repo_err.to_string())` on a repo error.
pub async fn script_gate_require_principal<E>(
repo: &dyn AuthzRepo,
cx: &picloud_shared::SdkCallCx,
cap: Capability,
forbidden: impl FnOnce() -> E,
backend: impl FnOnce(String) -> E,
) -> Result<(), E> {
let Some(principal) = cx.principal.as_ref() else {
return Err(forbidden());
};
match require(repo, principal, cap).await {
Ok(()) => Ok(()),
Err(AuthzDenied::Denied) => Err(forbidden()),
Err(AuthzDenied::Repo(e)) => Err(backend(e.to_string())),
}
}
// ----------------------------------------------------------------------------
// Layer 1: role-derived grant
// ----------------------------------------------------------------------------
@@ -412,7 +534,23 @@ async fn role_grants(
// walk (a group_admin on an ancestor is implicitly admin of
// the descendant group). Routed before member_grants because
// group caps carry no app_id.
Capability::GroupRead(g) | Capability::GroupWrite(g) | Capability::GroupAdmin(g) => {
Capability::GroupRead(g)
| Capability::GroupWrite(g)
| Capability::GroupAdmin(g)
| Capability::GroupVarsRead(g)
| Capability::GroupVarsWrite(g)
| Capability::GroupSecretsRead(g)
| Capability::GroupSecretsWrite(g)
| Capability::GroupScriptsRead(g)
| Capability::GroupScriptsWrite(g)
| Capability::GroupKvRead(g)
| Capability::GroupKvWrite(g)
| Capability::GroupDocsRead(g)
| Capability::GroupDocsWrite(g)
| Capability::GroupFilesRead(g)
| Capability::GroupFilesWrite(g)
| Capability::GroupPubsubPublish(g)
| Capability::GroupQueueEnqueue(g) => {
group_member_grants(repo, principal.user_id, cap, g).await
}
// Creating a root-level group is an instance act — members
@@ -472,9 +610,30 @@ async fn group_member_grants(
/// viewer→read, editor→write, group_admin(=AppAdmin)→admin.
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
match cap {
Capability::GroupRead(_) => true, // any role can read
Capability::GroupWrite(_) => matches!(role, AppRole::Editor | AppRole::AppAdmin),
Capability::GroupAdmin(_) => matches!(role, AppRole::AppAdmin),
// viewer+ reads group metadata, config vars, scripts, and shared KV.
Capability::GroupRead(_)
| Capability::GroupVarsRead(_)
| Capability::GroupScriptsRead(_)
| Capability::GroupKvRead(_)
| Capability::GroupDocsRead(_)
| Capability::GroupFilesRead(_) => true,
// editor+ writes config vars/secrets/scripts and shared KV.
Capability::GroupWrite(_)
| Capability::GroupVarsWrite(_)
| Capability::GroupSecretsWrite(_)
| Capability::GroupScriptsWrite(_)
| Capability::GroupKvWrite(_)
| Capability::GroupDocsWrite(_)
| Capability::GroupFilesWrite(_)
| Capability::GroupPubsubPublish(_)
| Capability::GroupQueueEnqueue(_) => {
matches!(role, AppRole::Editor | AppRole::AppAdmin)
}
// group_admin manages the group + reads secret VALUES (the
// human-read gate, distinct from an app's runtime injection).
Capability::GroupAdmin(_) | Capability::GroupSecretsRead(_) => {
matches!(role, AppRole::AppAdmin)
}
_ => false,
}
}
@@ -493,6 +652,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
| Capability::AppFilesRead(_)
| Capability::AppSecretsRead(_)
| Capability::AppUsersRead(_)
| Capability::AppVarsRead(_)
);
let in_editor = in_viewer
|| matches!(
@@ -508,6 +668,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
| Capability::AppSecretsWrite(_)
| Capability::AppEmailSend(_)
| Capability::AppUsersWrite(_)
| Capability::AppVarsWrite(_)
| Capability::AppInvoke(_)
);
let in_app_admin = in_editor
@@ -1146,6 +1307,147 @@ mod tests {
);
}
#[tokio::test]
async fn group_kv_caps_resolve_by_role_up_the_chain() {
// §11.6: shared-KV read is viewer+, write is editor+, both resolved via
// the group ancestor walk; an outsider gets nothing.
let repo = InMemoryAuthzRepo::default();
let root = GroupId::new();
let team = GroupId::new();
repo.add_group(root, None).await;
repo.add_group(team, Some(root)).await;
// A viewer at root can READ a descendant group's shared KV but not write.
let viewer = principal(InstanceRole::Member);
repo.grant_group(viewer.user_id, root, AppRole::Viewer)
.await;
assert!(can(&repo, &viewer, Capability::GroupKvRead(team))
.await
.unwrap()
.is_allow());
assert_eq!(
can(&repo, &viewer, Capability::GroupKvWrite(team))
.await
.unwrap(),
Decision::Deny
);
// An editor at root can WRITE it.
let editor = principal(InstanceRole::Member);
repo.grant_group(editor.user_id, root, AppRole::Editor)
.await;
assert!(can(&repo, &editor, Capability::GroupKvWrite(team))
.await
.unwrap()
.is_allow());
// An unrelated member gets neither.
let outsider = principal(InstanceRole::Member);
assert_eq!(
can(&repo, &outsider, Capability::GroupKvRead(team))
.await
.unwrap(),
Decision::Deny
);
// Group caps carry no app_id, so a bound key is denied at the binding
// layer regardless of role.
let bound = Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: Some(vec![Scope::ScriptWrite]),
app_binding: Some(AppId::new()),
};
assert_eq!(
can(&repo, &bound, Capability::GroupKvWrite(team))
.await
.unwrap(),
Decision::Deny
);
}
#[tokio::test]
async fn group_docs_caps_resolve_by_role_up_the_chain() {
// §11.6 docs: same trust shape as shared KV — read is viewer+, write is
// editor+, resolved via the group ancestor walk.
let repo = InMemoryAuthzRepo::default();
let root = GroupId::new();
let team = GroupId::new();
repo.add_group(root, None).await;
repo.add_group(team, Some(root)).await;
let viewer = principal(InstanceRole::Member);
repo.grant_group(viewer.user_id, root, AppRole::Viewer)
.await;
assert!(can(&repo, &viewer, Capability::GroupDocsRead(team))
.await
.unwrap()
.is_allow());
assert_eq!(
can(&repo, &viewer, Capability::GroupDocsWrite(team))
.await
.unwrap(),
Decision::Deny
);
let editor = principal(InstanceRole::Member);
repo.grant_group(editor.user_id, root, AppRole::Editor)
.await;
assert!(can(&repo, &editor, Capability::GroupDocsWrite(team))
.await
.unwrap()
.is_allow());
let outsider = principal(InstanceRole::Member);
assert_eq!(
can(&repo, &outsider, Capability::GroupDocsRead(team))
.await
.unwrap(),
Decision::Deny
);
}
#[tokio::test]
async fn group_files_caps_resolve_by_role_up_the_chain() {
// §11.6 files: same trust shape as shared KV/docs — read is viewer+,
// write is editor+, resolved via the group ancestor walk.
let repo = InMemoryAuthzRepo::default();
let root = GroupId::new();
let team = GroupId::new();
repo.add_group(root, None).await;
repo.add_group(team, Some(root)).await;
let viewer = principal(InstanceRole::Member);
repo.grant_group(viewer.user_id, root, AppRole::Viewer)
.await;
assert!(can(&repo, &viewer, Capability::GroupFilesRead(team))
.await
.unwrap()
.is_allow());
assert_eq!(
can(&repo, &viewer, Capability::GroupFilesWrite(team))
.await
.unwrap(),
Decision::Deny
);
let editor = principal(InstanceRole::Member);
repo.grant_group(editor.user_id, root, AppRole::Editor)
.await;
assert!(can(&repo, &editor, Capability::GroupFilesWrite(team))
.await
.unwrap()
.is_allow());
let outsider = principal(InstanceRole::Member);
assert_eq!(
can(&repo, &outsider, Capability::GroupFilesRead(team))
.await
.unwrap(),
Decision::Deny
);
}
#[tokio::test]
async fn admin_implicitly_manages_the_whole_group_tree() {
let repo = InMemoryAuthzRepo::default();

View File

@@ -0,0 +1,158 @@
//! `GET /api/v1/admin/apps/{id}/config/effective` — the resolved config an
//! app actually sees: every inherited var (with its value + provenance) and
//! every inherited secret (MASKED — name/owner/scope only, never the value).
//!
//! This is the read-only companion to the `vars`/`secrets` admin surfaces.
//! It runs the same §3 resolution the `vars::`/`secrets::` SDK calls run, so
//! a dev can see exactly what `vars::get`/`secrets::get` would return and
//! where each value comes from (`--explain` on the CLI surfaces the
//! provenance). Gated by `AppVarsRead` (config is app-readable); the secret
//! VALUES are deliberately absent — reading those needs `GroupSecretsRead`
//! at the owning group via the dedicated value endpoint.
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::get;
use axum::{Extension, Router};
use picloud_shared::{AppId, Principal};
use serde_json::json;
use sqlx::PgPool;
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::config_resolver::{
fetch_effective_secret_meta, fetch_var_candidates, resolve, OwnerKind,
};
#[derive(Clone)]
pub struct ConfigApiState {
pub pool: PgPool,
pub apps: Arc<dyn AppRepository>,
pub authz: Arc<dyn AuthzRepo>,
}
pub fn config_router(state: ConfigApiState) -> Router {
Router::new()
.route("/apps/{id_or_slug}/config/effective", get(effective_config))
.with_state(state)
}
fn owner_json(kind: OwnerKind, id: uuid::Uuid, depth: i32) -> serde_json::Value {
json!({ "kind": kind.as_str(), "id": id, "depth": depth })
}
async fn effective_config(
State(s): State<ConfigApiState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<serde_json::Value>, ConfigApiError> {
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppVarsRead(app_id),
)
.await?;
// Vars: resolve to values + provenance (vars are app-readable config).
let candidates = fetch_var_candidates(&s.pool, app_id)
.await
.map_err(|e| ConfigApiError::Backend(e.to_string()))?;
let (values, provenance) = resolve(candidates);
let mut vars = serde_json::Map::new();
for (key, value) in values {
let p = &provenance[&key];
vars.insert(
key,
json!({
"value": value,
"owner": owner_json(p.owner_kind, p.owner_id, p.depth),
"scope": p.scope,
"merged_from": p.merged_from
.iter()
.map(|(d, sc)| json!({ "depth": d, "scope": sc }))
.collect::<Vec<_>>(),
}),
);
}
// Secrets: masked — name + owner/level/scope + status, never the value.
let secret_meta = fetch_effective_secret_meta(&s.pool, app_id)
.await
.map_err(|e| ConfigApiError::Backend(e.to_string()))?;
let mut secrets = serde_json::Map::new();
for m in secret_meta {
secrets.insert(
m.name,
json!({
"status": "set",
"owner": owner_json(m.owner_kind, m.owner_id, m.depth),
"scope": m.scope,
}),
);
}
Ok(Json(json!({ "vars": vars, "secrets": secrets })))
}
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ConfigApiError> {
crate::app_repo::resolve_app(apps, ident)
.await
.map_err(|e| ConfigApiError::Backend(e.to_string()))?
.map(|l| l.app.id)
.ok_or(ConfigApiError::AppNotFound)
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigApiError {
#[error("app not found")]
AppNotFound,
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("config backend: {0}")]
Backend(String),
}
impl From<AuthzDenied> for ConfigApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl From<AuthzError> for ConfigApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl IntoResponse for ConfigApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "config effective authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Backend(e) => {
tracing::error!(error = %e, "config effective backend error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

@@ -0,0 +1,472 @@
//! The §3 configuration-resolution engine: env-filtered, proximity-first
//! inheritance down the group tree.
//!
//! To resolve a key for app A in environment E (docs/design §3):
//! 1. **Env-filter first, per level** — a value scoped `@E` is eligible;
//! `*` (env-agnostic) is the fallback. Within one level `@E` beats `*`.
//! Env is *eligibility*, not a precedence tier.
//! 2. **Nearest level wins** — walk A → parent group → … → root; the
//! closest level that defines the (filtered) key wins. Proximity beats
//! farther-level env-specificity (a leaf's `*` beats an ancestor's `@E`).
//! 3. **Maps deep-merge per key; scalars/arrays replace; deletion is an
//! explicit tombstone** that suppresses the inherited key.
//!
//! The recursive CTE that walks `apps.group_id → groups.parent_id → root`
//! mirrors `app_members_repo::effective_app_role`. The env-eligibility
//! filter happens in SQL; the §3 merge/replace/tombstone semantics — which
//! a window-function pick can't express — happen in the pure `resolve`
//! function below, so they're unit-tested without Postgres.
use std::collections::BTreeMap;
use serde_json::{Map, Value};
use sqlx::PgPool;
use uuid::Uuid;
use picloud_shared::AppId;
/// Shared chain-walk CTE: emits one row per owner-level for an app —
/// depth 0 = the app itself (`app_owner` set), then ancestor groups
/// nearest-first (`group_owner` set), each carrying the app's environment.
/// Depth-bounded `< 64` (the group cycle guard already forbids cycles; this
/// is a runaway guard). Bind `$1 = app_id`. Reused by the vars and secret
/// resolvers, which each append their own owner-keyed JOIN.
pub(crate) const CHAIN_LEVELS_CTE: &str = "\
WITH RECURSIVE chain AS ( \
SELECT a.id AS app_owner, NULL::uuid AS group_owner, \
a.group_id AS next_group, 0 AS depth, a.environment AS app_env \
FROM apps a WHERE a.id = $1 \
UNION ALL \
SELECT NULL::uuid, g.id, g.parent_id, c.depth + 1, c.app_env \
FROM groups g JOIN chain c ON g.id = c.next_group \
WHERE c.depth < 64 \
)";
/// Group-rooted variant of [`CHAIN_LEVELS_CTE`]: the chain starts at a
/// **group** (depth 0 = the group itself, `group_owner` set), then walks
/// its ancestor groups nearest-first. There is no `app_owner` level — a
/// group origin never sees app-owned rows below it (the §5.5 trust
/// boundary). Bind `$1 = group_id`. Used for the lexical module resolver
/// when the importing script's defining node is a group.
pub(crate) const GROUP_CHAIN_LEVELS_CTE: &str = "\
WITH RECURSIVE chain AS ( \
SELECT g.id AS group_owner, g.parent_id AS next_group, 0 AS depth \
FROM groups g WHERE g.id = $1 \
UNION ALL \
SELECT g.id, g.parent_id, c.depth + 1 \
FROM groups g JOIN chain c ON g.id = c.next_group \
WHERE c.depth < 64 \
)";
/// Owner kind of a resolved value, for `--explain` provenance.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OwnerKind {
App,
Group,
}
impl OwnerKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::App => "app",
Self::Group => "group",
}
}
}
/// One eligible (env-filtered) candidate row pulled by the resolver, before
/// §3 proximity/merge resolution.
#[derive(Debug, Clone)]
pub struct Candidate {
/// 0 = the app itself; 1 = its parent group; … (nearest-first).
pub depth: i32,
pub owner_kind: OwnerKind,
pub owner_id: Uuid,
/// `*` (env-agnostic) or a concrete environment name.
pub scope: String,
pub key: String,
pub value: Value,
pub is_tombstone: bool,
}
/// Where a resolved key came from (for `config --effective --explain`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Provenance {
pub owner_kind: OwnerKind,
pub owner_id: Uuid,
pub depth: i32,
pub scope: String,
/// For a deep-merged map, the `(depth, scope)` of every layer that
/// contributed, nearest-first. Empty for a plain scalar/array winner.
pub merged_from: Vec<(i32, String)>,
}
/// `@E`-scoped values outrank `*` *within the same level* (§3 step 1).
fn env_priority(scope: &str) -> u8 {
u8::from(scope != "*")
}
/// Deep-merge `src` into `dst` per key: nested objects merge recursively,
/// everything else is replaced by `src`. Caller merges farthest→nearest so
/// nearer layers overwrite.
fn deep_merge(dst: &mut Map<String, Value>, src: &Map<String, Value>) {
for (k, sv) in src {
match (dst.get_mut(k), sv) {
(Some(Value::Object(dm)), Value::Object(sm)) => deep_merge(dm, sm),
_ => {
dst.insert(k.clone(), sv.clone());
}
}
}
}
/// Resolve one key's candidate rows (any order in) to its effective value,
/// plus the provenance. Returns `None` when a tombstone suppresses the key.
fn resolve_one(mut rows: Vec<Candidate>) -> Option<(Value, Provenance)> {
// Nearest-first; within a level, `@E` before `*` (§3 steps 1-2).
rows.sort_by(|a, b| {
a.depth
.cmp(&b.depth)
.then(env_priority(&b.scope).cmp(&env_priority(&a.scope)))
});
// §3 step 1: env is eligibility, not a merge tier — within a single level
// `@E` *suppresses* `*` (it does not layer on top of it). Each level is one
// owner (single-parent tree) with at most one `@E` and one `*` row per key
// after env-filtering; keep only the level's winner (the `@E` row sorts
// first). Without this, a level holding both an `@E` map and a `*` map would
// deep-merge them instead of the `@E` shadowing the `*`.
rows.dedup_by_key(|r| r.depth);
// Collect the contiguous run of map-valued rows from the nearest. A
// tombstone or scalar/array boundary stops the run (and, if it's the
// nearest row, decides the result outright).
let mut maps: Vec<&Candidate> = Vec::new();
let mut boundary: Option<&Candidate> = None;
for r in &rows {
if r.is_tombstone {
boundary = Some(r);
break;
}
if r.value.is_object() {
maps.push(r);
} else {
boundary = Some(r);
break;
}
}
if let Some(first) = maps.first() {
// Map run wins; deep-merge farthest→nearest so nearest keys win.
let mut acc = Map::new();
for m in maps.iter().rev() {
if let Value::Object(obj) = &m.value {
deep_merge(&mut acc, obj);
}
}
let prov = Provenance {
owner_kind: first.owner_kind,
owner_id: first.owner_id,
depth: first.depth,
scope: first.scope.clone(),
merged_from: maps.iter().map(|m| (m.depth, m.scope.clone())).collect(),
};
return Some((Value::Object(acc), prov));
}
// No leading map run: the nearest row is the boundary.
match boundary {
// Nearest is a tombstone → key deleted.
Some(b) if b.is_tombstone => None,
// Nearest is a scalar/array → take it verbatim.
Some(b) => Some((
b.value.clone(),
Provenance {
owner_kind: b.owner_kind,
owner_id: b.owner_id,
depth: b.depth,
scope: b.scope.clone(),
merged_from: Vec::new(),
},
)),
// No rows at all (caller only passes non-empty groups).
None => None,
}
}
/// Resolve a full candidate set (mixed keys, any order) into the effective
/// config map + per-key provenance. Pure — unit-tested without Postgres.
#[must_use]
pub fn resolve(
candidates: Vec<Candidate>,
) -> (BTreeMap<String, Value>, BTreeMap<String, Provenance>) {
let mut by_key: BTreeMap<String, Vec<Candidate>> = BTreeMap::new();
for c in candidates {
by_key.entry(c.key.clone()).or_default().push(c);
}
let mut values = BTreeMap::new();
let mut provenance = BTreeMap::new();
for (key, rows) in by_key {
if let Some((v, p)) = resolve_one(rows) {
values.insert(key.clone(), v);
provenance.insert(key, p);
}
}
(values, provenance)
}
/// Pull every env-eligible `vars` candidate for `app_id`: the app's own
/// rows + every ancestor group's rows, filtered to `*` or the app's
/// environment, ordered nearest-first.
///
/// # Errors
/// Propagates sqlx errors.
pub async fn fetch_var_candidates(
pool: &PgPool,
app_id: AppId,
) -> Result<Vec<Candidate>, sqlx::Error> {
let sql = format!(
"{CHAIN_LEVELS_CTE} \
SELECT c.depth, \
CASE WHEN v.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
COALESCE(v.app_id, v.group_id) AS owner_id, \
v.environment_scope, v.key, v.value, v.is_tombstone \
FROM chain c \
JOIN vars v ON (v.app_id = c.app_owner OR v.group_id = c.group_owner) \
WHERE v.environment_scope = '*' OR v.environment_scope = c.app_env \
ORDER BY c.depth ASC"
);
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
.bind(app_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// The masked, resolved view of one inherited secret for `config/effective`:
/// which owner/level/scope supplies it — **never** the value.
#[derive(Debug, Clone)]
pub struct EffectiveSecretMeta {
pub name: String,
pub owner_kind: OwnerKind,
pub owner_id: Uuid,
pub scope: String,
pub depth: i32,
}
/// Resolve the *names* of every secret an app effectively sees — its own
/// plus every ancestor group's, env-filtered, nearest-wins per name. Returns
/// masked metadata only (owner/level/scope), so it's safe for an app-level
/// principal to read. `DISTINCT ON (name)` with the same ordering as the
/// per-name secret resolver guarantees the same winner.
///
/// # Errors
/// Propagates sqlx errors.
pub async fn fetch_effective_secret_meta(
pool: &PgPool,
app_id: AppId,
) -> Result<Vec<EffectiveSecretMeta>, sqlx::Error> {
let sql = format!(
"{CHAIN_LEVELS_CTE} \
SELECT DISTINCT ON (s.name) s.name, \
CASE WHEN s.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
COALESCE(s.app_id, s.group_id) AS owner_id, \
s.environment_scope, c.depth \
FROM chain c \
JOIN secrets s ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
WHERE s.environment_scope = '*' OR s.environment_scope = c.app_env \
ORDER BY s.name ASC, c.depth ASC, (s.environment_scope <> '*') DESC"
);
let rows: Vec<(String, String, Uuid, String, i32)> = sqlx::query_as(&sql)
.bind(app_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(
|(name, owner_kind, owner_id, scope, depth)| EffectiveSecretMeta {
name,
owner_kind: if owner_kind == "app" {
OwnerKind::App
} else {
OwnerKind::Group
},
owner_id,
scope,
depth,
},
)
.collect())
}
#[derive(sqlx::FromRow)]
struct VarCandidateRow {
depth: i32,
owner_kind: String,
owner_id: Uuid,
environment_scope: String,
key: String,
value: Value,
is_tombstone: bool,
}
impl From<VarCandidateRow> for Candidate {
fn from(r: VarCandidateRow) -> Self {
Self {
depth: r.depth,
owner_kind: if r.owner_kind == "app" {
OwnerKind::App
} else {
OwnerKind::Group
},
owner_id: r.owner_id,
scope: r.environment_scope,
key: r.key,
value: r.value,
is_tombstone: r.is_tombstone,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn cand(depth: i32, scope: &str, key: &str, value: Value) -> Candidate {
Candidate {
depth,
owner_kind: if depth == 0 {
OwnerKind::App
} else {
OwnerKind::Group
},
owner_id: Uuid::nil(),
scope: scope.into(),
key: key.into(),
value,
is_tombstone: false,
}
}
fn tomb(depth: i32, scope: &str, key: &str) -> Candidate {
Candidate {
is_tombstone: true,
value: Value::Null,
..cand(depth, scope, key, Value::Null)
}
}
#[test]
fn nearest_level_wins() {
// app (depth 0) overrides group (depth 2).
let (v, p) = resolve(vec![
cand(2, "*", "region", json!("eu")),
cand(0, "*", "region", json!("us")),
]);
assert_eq!(v["region"], json!("us"));
assert_eq!(p["region"].depth, 0);
}
#[test]
fn env_scoped_beats_agnostic_within_a_level() {
let (v, p) = resolve(vec![
cand(1, "*", "db_url", json!("default")),
cand(1, "staging", "db_url", json!("staging-db")),
]);
assert_eq!(v["db_url"], json!("staging-db"));
assert_eq!(p["db_url"].scope, "staging");
}
#[test]
fn proximity_beats_env_specificity_across_levels() {
// §3.2 deliberately-novel call: a leaf's `*` beats an ancestor's `@E`.
let (v, _) = resolve(vec![
cand(2, "production", "db_url", json!("anc-prod")),
cand(0, "*", "db_url", json!("leaf-default")),
]);
assert_eq!(v["db_url"], json!("leaf-default"));
}
#[test]
fn maps_deep_merge_nearest_wins() {
// ancestor sets {title, region}; leaf overrides title, adds locale.
let (v, p) = resolve(vec![
cand(2, "*", "ui", json!({"title": "Base", "region": "eu"})),
cand(0, "*", "ui", json!({"title": "Leaf", "locale": "en"})),
]);
assert_eq!(
v["ui"],
json!({"title": "Leaf", "region": "eu", "locale": "en"})
);
assert_eq!(p["ui"].merged_from.len(), 2);
}
#[test]
fn same_level_env_map_suppresses_agnostic_map_no_merge() {
// §3 step 1: within ONE level, `@E` is not a merge layer over `*` — it
// shadows it. A level holding both an `@staging` map and a `*` map must
// resolve to the `@staging` map alone, never a deep-merge of the two.
let (v, p) = resolve(vec![
cand(1, "*", "cfg", json!({"a": 1, "shared": "default"})),
cand(1, "staging", "cfg", json!({"shared": "staging"})),
]);
assert_eq!(v["cfg"], json!({"shared": "staging"}));
assert_eq!(p["cfg"].scope, "staging");
// Provenance must not list the suppressed `*` layer.
assert_eq!(p["cfg"].merged_from, vec![(1, "staging".to_string())]);
}
#[test]
fn same_level_env_map_then_ancestor_map_merges_without_agnostic_sibling() {
// The suppressed same-level `*` must also be invisible to cross-level
// merge: leaf `@staging` map merges onto the ancestor map, but the
// leaf's own `*` map (shadowed at its level) never contributes.
let (v, _) = resolve(vec![
cand(2, "*", "cfg", json!({"region": "eu", "tier": "base"})),
cand(0, "*", "cfg", json!({"leak": "should-not-appear"})),
cand(0, "staging", "cfg", json!({"tier": "leaf"})),
]);
assert_eq!(v["cfg"], json!({"region": "eu", "tier": "leaf"}));
assert!(!v["cfg"].as_object().unwrap().contains_key("leak"));
}
#[test]
fn nearer_scalar_replaces_whole_inherited_map() {
let (v, _) = resolve(vec![
cand(2, "*", "x", json!({"a": 1})),
cand(0, "*", "x", json!("scalar")),
]);
assert_eq!(v["x"], json!("scalar"));
}
#[test]
fn tombstone_suppresses_inherited_key() {
let (v, _) = resolve(vec![
cand(2, "*", "secret_flag", json!(true)),
tomb(0, "*", "secret_flag"),
]);
assert!(!v.contains_key("secret_flag"));
}
#[test]
fn json_null_is_a_value_not_a_deletion() {
let (v, _) = resolve(vec![cand(0, "*", "k", Value::Null)]);
assert!(v.contains_key("k"));
assert_eq!(v["k"], Value::Null);
}
#[test]
fn map_merge_stops_at_a_nearer_scalar_boundary() {
// nearest map, then a scalar, then a farther map: only the
// contiguous top map run merges; the scalar bounds it.
let (v, _) = resolve(vec![
cand(3, "*", "m", json!({"deep": 1})),
cand(2, "*", "m", json!("scalar-boundary")),
cand(0, "*", "m", json!({"near": 2})),
]);
// depth 0 map is the only one above the depth-2 scalar boundary.
assert_eq!(v["m"], json!({"near": 2}));
}
}

View File

@@ -119,7 +119,7 @@ async fn tick(pool: &PgPool, now: DateTime<Utc>) -> Result<usize, sqlx::Error> {
d.schedule, d.timezone, d.last_fired_at \
FROM cron_trigger_details d \
JOIN triggers t ON t.id = d.trigger_id \
WHERE t.enabled = TRUE \
WHERE t.enabled = TRUE AND t.app_id IS NOT NULL \
FOR UPDATE OF d SKIP LOCKED",
)
.fetch_all(&mut *tx)

View File

@@ -29,9 +29,10 @@ use picloud_executor_core::{
};
use picloud_orchestrator_core::{ExecutionGate, ExecutorClient};
use picloud_shared::{
DeadLetterId, ExecResponseSummary, ExecutionId, ExecutionLogSink, ExecutionSource,
AppId, DeadLetterId, ExecResponseSummary, ExecutionId, ExecutionLogSink, ExecutionSource,
HttpDispatchPayload, InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult,
RequestId, ScriptId, ScriptSandbox, TriggerEvent,
QueueMessageId, RequestId, Script, ScriptId, ScriptOwner, ScriptSandbox, TriggerEvent,
TriggerId,
};
use rand::Rng;
use uuid::Uuid;
@@ -65,6 +66,10 @@ pub struct Dispatcher {
/// v1.1.9. Reads `queue_messages` for the queue arm + the reclaim
/// task. None in tests / harnesses that don't exercise queues.
pub queue: Arc<dyn QueueRepo>,
/// §11.6 D3. The group shared-queue store. A materialized consumer of a
/// SHARED group queue template (`consumer.shared_group.is_some()`) claims
/// from here instead of the per-app `queue`.
pub group_queue: Arc<dyn crate::group_queue_repo::GroupQueueRepo>,
pub config: TriggerConfig,
/// Stable id for this dispatcher instance — written into
/// `outbox.claimed_by` for forensics. In MVP this is the host's
@@ -191,6 +196,32 @@ impl ExecLogContext {
}
impl Dispatcher {
/// Phase 4 isolation backstop: may `script` run under `app_id`'s context?
/// True when the script is owned by `app_id` directly, or by a group on
/// its ancestor chain (an inherited group script). App-owned is the
/// in-memory fast path — only a group-owned script pays the chain query,
/// and a query error fails closed (not invocable). This generalizes the
/// pre-Phase-4 same-app guard (`script.app_id == row.app_id`) to the
/// hierarchy without changing behavior for app-owned scripts.
async fn script_invocable(&self, script: &Script, app_id: AppId) -> bool {
if script.is_owned_by_app(app_id) {
return true;
}
if script.group_id.is_none() {
return false;
}
match self.scripts.is_invocable_by_app(script.id, app_id).await {
Ok(ok) => ok,
Err(e) => {
tracing::error!(
error = %e, script_id = %script.id, %app_id,
"chain-membership check failed; treating script as not invocable"
);
false
}
}
}
/// Spawn the dispatcher loop as a detached `tokio::task`. Also
/// spawns the v1.1.9 queue visibility-timeout reclaim task. Both
/// run for the process lifetime; returned `JoinHandle`s are
@@ -199,6 +230,7 @@ impl Dispatcher {
// Reclaim task: independent cadence (default 30s) so it doesn't
// contend with the per-100ms dispatcher tick.
let reclaim_queue = self.queue.clone();
let reclaim_group_queue = self.group_queue.clone();
let reclaim_interval =
Duration::from_millis(u64::from(self.config.queue_reclaim_interval_ms));
tokio::spawn(async move {
@@ -211,6 +243,14 @@ impl Dispatcher {
Ok(n) => tracing::info!(reclaimed = n, "queue visibility-timeout reclaim"),
Err(e) => tracing::warn!(?e, "queue reclaim task errored"),
}
// §11.6 D3: the group shared-queue store has the same reclaim.
match reclaim_group_queue.reclaim_visibility_timeouts().await {
Ok(0) => {}
Ok(n) => {
tracing::info!(reclaimed = n, "group-queue visibility-timeout reclaim");
}
Err(e) => tracing::warn!(?e, "group-queue reclaim task errored"),
}
}
});
@@ -287,6 +327,126 @@ impl Dispatcher {
Ok(())
}
// §11.6 D3: route the four queue store operations to the per-app store or
// the group shared store, based on whether this consumer was materialized
// from a SHARED group queue template (`consumer.shared_group`). A group
// claim is normalized to a `ClaimedMessage` under the CONSUMING app so the
// rest of the queue arm (handler dispatch, event, logging) is unchanged.
async fn q_claim(&self, c: &ActiveQueueConsumer) -> Result<Option<ClaimedMessage>, String> {
match c.shared_group {
Some(g) => Ok(self
.group_queue
.claim(g, &c.queue_name)
.await
.map_err(|e| e.to_string())?
.map(|m| ClaimedMessage {
id: m.id,
app_id: c.app_id,
queue_name: m.collection,
payload: m.payload,
enqueued_at: m.enqueued_at,
attempt: m.attempt,
max_attempts: m.max_attempts,
claim_token: m.claim_token,
})),
None => self
.queue
.claim(c.app_id, &c.queue_name)
.await
.map_err(|e| e.to_string()),
}
}
async fn q_ack(
&self,
c: &ActiveQueueConsumer,
id: QueueMessageId,
token: uuid::Uuid,
) -> Result<bool, String> {
match c.shared_group {
Some(_) => self
.group_queue
.ack(id, token)
.await
.map_err(|e| e.to_string()),
None => self.queue.ack(id, token).await.map_err(|e| e.to_string()),
}
}
async fn q_nack(
&self,
c: &ActiveQueueConsumer,
id: QueueMessageId,
token: uuid::Uuid,
delay: chrono::Duration,
) -> Result<bool, String> {
match c.shared_group {
Some(_) => self
.group_queue
.nack(id, token, delay)
.await
.map_err(|e| e.to_string()),
None => self
.queue
.nack(id, token, delay)
.await
.map_err(|e| e.to_string()),
}
}
/// Terminal disposition of a message that can't be processed (script
/// missing / cross-app / exhausted). Per-app → dead-letter (+ the caller
/// fans out `dead_letter` triggers). Group shared queue → drop the row
/// (no group dead-letter store yet — documented D3 deferral) + warn.
/// Returns the dead-letter id only for the per-app path (drives fan-out).
async fn q_terminal(
&self,
c: &ActiveQueueConsumer,
claimed: &ClaimedMessage,
trigger_id: Option<TriggerId>,
script_id: Option<ScriptId>,
reason: &str,
) -> Option<DeadLetterId> {
match c.shared_group {
Some(_) => {
if let Err(e) = self
.group_queue
.drop_exhausted(claimed.id, claimed.claim_token)
.await
{
tracing::warn!(?e, "shared-queue drop failed");
}
tracing::warn!(
reason,
queue = %claimed.queue_name,
"shared-queue message dropped (no group dead-letter store yet)"
);
None
}
None => match self
.queue
.dead_letter(
claimed.id,
claimed.claim_token,
claimed.app_id,
&claimed.queue_name,
trigger_id,
script_id,
claimed.attempt,
claimed.enqueued_at,
reason,
)
.await
{
Ok(dl_id) => Some(dl_id),
Err(e) => {
tracing::error!(?e, "queue dead-letter write failed");
None
}
},
}
}
#[allow(clippy::too_many_lines)]
async fn dispatch_one_queue(
&self,
@@ -294,10 +454,9 @@ impl Dispatcher {
) -> Result<(), DispatcherError> {
// Atomic claim — None → nothing pending right now for this queue.
let Some(claimed) = self
.queue
.claim(consumer.app_id, &consumer.queue_name)
.q_claim(consumer)
.await
.map_err(|e| DispatcherError::Outbox(e.to_string()))?
.map_err(DispatcherError::Outbox)?
else {
return Ok(());
};
@@ -307,8 +466,8 @@ impl Dispatcher {
// outbox arm.
let Ok(permit) = self.gate.try_acquire() else {
let _ = self
.queue
.nack(
.q_nack(
consumer,
claimed.id,
claimed.claim_token,
chrono::Duration::milliseconds(100),
@@ -333,16 +492,11 @@ impl Dispatcher {
Ok(None) => {
tracing::warn!(script_id = %consumer.script_id, "queue trigger script missing; dead-lettering");
let _ = self
.queue
.dead_letter(
claimed.id,
claimed.claim_token,
claimed.app_id,
&claimed.queue_name,
.q_terminal(
consumer,
&claimed,
Some(consumer.trigger_id),
Some(consumer.script_id),
claimed.attempt,
claimed.enqueued_at,
"queue trigger script not found",
)
.await;
@@ -359,24 +513,24 @@ impl Dispatcher {
// existing consumers. Dead-letter the message so the misfire is
// observable rather than executing one app's script under
// another app's SdkCallCx.
if script.app_id != claimed.app_id {
// Phase 4: the target must be invocable in the claimed message's app
// context — owned by that app or an inherited group script on its
// chain. App-owned scripts take the in-memory fast path; only a group
// script pays the chain query, and a non-member fails closed
// (dead-lettered, not silently run under another app's SdkCallCx).
if !self.script_invocable(&script, claimed.app_id).await {
tracing::error!(
script_app = %script.app_id,
script_app = ?script.app_id,
claim_app = %claimed.app_id,
script_id = %consumer.script_id,
"queue consumer script belongs to a different app; dead-lettering"
);
let _ = self
.queue
.dead_letter(
claimed.id,
claimed.claim_token,
claimed.app_id,
&claimed.queue_name,
.q_terminal(
consumer,
&claimed,
Some(consumer.trigger_id),
Some(consumer.script_id),
claimed.attempt,
claimed.enqueued_at,
"queue consumer target belongs to a different app",
)
.await;
@@ -403,8 +557,8 @@ impl Dispatcher {
"queue consumer script disabled at fire time; releasing claim"
);
if let Err(e) = self
.queue
.nack(
.q_nack(
consumer,
claimed.id,
claimed.claim_token,
chrono::Duration::seconds(1),
@@ -440,6 +594,7 @@ impl Dispatcher {
rest: String::new(),
sandbox_overrides: script.sandbox,
app_id: claimed.app_id,
script_owner: script.owner(),
principal: Some(principal),
trigger_depth: 0,
root_execution_id: execution_id,
@@ -483,7 +638,7 @@ impl Dispatcher {
match outcome {
Ok(_) => {
// Auto-ack on success.
if let Err(e) = self.queue.ack(claimed.id, claimed.claim_token).await {
if let Err(e) = self.q_ack(consumer, claimed.id, claimed.claim_token).await {
tracing::warn!(?e, "queue ack failed");
}
}
@@ -510,8 +665,7 @@ impl Dispatcher {
);
let delay = chrono::Duration::milliseconds(i64::from(delay_ms));
if let Err(e) = self
.queue
.nack(claimed.id, claimed.claim_token, delay)
.q_nack(consumer, claimed.id, claimed.claim_token, delay)
.await
{
tracing::warn!(?e, "queue nack failed");
@@ -526,27 +680,18 @@ impl Dispatcher {
// same way here.
let now = Utc::now();
let last_error = err.to_string();
let dl_id = match self
.queue
.dead_letter(
claimed.id,
claimed.claim_token,
claimed.app_id,
&claimed.queue_name,
// Per-app → dead-letter (+ fan out below). Shared group queue → dropped
// inside q_terminal (no group dead-letter store yet), returns None so
// the fan-out is skipped.
let dl_id = self
.q_terminal(
consumer,
claimed,
Some(consumer.trigger_id),
Some(consumer.script_id),
claimed.attempt,
claimed.enqueued_at,
&last_error,
)
.await
{
Ok(dl_id) => Some(dl_id),
Err(e) => {
tracing::error!(?e, "queue dead-letter write failed");
None
}
};
.await;
if let Some(dead_letter_id) = dl_id {
let original = TriggerEvent::Queue {
queue_name: claimed.queue_name.clone(),
@@ -786,7 +931,9 @@ impl Dispatcher {
// boundary. Not reachable via the trigger-create or `apply` paths
// (both resolve the script within the app's own scope), so this is
// the runtime backstop the other arms already carry.
if script.app_id != row.app_id {
// Phase 4: invocable in the outbox row's app context (app-owned or an
// inherited group script on the chain); a non-member fails closed.
if !self.script_invocable(&script, row.app_id).await {
return Err(DispatcherError::ResolveTrigger(
"trigger outbox target belongs to a different app".into(),
));
@@ -797,6 +944,7 @@ impl Dispatcher {
is_dead_letter_handler: matches!(trigger.kind, TriggerKind::DeadLetter),
active: trigger.enabled && script.enabled,
script_id: script.id,
script_owner: script.owner(),
script_source: script.source,
script_name: script.name,
script_updated_at: script.updated_at,
@@ -839,6 +987,7 @@ impl Dispatcher {
rest: String::new(),
sandbox_overrides: resolved.sandbox_overrides,
app_id: row.app_id,
script_owner: resolved.script_owner,
principal: Some(principal),
trigger_depth: row.trigger_depth,
root_execution_id: row.root_execution_id.unwrap_or(execution_id),
@@ -876,7 +1025,8 @@ impl Dispatcher {
// build_invoke_request and dispatch_one_queue. A hand-edited
// outbox row could otherwise execute one app's script under
// another app's SdkCallCx.
if script.app_id != row.app_id {
// Phase 4: invocable in this app's context (see queue/trigger arms).
if !self.script_invocable(&script, row.app_id).await {
return Err(DispatcherError::ResolveTrigger(
"http outbox target belongs to a different app".into(),
));
@@ -901,6 +1051,7 @@ impl Dispatcher {
rest: payload.rest,
sandbox_overrides: script.sandbox,
app_id: row.app_id,
script_owner: script.owner(),
// HTTP outbox rows don't run as the trigger registrant —
// they run with no principal (public ingress) or the
// attached one (origin_principal forensic field is not
@@ -919,6 +1070,7 @@ impl Dispatcher {
// enqueue is dropped at fire time by the post-match active check.
active: script.enabled,
script_id,
script_owner: script.owner(),
script_source: script.source,
script_name: payload.script_name,
script_updated_at: script.updated_at,
@@ -969,7 +1121,8 @@ impl Dispatcher {
})?;
// Same-app guard — the script could have been re-assigned or
// the row could have been hand-edited. Reject mismatches.
if script.app_id != row.app_id {
// Phase 4: invocable in this app's context (app-owned or inherited).
if !self.script_invocable(&script, row.app_id).await {
return Err(DispatcherError::ResolveTrigger(
"invoke target belongs to a different app".into(),
));
@@ -1000,6 +1153,7 @@ impl Dispatcher {
rest: String::new(),
sandbox_overrides: script.sandbox,
app_id: row.app_id,
script_owner: script.owner(),
// invoke_async runs with no principal — same as HTTP outbox.
principal: None,
trigger_depth,
@@ -1019,6 +1173,7 @@ impl Dispatcher {
// enqueue is dropped at fire time by the post-match active check.
active: script.enabled,
script_id: script.id,
script_owner: script.owner(),
script_source: script.source,
script_name: script.name,
script_updated_at: script.updated_at,
@@ -1321,6 +1476,10 @@ pub struct ResolvedTrigger {
pub script_id: ScriptId,
pub script_source: String,
pub script_name: String,
/// Phase 4b: the resolved script's defining node — the lexical origin
/// for its `import`s (§5.5). `None` only for a corrupt owner row; the
/// executor then falls back to `App(app_id)`.
pub script_owner: Option<ScriptOwner>,
/// v1.1.3: freshness comparator for the orchestrator's top-level
/// script cache. The dispatcher hands `(script_id, updated_at)`
/// in alongside the source so cached ASTs can be reused across
@@ -1466,6 +1625,72 @@ fn apply_jitter(raw: u32, pct: u32) -> u32 {
mod tests {
use super::*;
/// §11.6 D3: a do-nothing group-queue store for dispatcher tests that don't
/// exercise SHARED queues (every consumer has `shared_group: None`, so these
/// methods are never reached — they just satisfy the struct field).
struct NoopGroupQueue;
#[async_trait::async_trait]
impl crate::group_queue_repo::GroupQueueRepo for NoopGroupQueue {
async fn enqueue(
&self,
_msg: crate::group_queue_repo::NewGroupQueueMessage,
) -> Result<QueueMessageId, crate::group_queue_repo::GroupQueueRepoError> {
unreachable!("shared queue not exercised")
}
async fn claim(
&self,
_group_id: picloud_shared::GroupId,
_collection: &str,
) -> Result<
Option<crate::group_queue_repo::ClaimedGroupMessage>,
crate::group_queue_repo::GroupQueueRepoError,
> {
Ok(None)
}
async fn ack(
&self,
_id: QueueMessageId,
_token: Uuid,
) -> Result<bool, crate::group_queue_repo::GroupQueueRepoError> {
Ok(false)
}
async fn nack(
&self,
_id: QueueMessageId,
_token: Uuid,
_delay: chrono::Duration,
) -> Result<bool, crate::group_queue_repo::GroupQueueRepoError> {
Ok(false)
}
async fn drop_exhausted(
&self,
_id: QueueMessageId,
_token: Uuid,
) -> Result<bool, crate::group_queue_repo::GroupQueueRepoError> {
Ok(false)
}
async fn reclaim_visibility_timeouts(
&self,
) -> Result<u64, crate::group_queue_repo::GroupQueueRepoError> {
Ok(0)
}
async fn depth(
&self,
_group_id: picloud_shared::GroupId,
_collection: &str,
) -> Result<u64, crate::group_queue_repo::GroupQueueRepoError> {
Ok(0)
}
async fn depth_pending(
&self,
_group_id: picloud_shared::GroupId,
_collection: &str,
) -> Result<u64, crate::group_queue_repo::GroupQueueRepoError> {
Ok(0)
}
}
#[test]
fn exponential_backoff_doubles_per_attempt() {
// No jitter (pct=0) for a deterministic check.
@@ -1640,6 +1865,20 @@ mod tests {
) -> Result<Option<Script>, ScriptRepositoryError> {
unimplemented!("not used by this test")
}
async fn get_by_name_inherited(
&self,
_app_id: AppId,
_name: &str,
) -> Result<Option<Script>, ScriptRepositoryError> {
unimplemented!("not used by this test")
}
async fn is_invocable_by_app(
&self,
_script_id: ScriptId,
app_id: AppId,
) -> Result<bool, ScriptRepositoryError> {
Ok(self.script.is_owned_by_app(app_id))
}
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
unimplemented!("not used by this test")
}
@@ -1649,6 +1888,12 @@ mod tests {
) -> Result<Vec<Script>, ScriptRepositoryError> {
unimplemented!("not used by this test")
}
async fn list_for_group(
&self,
_group_id: picloud_shared::GroupId,
) -> Result<Vec<Script>, ScriptRepositoryError> {
unimplemented!("not used by this test")
}
async fn list_for_user(
&self,
_user_id: AdminUserId,
@@ -1712,14 +1957,14 @@ mod tests {
}
async fn ack(
&self,
_message_id: picloud_shared::QueueMessageId,
_message_id: QueueMessageId,
_claim_token: Uuid,
) -> Result<bool, crate::queue_repo::QueueRepoError> {
unimplemented!("not used by this test")
}
async fn nack(
&self,
_message_id: picloud_shared::QueueMessageId,
_message_id: QueueMessageId,
_claim_token: Uuid,
_retry_delay: chrono::Duration,
) -> Result<bool, crate::queue_repo::QueueRepoError> {
@@ -1754,7 +1999,7 @@ mod tests {
#[allow(clippy::too_many_arguments)]
async fn dead_letter(
&self,
_message_id: picloud_shared::QueueMessageId,
_message_id: QueueMessageId,
_claim_token: Uuid,
_app_id: AppId,
_queue_name: &str,
@@ -2097,7 +2342,8 @@ mod tests {
pub(super) fn disabled_script(app_id: AppId) -> Script {
Script {
id: ScriptId::new(),
app_id,
app_id: Some(app_id),
group_id: None,
name: "worker".into(),
description: None,
version: 1,
@@ -2138,6 +2384,7 @@ mod tests {
retry_backoff: BackoffShape::Exponential,
retry_base_ms: 1000,
registered_by_principal: AdminUserId::new(),
shared_group: None,
};
let nacked = Arc::new(AtomicBool::new(false));
@@ -2162,6 +2409,7 @@ mod tests {
claimed,
nacked: nacked.clone(),
}),
group_queue: Arc::new(NoopGroupQueue),
config: TriggerConfig::from_env(),
instance_id: "test-instance".into(),
};
@@ -2243,14 +2491,14 @@ mod tests {
}
async fn ack(
&self,
_message_id: picloud_shared::QueueMessageId,
_message_id: QueueMessageId,
_claim_token: Uuid,
) -> Result<bool, crate::queue_repo::QueueRepoError> {
unimplemented!("not used by this test")
}
async fn nack(
&self,
_message_id: picloud_shared::QueueMessageId,
_message_id: QueueMessageId,
_claim_token: Uuid,
_retry_delay: chrono::Duration,
) -> Result<bool, crate::queue_repo::QueueRepoError> {
@@ -2287,7 +2535,7 @@ mod tests {
#[allow(clippy::too_many_arguments)]
async fn dead_letter(
&self,
_message_id: picloud_shared::QueueMessageId,
_message_id: QueueMessageId,
_claim_token: Uuid,
_app_id: AppId,
_queue_name: &str,
@@ -2394,6 +2642,7 @@ mod tests {
log_sink: Arc::new(UnusedLogSink),
inbox: Arc::new(UnusedInbox),
queue: Arc::new(UnusedQueue),
group_queue: Arc::new(NoopGroupQueue),
config: TriggerConfig::from_env(),
instance_id: "test-instance".into(),
};

View File

@@ -167,7 +167,7 @@ impl DocsRepo for PostgresDocsRepo {
collection: &str,
filter: &DocsFilter,
) -> Result<Vec<DocRow>, DocsRepoError> {
let mut qb = build_find_query(app_id, collection, filter);
let mut qb = build_find_query("docs", "app_id", app_id.into_inner(), collection, filter);
let rows = qb.build().fetch_all(&self.pool).await?;
rows.into_iter().map(row_to_doc).collect()
}
@@ -281,7 +281,7 @@ impl DocsRepo for PostgresDocsRepo {
}
}
fn row_to_doc(row: PgRow) -> Result<DocRow, DocsRepoError> {
pub(crate) fn row_to_doc(row: PgRow) -> Result<DocRow, DocsRepoError> {
Ok(DocRow {
id: row.try_get("id")?,
data: row.try_get("data")?,
@@ -316,14 +316,22 @@ fn decode_cursor(cursor: &str) -> Result<Uuid, DocsRepoError> {
// **No user input ever lands in the SQL text unparameterized.**
// ----------------------------------------------------------------------------
fn build_find_query<'a>(
app_id: AppId,
/// Build the `find` query for a docs store. `table` and `owner_col` are
/// compile-time literals (`"docs"`/`"app_id"` for app docs, `"group_docs"`/
/// `"group_id"` for §11.6 group-shared docs) — never user input, so
/// interpolating them is injection-safe. `pub(crate)` so the group-docs repo
/// reuses this single source for the (security-sensitive) query SQL.
pub(crate) fn build_find_query<'a>(
table: &'static str,
owner_col: &'static str,
owner_id: uuid::Uuid,
collection: &'a str,
filter: &'a DocsFilter,
) -> QueryBuilder<'a, Postgres> {
let mut qb =
QueryBuilder::new("SELECT id, data, created_at, updated_at FROM docs WHERE app_id = ");
qb.push_bind(app_id.into_inner());
let mut qb = QueryBuilder::new(format!(
"SELECT id, data, created_at, updated_at FROM {table} WHERE {owner_col} = "
));
qb.push_bind(owner_id);
qb.push(" AND collection = ");
qb.push_bind(collection);
@@ -448,7 +456,13 @@ mod sql_shape_tests {
fn sql_for(filter_json: serde_json::Value) -> String {
let filter = parse_filter(&filter_json).unwrap();
let qb = build_find_query(AppId::new(), "users", &filter);
let qb = build_find_query(
"docs",
"app_id",
AppId::new().into_inner(),
"users",
&filter,
);
qb.sql().to_string()
}

View File

@@ -0,0 +1,117 @@
//! Extension-point markers (§5.5) — the `extension_points` table (0051).
//!
//! An extension point is a pure marker `(owner, name)` declaring that a module
//! name is one descendants may provide/override (the import resolver then
//! resolves it dynamically against the inheriting app — see
//! [`crate::module_source`]). This module holds the read + transactional-write
//! helpers; it mirrors the var/secret tx-function style (free functions over a
//! `&PgPool` / `&mut Transaction`), keyed by the shared [`ScriptOwner`].
use picloud_shared::{AppId, ScriptOwner};
use sqlx::{PgPool, Postgres, Transaction};
use crate::config_resolver::CHAIN_LEVELS_CTE;
/// List the EP names declared **directly at** `owner` (not inherited),
/// case-insensitively sorted. Used by `load_current` (apply diff), the
/// no-provider check, and the read-only `extension-points ls`.
pub async fn list_for_owner(pool: &PgPool, owner: ScriptOwner) -> Result<Vec<String>, sqlx::Error> {
let rows: Vec<(String,)> = match owner {
ScriptOwner::App(a) => {
sqlx::query_as(
"SELECT name FROM extension_points WHERE app_id = $1 ORDER BY LOWER(name)",
)
.bind(a.into_inner())
.fetch_all(pool)
.await?
}
ScriptOwner::Group(g) => {
sqlx::query_as(
"SELECT name FROM extension_points WHERE group_id = $1 ORDER BY LOWER(name)",
)
.bind(g.into_inner())
.fetch_all(pool)
.await?
}
};
Ok(rows.into_iter().map(|(n,)| n).collect())
}
/// All distinct EP names **visible to an app** — declared at the app or any
/// ancestor group, walking `apps.group_id → groups.parent_id`. Used by the
/// no-provider plan check and the read-only `extension-points ls --app`.
pub async fn list_on_app_chain(pool: &PgPool, app_id: AppId) -> Result<Vec<String>, sqlx::Error> {
let rows: Vec<(String,)> = sqlx::query_as(&format!(
"{CHAIN_LEVELS_CTE} \
SELECT DISTINCT e.name FROM extension_points e \
JOIN chain c ON (e.app_id = c.app_owner OR e.group_id = c.group_owner) \
ORDER BY e.name",
))
.bind(app_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|(n,)| n).collect())
}
/// Insert an EP marker at `owner`, in the apply transaction. Idempotent: a
/// re-apply of an already-declared name is a no-op (`ON CONFLICT DO NOTHING`),
/// so the marker survives without a spurious version bump.
pub async fn insert_extension_point_tx(
tx: &mut Transaction<'_, Postgres>,
owner: ScriptOwner,
name: &str,
) -> Result<(), sqlx::Error> {
match owner {
ScriptOwner::App(a) => {
sqlx::query(
"INSERT INTO extension_points (app_id, name) VALUES ($1, $2) \
ON CONFLICT (app_id, LOWER(name)) WHERE app_id IS NOT NULL DO NOTHING",
)
.bind(a.into_inner())
.bind(name)
.execute(&mut **tx)
.await?;
}
ScriptOwner::Group(g) => {
sqlx::query(
"INSERT INTO extension_points (group_id, name) VALUES ($1, $2) \
ON CONFLICT (group_id, LOWER(name)) WHERE group_id IS NOT NULL DO NOTHING",
)
.bind(g.into_inner())
.bind(name)
.execute(&mut **tx)
.await?;
}
}
Ok(())
}
/// Delete an EP marker at `owner` (case-insensitive), in the apply transaction.
/// Used by `--prune` when the manifest stops declaring a name.
pub async fn delete_extension_point_tx(
tx: &mut Transaction<'_, Postgres>,
owner: ScriptOwner,
name: &str,
) -> Result<(), sqlx::Error> {
match owner {
ScriptOwner::App(a) => {
sqlx::query(
"DELETE FROM extension_points WHERE app_id = $1 AND LOWER(name) = LOWER($2)",
)
.bind(a.into_inner())
.bind(name)
.execute(&mut **tx)
.await?;
}
ScriptOwner::Group(g) => {
sqlx::query(
"DELETE FROM extension_points WHERE group_id = $1 AND LOWER(name) = LOWER($2)",
)
.bind(g.into_inner())
.bind(name)
.execute(&mut **tx)
.await?;
}
}
Ok(())
}

View File

@@ -211,7 +211,7 @@ impl FsFilesRepo {
}
fn final_path(&self, app_id: AppId, collection: &str, id: Uuid) -> PathBuf {
final_path_at(&self.config.root, app_id, collection, id)
final_path_at(&self.config.root, &app_owner_dir(app_id), collection, id)
}
fn write_atomic(
@@ -221,29 +221,53 @@ impl FsFilesRepo {
id: Uuid,
bytes: &[u8],
) -> Result<String, FilesRepoError> {
write_atomic_at(&self.config.root, app_id, collection, id, bytes)
write_atomic_at(
&self.config.root,
&app_owner_dir(app_id),
collection,
id,
bytes,
)
}
}
fn shard_dir_at(root: &Path, app_id: AppId, collection: &str, id_str: &str) -> PathBuf {
/// The owner-relative subdirectory of an **app**'s blobs under `<root>/files/`:
/// just `<app_id>`. Group-shared blobs (§11.6) shard at `groups/<group_id>`
/// instead (see `group_files_repo::group_owner_dir`) — a disjoint subtree under
/// the same `files/` base, so the orphan sweeper covers both with one walk.
pub(crate) fn app_owner_dir(app_id: AppId) -> PathBuf {
PathBuf::from(app_id.into_inner().to_string())
}
/// `<root>/files/<owner_rel>/<collection>/<id[0:2]>/`. `owner_rel` is built
/// by the caller from a server-generated id (`<app_id>` or `groups/<group_id>`)
/// — never user input — and `collection` is path-guarded one layer up, so no
/// component can escape the `files/` base.
pub(crate) fn shard_dir_at(
root: &Path,
owner_rel: &Path,
collection: &str,
id_str: &str,
) -> PathBuf {
root.join("files")
.join(app_id.into_inner().to_string())
.join(owner_rel)
.join(collection)
.join(&id_str[..2])
}
fn final_path_at(root: &Path, app_id: AppId, collection: &str, id: Uuid) -> PathBuf {
pub(crate) fn final_path_at(root: &Path, owner_rel: &Path, collection: &str, id: Uuid) -> PathBuf {
let id_str = id.to_string();
shard_dir_at(root, app_id, collection, &id_str).join(&id_str)
shard_dir_at(root, owner_rel, collection, &id_str).join(&id_str)
}
/// Steps 26 of the atomic-write protocol. Returns the lowercase hex
/// SHA-256 of the bytes (computed in a single pass over the in-memory
/// buffer — the file is never re-read). Free function so the fs
/// mechanics are unit-testable without a Postgres pool.
fn write_atomic_at(
/// buffer — the file is never re-read). `pub(crate)` + owner-relative so the
/// app and group-shared (§11.6) repos share this single source for the
/// security-sensitive write+checksum mechanics, unit-testable without a pool.
pub(crate) fn write_atomic_at(
root: &Path,
app_id: AppId,
owner_rel: &Path,
collection: &str,
id: Uuid,
bytes: &[u8],
@@ -251,7 +275,7 @@ fn write_atomic_at(
use std::io::Write as _;
let id_str = id.to_string();
let dir = shard_dir_at(root, app_id, collection, &id_str);
let dir = shard_dir_at(root, owner_rel, collection, &id_str);
create_dir_all_secure(&dir)?;
// Single-pass checksum over the in-memory buffer.
@@ -278,15 +302,16 @@ fn write_atomic_at(
/// Read + checksum-verify the bytes at the given path-set. Free
/// function mirror of the `get` read path. Returns `Corrupted` when the
/// bytes are missing or don't match `expected_checksum`.
fn read_verify_at(
/// bytes are missing or don't match `expected_checksum`. `pub(crate)` +
/// owner-relative — shared with the group-files (§11.6) repo.
pub(crate) fn read_verify_at(
root: &Path,
app_id: AppId,
owner_rel: &Path,
collection: &str,
id: Uuid,
expected_checksum: &str,
) -> Result<Vec<u8>, FilesRepoError> {
let path = final_path_at(root, app_id, collection, id);
let path = final_path_at(root, owner_rel, collection, id);
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
@@ -383,7 +408,13 @@ impl FilesRepo for FsFilesRepo {
let Some((stored_checksum,)) = row else {
return Ok(None);
};
let bytes = read_verify_at(&self.config.root, app_id, collection, id, &stored_checksum)?;
let bytes = read_verify_at(
&self.config.root,
&app_owner_dir(app_id),
collection,
id,
&stored_checksum,
)?;
Ok(Some(bytes))
}
@@ -555,11 +586,11 @@ fn hex_lower(bytes: &[u8]) -> String {
s
}
fn encode_cursor(last_id: Uuid) -> String {
pub(crate) fn encode_cursor(last_id: Uuid) -> String {
URL_SAFE_NO_PAD.encode(last_id.to_string().as_bytes())
}
fn decode_cursor(cursor: &str) -> Result<Uuid, FilesRepoError> {
pub(crate) fn decode_cursor(cursor: &str) -> Result<Uuid, FilesRepoError> {
let bytes = URL_SAFE_NO_PAD
.decode(cursor)
.map_err(|_| FilesRepoError::InvalidCursor)?;
@@ -672,13 +703,13 @@ mod tests {
let id = Uuid::new_v4();
let bytes = b"hello picloud files".to_vec();
let checksum = write_atomic_at(&root, app, "avatars", id, &bytes).unwrap();
let checksum = write_atomic_at(&root, &app_owner_dir(app), "avatars", id, &bytes).unwrap();
// Single-pass checksum matches an independent hash of the bytes.
let mut h = Sha256::new();
h.update(&bytes);
assert_eq!(checksum, hex_lower(&h.finalize()));
let read = read_verify_at(&root, app, "avatars", id, &checksum).unwrap();
let read = read_verify_at(&root, &app_owner_dir(app), "avatars", id, &checksum).unwrap();
assert_eq!(read, bytes);
std::fs::remove_dir_all(&root).ok();
@@ -689,13 +720,13 @@ mod tests {
let root = unique_tmp_root();
let app = AppId::new();
let id = Uuid::new_v4();
let checksum = write_atomic_at(&root, app, "c", id, b"original").unwrap();
let checksum = write_atomic_at(&root, &app_owner_dir(app), "c", id, b"original").unwrap();
// Mutate the bytes behind the repo's back.
let path = final_path_at(&root, app, "c", id);
let path = final_path_at(&root, &app_owner_dir(app), "c", id);
std::fs::write(&path, b"tampered").unwrap();
let err = read_verify_at(&root, app, "c", id, &checksum).unwrap_err();
let err = read_verify_at(&root, &app_owner_dir(app), "c", id, &checksum).unwrap_err();
assert!(matches!(err, FilesRepoError::Corrupted));
std::fs::remove_dir_all(&root).ok();
@@ -707,7 +738,7 @@ mod tests {
let app = AppId::new();
let id = Uuid::new_v4();
// No write — the file never existed.
let err = read_verify_at(&root, app, "c", id, "deadbeef").unwrap_err();
let err = read_verify_at(&root, &app_owner_dir(app), "c", id, "deadbeef").unwrap_err();
assert!(matches!(err, FilesRepoError::Corrupted));
std::fs::remove_dir_all(&root).ok();
}
@@ -717,10 +748,10 @@ mod tests {
let root = unique_tmp_root();
let app = AppId::new();
let id = Uuid::new_v4();
write_atomic_at(&root, app, "c", id, b"data").unwrap();
write_atomic_at(&root, &app_owner_dir(app), "c", id, b"data").unwrap();
let id_str = id.to_string();
let dir = shard_dir_at(&root, app, "c", &id_str);
let dir = shard_dir_at(&root, &app_owner_dir(app), "c", &id_str);
let entries: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(Result::ok)
@@ -739,7 +770,7 @@ mod tests {
let app = AppId::new();
let id = Uuid::new_v4();
let id_str = id.to_string();
let path = final_path_at(&root, app, "col", id);
let path = final_path_at(&root, &app_owner_dir(app), "col", id);
let shard = &id_str[..2];
assert!(path
.to_string_lossy()
@@ -753,9 +784,9 @@ mod tests {
let root = unique_tmp_root();
let app = AppId::new();
let id = Uuid::new_v4();
write_atomic_at(&root, app, "c", id, b"data").unwrap();
write_atomic_at(&root, &app_owner_dir(app), "c", id, b"data").unwrap();
let id_str = id.to_string();
let dir = shard_dir_at(&root, app, "c", &id_str);
let dir = shard_dir_at(&root, &app_owner_dir(app), "c", &id_str);
let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700, "shard dir should be 0o700");
std::fs::remove_dir_all(&root).ok();

View File

@@ -165,6 +165,23 @@ mod tests {
assert!(tmp.exists());
}
#[test]
fn sweeps_group_shared_tmp_files() {
// §11.6 group-shared files shard under `files/groups/<group_id>/...`,
// a disjoint subtree from the per-app `files/<app_id>/...`. The walk is
// recursive from `<root>/files/`, so an orphan there is reaped without
// any group-specific sweeper change — this pins that "covered for free"
// guarantee against a future refactor.
let root = tmp_root();
let group_shard = root.join("files/groups/6f693a33-9ae5-485b-804e-191e9fd33524/assets/ab");
std::fs::create_dir_all(&group_shard).unwrap();
let tmp = group_shard.join("uuid.tmp.123-0");
touch(&tmp);
let stats = sweep_orphan_tmp_files(&root, Duration::ZERO);
assert_eq!(stats.files_deleted, 1, "group-shared orphan must be reaped");
assert!(!tmp.exists());
}
#[test]
fn keeps_non_tmp_files() {
let root = tmp_root();

View File

@@ -0,0 +1,313 @@
//! `/api/v1/admin/groups/{id}/{kv,docs,files}*` — read-only operator inspection
//! of a group's §11.6 SHARED collections (M4). Mirrors the per-app `kv_api` /
//! `files_api` admin surface for groups so `pic {kv,docs,files} ls --group` (and
//! a future dashboard tab) can browse shared data without a script.
//!
//! **Read-only by design** — shared writes go through the SDK
//! (`kv::shared_collection(...)` etc.), which run the reads-open / writes-authed
//! authz + fire `shared = true` triggers; an admin write would bypass both. The
//! deferral (operator write/delete on shared blobs) stands.
//!
//! Capabilities: `GroupKvRead` / `GroupDocsRead` / `GroupFilesRead`, resolved
//! against the group loaded from the path (the same tier the SDK read path uses).
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::header::{CONTENT_LENGTH, CONTENT_TYPE};
use axum::response::{IntoResponse, Json, Response};
use axum::routing::get;
use axum::{Extension, Router};
use picloud_shared::{GroupId, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::group_docs_repo::GroupDocsRepo;
use crate::group_files_repo::GroupFilesRepo;
use crate::group_kv_repo::GroupKvRepo;
use crate::group_repo::GroupRepository;
#[derive(Clone)]
pub struct GroupBlobsState {
pub kv: Arc<dyn GroupKvRepo>,
pub docs: Arc<dyn GroupDocsRepo>,
pub files: Arc<dyn GroupFilesRepo>,
pub groups: Arc<dyn GroupRepository>,
pub authz: Arc<dyn AuthzRepo>,
}
pub fn group_blobs_router(state: GroupBlobsState) -> Router {
Router::new()
.route("/groups/{id}/kv", get(list_kv))
.route("/groups/{id}/kv/{collection}/{key}", get(get_kv))
.route("/groups/{id}/docs", get(list_docs))
.route("/groups/{id}/docs/{collection}/{doc_id}", get(get_doc))
.route("/groups/{id}/files", get(list_files))
.route("/groups/{id}/files/{collection}/{file_id}", get(get_file))
.with_state(state)
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
pub collection: String,
#[serde(default)]
pub cursor: Option<String>,
#[serde(default)]
pub limit: Option<u32>,
}
#[derive(Debug, Serialize)]
struct ListKeysResponse {
keys: Vec<String>,
next_cursor: Option<String>,
}
async fn list_kv(
State(s): State<GroupBlobsState>,
Extension(principal): Extension<Principal>,
Path(ident): Path<String>,
Query(q): Query<ListQuery>,
) -> Result<Json<ListKeysResponse>, GroupBlobsError> {
let group_id = resolve_group(&*s.groups, &ident).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupKvRead(group_id),
)
.await?;
let page =
s.kv.list(
group_id,
&q.collection,
q.cursor.as_deref(),
q.limit.unwrap_or(0),
)
.await
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?;
Ok(Json(ListKeysResponse {
keys: page.keys,
next_cursor: page.next_cursor,
}))
}
async fn get_kv(
State(s): State<GroupBlobsState>,
Extension(principal): Extension<Principal>,
Path((ident, collection, key)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>, GroupBlobsError> {
let group_id = resolve_group(&*s.groups, &ident).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupKvRead(group_id),
)
.await?;
let value =
s.kv.get(group_id, &collection, &key)
.await
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?
.ok_or(GroupBlobsError::NotFound)?;
Ok(Json(json!({ "value": value })))
}
#[derive(Debug, Serialize)]
struct DocEntry {
id: String,
data: serde_json::Value,
}
#[derive(Debug, Serialize)]
struct ListDocsResponse {
docs: Vec<DocEntry>,
next_cursor: Option<String>,
}
async fn list_docs(
State(s): State<GroupBlobsState>,
Extension(principal): Extension<Principal>,
Path(ident): Path<String>,
Query(q): Query<ListQuery>,
) -> Result<Json<ListDocsResponse>, GroupBlobsError> {
let group_id = resolve_group(&*s.groups, &ident).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupDocsRead(group_id),
)
.await?;
let page = s
.docs
.list(
group_id,
&q.collection,
q.cursor.as_deref(),
q.limit.unwrap_or(0),
)
.await
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?;
Ok(Json(ListDocsResponse {
docs: page
.docs
.into_iter()
.map(|d| DocEntry {
id: d.id.to_string(),
data: d.data,
})
.collect(),
next_cursor: page.next_cursor,
}))
}
async fn get_doc(
State(s): State<GroupBlobsState>,
Extension(principal): Extension<Principal>,
Path((ident, collection, doc_id)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>, GroupBlobsError> {
let group_id = resolve_group(&*s.groups, &ident).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupDocsRead(group_id),
)
.await?;
let id = doc_id
.parse::<Uuid>()
.map_err(|_| GroupBlobsError::NotFound)?;
let row = s
.docs
.get(group_id, &collection, id)
.await
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?
.ok_or(GroupBlobsError::NotFound)?;
Ok(Json(json!({ "id": row.id.to_string(), "data": row.data })))
}
async fn list_files(
State(s): State<GroupBlobsState>,
Extension(principal): Extension<Principal>,
Path(ident): Path<String>,
Query(q): Query<ListQuery>,
) -> Result<Json<serde_json::Value>, GroupBlobsError> {
let group_id = resolve_group(&*s.groups, &ident).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupFilesRead(group_id),
)
.await?;
let page = s
.files
.list(
group_id,
&q.collection,
q.cursor.as_deref(),
q.limit.unwrap_or(0),
)
.await
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?;
// FileMeta is Serialize; return the metadata list + cursor.
Ok(Json(json!({
"files": page.files,
"next_cursor": page.next_cursor,
})))
}
async fn get_file(
State(s): State<GroupBlobsState>,
Extension(principal): Extension<Principal>,
Path((ident, collection, file_id)): Path<(String, String, String)>,
) -> Result<Response, GroupBlobsError> {
let group_id = resolve_group(&*s.groups, &ident).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupFilesRead(group_id),
)
.await?;
let id = file_id
.parse::<Uuid>()
.map_err(|_| GroupBlobsError::NotFound)?;
let meta = s
.files
.head(group_id, &collection, id)
.await
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?
.ok_or(GroupBlobsError::NotFound)?;
let bytes = s
.files
.get(group_id, &collection, id)
.await
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?
.ok_or(GroupBlobsError::NotFound)?;
Ok((
[
(CONTENT_TYPE, meta.content_type),
(CONTENT_LENGTH, bytes.len().to_string()),
],
bytes,
)
.into_response())
}
async fn resolve_group(
groups: &dyn GroupRepository,
ident: &str,
) -> Result<GroupId, GroupBlobsError> {
let found = if let Ok(uuid) = ident.parse::<Uuid>() {
groups
.get_by_id(uuid.into())
.await
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?
} else {
groups
.get_by_slug(ident)
.await
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?
};
found.map(|g| g.id).ok_or(GroupBlobsError::GroupNotFound)
}
#[derive(Debug, thiserror::Error)]
pub enum GroupBlobsError {
#[error("group not found")]
GroupNotFound,
#[error("not found")]
NotFound,
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("backend: {0}")]
Backend(String),
}
impl From<AuthzDenied> for GroupBlobsError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl IntoResponse for GroupBlobsError {
fn into_response(self) -> Response {
use axum::http::StatusCode;
let (status, body) = match &self {
Self::GroupNotFound | Self::NotFound => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) | Self::Backend(e) => {
tracing::error!(error = %e, "group blobs admin error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

@@ -0,0 +1,189 @@
//! Group-collection markers (§11.6) — the `group_collections` table (0052).
//!
//! A marker `(owner, name, kind)` declares that a collection name is
//! group-shared. `kind` selects the storage backend: `'kv'` →
//! `group_kv_entries` (0053), `'docs'` → `group_docs` (0054). This module holds
//! the read + transactional-write helpers, the runtime resolver, and the
//! injectable [`GroupCollectionResolver`] trait the per-kind services consume.
//!
//! The load-bearing function is [`resolve_owning_group`]: it walks the reading
//! app's ancestor chain ([`CHAIN_LEVELS_CTE`]) for the nearest group declaring
//! the collection of the requested `kind`. That walk **is** the isolation
//! boundary — a foreign app's chain never contains the owning group, so the
//! name does not resolve.
use async_trait::async_trait;
use picloud_shared::{AppId, GroupId, ScriptOwner};
use sqlx::{PgPool, Postgres, Transaction};
use crate::config_resolver::CHAIN_LEVELS_CTE;
/// List **all** shared-collection declarations at `owner` as `(name, kind)`
/// pairs, sorted. Used by the kind-aware apply diff and `collections ls`.
pub async fn list_all_for_owner(
pool: &PgPool,
owner: ScriptOwner,
) -> Result<Vec<(String, String)>, sqlx::Error> {
let rows: Vec<(String, String)> = match owner {
ScriptOwner::App(a) => {
sqlx::query_as(
"SELECT name, kind FROM group_collections \
WHERE app_id = $1 ORDER BY kind, LOWER(name)",
)
.bind(a.into_inner())
.fetch_all(pool)
.await?
}
ScriptOwner::Group(g) => {
sqlx::query_as(
"SELECT name, kind FROM group_collections \
WHERE group_id = $1 ORDER BY kind, LOWER(name)",
)
.bind(g.into_inner())
.fetch_all(pool)
.await?
}
};
Ok(rows)
}
/// Resolve the group that OWNS the shared collection `(name, kind)` for a
/// reading app: the nearest ancestor group on the app's chain that declares it.
/// Returns `None` when no group on the chain shares that `(name, kind)` — the
/// structural "not shared with you" boundary. Nearest-wins (CoW shadowing) is
/// enforced by `ORDER BY depth ASC LIMIT 1` and is security-relevant.
///
/// The join is on `group_owner` only: an app-declared marker (the degenerate
/// case) never makes a collection visible to *other* apps — sharing is a group
/// property.
pub async fn resolve_owning_group(
pool: &PgPool,
app_id: AppId,
name: &str,
kind: &str,
) -> Result<Option<GroupId>, sqlx::Error> {
let row: Option<(uuid::Uuid,)> = sqlx::query_as(&format!(
"{CHAIN_LEVELS_CTE} \
SELECT gc.group_id FROM group_collections gc \
JOIN chain c ON gc.group_id = c.group_owner \
WHERE LOWER(gc.name) = LOWER($2) AND gc.kind = $3 \
ORDER BY c.depth ASC LIMIT 1",
))
.bind(app_id.into_inner())
.bind(name)
.bind(kind)
.fetch_optional(pool)
.await?;
Ok(row.map(|(id,)| GroupId::from(id)))
}
/// Insert a `(name, kind)` marker at `owner`, in the apply transaction.
/// Idempotent: a re-apply of an already-declared marker is a no-op
/// (`ON CONFLICT DO NOTHING`), so it survives without a spurious version bump.
pub async fn insert_collection_tx(
tx: &mut Transaction<'_, Postgres>,
owner: ScriptOwner,
name: &str,
kind: &str,
) -> Result<(), sqlx::Error> {
match owner {
ScriptOwner::App(a) => {
sqlx::query(
"INSERT INTO group_collections (app_id, name, kind) VALUES ($1, $2, $3) \
ON CONFLICT (app_id, LOWER(name), kind) WHERE app_id IS NOT NULL DO NOTHING",
)
.bind(a.into_inner())
.bind(name)
.bind(kind)
.execute(&mut **tx)
.await?;
}
ScriptOwner::Group(g) => {
sqlx::query(
"INSERT INTO group_collections (group_id, name, kind) VALUES ($1, $2, $3) \
ON CONFLICT (group_id, LOWER(name), kind) WHERE group_id IS NOT NULL DO NOTHING",
)
.bind(g.into_inner())
.bind(name)
.bind(kind)
.execute(&mut **tx)
.await?;
}
}
Ok(())
}
/// Delete a `(name, kind)` marker at `owner` (case-insensitive), in the apply
/// transaction. Used by `--prune`. The storage data is NOT dropped here —
/// pruning a marker hides the store but leaves the data until the owning group
/// is deleted.
pub async fn delete_collection_tx(
tx: &mut Transaction<'_, Postgres>,
owner: ScriptOwner,
name: &str,
kind: &str,
) -> Result<(), sqlx::Error> {
match owner {
ScriptOwner::App(a) => {
sqlx::query(
"DELETE FROM group_collections \
WHERE app_id = $1 AND LOWER(name) = LOWER($2) AND kind = $3",
)
.bind(a.into_inner())
.bind(name)
.bind(kind)
.execute(&mut **tx)
.await?;
}
ScriptOwner::Group(g) => {
sqlx::query(
"DELETE FROM group_collections \
WHERE group_id = $1 AND LOWER(name) = LOWER($2) AND kind = $3",
)
.bind(g.into_inner())
.bind(name)
.bind(kind)
.execute(&mut **tx)
.await?;
}
}
Ok(())
}
/// Resolves a shared-collection name+kind to its owning group for a calling app
/// (nearest ancestor group on the app's chain that declares it). Behind a trait
/// so the per-kind services (`GroupKvServiceImpl`, `GroupDocsServiceImpl`) can
/// inject a fake in unit tests without Postgres.
#[async_trait]
pub trait GroupCollectionResolver: Send + Sync {
async fn resolve_owning_group(
&self,
app_id: AppId,
name: &str,
kind: &str,
) -> Result<Option<GroupId>, sqlx::Error>;
}
/// Postgres-backed resolver — delegates to [`resolve_owning_group`].
pub struct PostgresGroupCollectionResolver {
pool: PgPool,
}
impl PostgresGroupCollectionResolver {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl GroupCollectionResolver for PostgresGroupCollectionResolver {
async fn resolve_owning_group(
&self,
app_id: AppId,
name: &str,
kind: &str,
) -> Result<Option<GroupId>, sqlx::Error> {
resolve_owning_group(&self.pool, app_id, name, kind).await
}
}

View File

@@ -0,0 +1,299 @@
//! Low-level Postgres CRUD over `group_docs` (§11.6). A near-clone of
//! [`crate::docs_repo`] keyed by the owning `group_id` instead of `app_id`.
//! The `find` query is built by the shared [`crate::docs_repo::build_find_query`]
//! (parameterized on table + owner column), so the security-sensitive filter
//! SQL has a single source. Authorization, group resolution, value validation,
//! and event policy live one layer up in `GroupDocsServiceImpl`.
use async_trait::async_trait;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use chrono::{DateTime, Utc};
use picloud_shared::{DocId, DocRow, DocsListPage, GroupId};
use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;
use crate::docs_filter::DocsFilter;
use crate::docs_repo::{build_find_query, row_to_doc, DocsRepoError};
#[derive(Debug, thiserror::Error)]
pub enum GroupDocsRepoError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("invalid pagination cursor")]
InvalidCursor,
}
impl From<DocsRepoError> for GroupDocsRepoError {
fn from(e: DocsRepoError) -> Self {
match e {
DocsRepoError::Db(e) => Self::Db(e),
DocsRepoError::InvalidCursor => Self::InvalidCursor,
}
}
}
#[async_trait]
pub trait GroupDocsRepo: Send + Sync {
async fn create(
&self,
group_id: GroupId,
collection: &str,
data: Value,
) -> Result<DocRow, GroupDocsRepoError>;
async fn get(
&self,
group_id: GroupId,
collection: &str,
id: DocId,
) -> Result<Option<DocRow>, GroupDocsRepoError>;
async fn find(
&self,
group_id: GroupId,
collection: &str,
filter: &DocsFilter,
) -> Result<Vec<DocRow>, GroupDocsRepoError>;
/// Returns the previous data (for the would-be event), `None` if missing.
async fn update(
&self,
group_id: GroupId,
collection: &str,
id: DocId,
data: Value,
) -> Result<Option<Value>, GroupDocsRepoError>;
async fn delete(
&self,
group_id: GroupId,
collection: &str,
id: DocId,
) -> Result<Option<Value>, GroupDocsRepoError>;
async fn list(
&self,
group_id: GroupId,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<DocsListPage, GroupDocsRepoError>;
/// §11.6 quota: total doc count across the group's shared-docs collections.
/// Default `Ok(0)` so non-Postgres impls skip the quota check.
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupDocsRepoError> {
let _ = group_id;
Ok(0)
}
}
pub struct PostgresGroupDocsRepo {
pool: PgPool,
}
impl PostgresGroupDocsRepo {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
const DOCS_LIST_MAX_LIMIT: u32 = 1_000;
const DOCS_LIST_DEFAULT_LIMIT: u32 = 100;
#[async_trait]
impl GroupDocsRepo for PostgresGroupDocsRepo {
async fn create(
&self,
group_id: GroupId,
collection: &str,
data: Value,
) -> Result<DocRow, GroupDocsRepoError> {
let id = Uuid::new_v4();
let row: (DateTime<Utc>, DateTime<Utc>) = sqlx::query_as(
"INSERT INTO group_docs (group_id, collection, id, data) \
VALUES ($1, $2, $3, $4) \
RETURNING created_at, updated_at",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(id)
.bind(&data)
.fetch_one(&self.pool)
.await?;
Ok(DocRow {
id,
data,
created_at: row.0,
updated_at: row.1,
})
}
async fn get(
&self,
group_id: GroupId,
collection: &str,
id: DocId,
) -> Result<Option<DocRow>, GroupDocsRepoError> {
let row: Option<(Value, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as(
"SELECT data, created_at, updated_at FROM group_docs \
WHERE group_id = $1 AND collection = $2 AND id = $3",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|(data, created_at, updated_at)| DocRow {
id,
data,
created_at,
updated_at,
}))
}
async fn find(
&self,
group_id: GroupId,
collection: &str,
filter: &DocsFilter,
) -> Result<Vec<DocRow>, GroupDocsRepoError> {
let mut qb = build_find_query(
"group_docs",
"group_id",
group_id.into_inner(),
collection,
filter,
);
let rows = qb.build().fetch_all(&self.pool).await?;
rows.into_iter()
.map(row_to_doc)
.collect::<Result<Vec<_>, _>>()
.map_err(Into::into)
}
async fn update(
&self,
group_id: GroupId,
collection: &str,
id: DocId,
data: Value,
) -> Result<Option<Value>, GroupDocsRepoError> {
let row: Option<(Option<Value>,)> = sqlx::query_as(
"WITH prev AS ( \
SELECT data FROM group_docs \
WHERE group_id = $1 AND collection = $2 AND id = $3 \
), \
updated AS ( \
UPDATE group_docs SET data = $4, updated_at = NOW() \
WHERE group_id = $1 AND collection = $2 AND id = $3 \
RETURNING 1 \
) \
SELECT (SELECT data FROM prev) FROM updated",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(id)
.bind(&data)
.fetch_optional(&self.pool)
.await?;
Ok(row.and_then(|(v,)| v))
}
async fn delete(
&self,
group_id: GroupId,
collection: &str,
id: DocId,
) -> Result<Option<Value>, GroupDocsRepoError> {
let row: Option<(Value,)> = sqlx::query_as(
"DELETE FROM group_docs \
WHERE group_id = $1 AND collection = $2 AND id = $3 \
RETURNING data",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|(v,)| v))
}
async fn list(
&self,
group_id: GroupId,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<DocsListPage, GroupDocsRepoError> {
let limit = if limit == 0 {
DOCS_LIST_DEFAULT_LIMIT
} else {
limit.min(DOCS_LIST_MAX_LIMIT)
};
let last_id = match cursor {
Some(c) => Some(decode_cursor(c)?),
None => None,
};
let take = i64::from(limit) + 1;
let rows: Vec<(Uuid, Value, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as(
"SELECT id, data, created_at, updated_at FROM group_docs \
WHERE group_id = $1 AND collection = $2 \
AND ($3::uuid IS NULL OR id > $3) \
ORDER BY id ASC \
LIMIT $4",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(last_id)
.bind(take)
.fetch_all(&self.pool)
.await?;
let mut docs: Vec<DocRow> = rows
.into_iter()
.map(|(id, data, created_at, updated_at)| DocRow {
id,
data,
created_at,
updated_at,
})
.collect();
let next_cursor = if docs.len() > limit as usize {
docs.truncate(limit as usize);
docs.last().map(|d| encode_cursor(&d.id))
} else {
None
};
Ok(DocsListPage { docs, next_cursor })
}
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupDocsRepoError> {
let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM group_docs WHERE group_id = $1")
.bind(group_id.into_inner())
.fetch_one(&self.pool)
.await?;
Ok(u64::try_from(n).unwrap_or(0))
}
}
fn encode_cursor(last_id: &Uuid) -> String {
URL_SAFE_NO_PAD.encode(last_id.as_bytes())
}
fn decode_cursor(cursor: &str) -> Result<Uuid, GroupDocsRepoError> {
let bytes = URL_SAFE_NO_PAD
.decode(cursor)
.map_err(|_| GroupDocsRepoError::InvalidCursor)?;
let arr: [u8; 16] = bytes
.as_slice()
.try_into()
.map_err(|_| GroupDocsRepoError::InvalidCursor)?;
Ok(Uuid::from_bytes(arr))
}

View File

@@ -0,0 +1,581 @@
//! `GroupDocsServiceImpl` — wires `GroupDocsRepo` + the group-collection
//! registry underneath the `picloud_shared::GroupDocsService` trait that scripts
//! reach via the `docs::shared_collection("name")` Rhai handle (§11.6).
//!
//! Combines the group-KV service pattern (owner resolution from `cx.app_id`,
//! reads-open / writes-authed authz) with the docs surface (filter parsing,
//! JSON-object validation, value-size cap). No event emission in the MVP (the
//! "group trigger has no app to watch" deferral).
use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{
DocId, DocRow, DocsListPage, GroupDocsError, GroupDocsService, GroupId, NoopEventEmitter,
SdkCallCx, ServiceEvent, ServiceEventEmitter,
};
use crate::authz::{self, AuthzRepo, Capability};
use crate::docs_filter::{parse_filter, FilterParseError};
use crate::docs_service::docs_max_value_bytes_from_env;
use crate::group_collection_repo::GroupCollectionResolver;
use crate::group_docs_repo::{GroupDocsRepo, GroupDocsRepoError};
/// The registry `kind` this service resolves.
const KIND_DOCS: &str = "docs";
pub struct GroupDocsServiceImpl {
repo: Arc<dyn GroupDocsRepo>,
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
max_value_bytes: usize,
/// §11.6 per-group quota: max total docs across the group's shared-docs
/// collections (`PICLOUD_GROUP_DOCS_MAX_ROWS`).
max_rows: u64,
/// §11.6: fires `shared = true` docs triggers on a shared-collection write.
events: Arc<dyn ServiceEventEmitter>,
}
impl GroupDocsServiceImpl {
#[must_use]
pub fn new(
repo: Arc<dyn GroupDocsRepo>,
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
) -> Self {
Self::with_max_value_bytes(repo, resolver, authz, docs_max_value_bytes_from_env())
}
/// Wire the event emitter (§11.6 shared-collection triggers).
#[must_use]
pub fn with_events(mut self, events: Arc<dyn ServiceEventEmitter>) -> Self {
self.events = events;
self
}
#[must_use]
pub fn with_max_value_bytes(
repo: Arc<dyn GroupDocsRepo>,
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
max_value_bytes: usize,
) -> Self {
Self {
events: Arc::new(NoopEventEmitter),
max_rows: crate::group_quota::group_docs_max_rows_from_env(),
repo,
resolver,
authz,
max_value_bytes,
}
}
/// §11.6: fire `shared = true` docs triggers on the owning group.
/// Best-effort; an emit failure is logged, never surfaced.
#[allow(clippy::too_many_arguments)] // op/collection/id + new + old payloads
async fn emit_shared(
&self,
cx: &SdkCallCx,
group_id: GroupId,
op: &'static str,
collection: &str,
id: &str,
payload: Option<serde_json::Value>,
old_payload: Option<serde_json::Value>,
) {
if let Err(e) = self
.events
.emit_shared(
cx,
group_id,
ServiceEvent {
source: "docs",
op,
collection: Some(collection.to_string()),
key: Some(id.to_string()),
payload,
old_payload,
},
)
.await
{
tracing::error!(error = %e, source = "docs", op, event_emit_failure = true, "shared event emit failed");
}
}
/// The structural boundary: resolve the collection to its owning group
/// (kind=`docs`) on the calling app's chain. `CollectionNotShared` when no
/// ancestor group declares it.
async fn owning_group(
&self,
cx: &SdkCallCx,
collection: &str,
) -> Result<GroupId, GroupDocsError> {
if collection.is_empty() {
return Err(GroupDocsError::InvalidCollection);
}
self.resolver
.resolve_owning_group(cx.app_id, collection, KIND_DOCS)
.await
.map_err(|e| GroupDocsError::Backend(e.to_string()))?
.ok_or_else(|| GroupDocsError::CollectionNotShared(collection.to_string()))
}
fn check_data_size(&self, data: &serde_json::Value) -> Result<(), GroupDocsError> {
let encoded_len = serde_json::to_vec(data)
.map(|v| v.len())
.map_err(|e| GroupDocsError::Backend(format!("encode doc data: {e}")))?;
if encoded_len > self.max_value_bytes {
return Err(GroupDocsError::ValueTooLarge {
limit: self.max_value_bytes,
actual: encoded_len,
});
}
Ok(())
}
async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupDocsError> {
authz::script_gate(
&*self.authz,
cx,
Capability::GroupDocsRead(group_id),
|| GroupDocsError::Forbidden,
GroupDocsError::Backend,
)
.await
}
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupDocsError> {
// Fails closed for an anonymous principal — shared mutation always needs
// an authenticated editor+ on the owning group.
authz::script_gate_require_principal(
&*self.authz,
cx,
Capability::GroupDocsWrite(group_id),
|| GroupDocsError::Forbidden,
GroupDocsError::Backend,
)
.await
}
}
fn validate_data(data: &serde_json::Value) -> Result<(), GroupDocsError> {
if !data.is_object() {
return Err(GroupDocsError::InvalidData);
}
Ok(())
}
impl From<GroupDocsRepoError> for GroupDocsError {
fn from(e: GroupDocsRepoError) -> Self {
Self::Backend(e.to_string())
}
}
impl From<FilterParseError> for GroupDocsError {
fn from(e: FilterParseError) -> Self {
match e {
FilterParseError::InvalidFilter(s) => Self::InvalidFilter(s),
FilterParseError::UnsupportedOperator(s) => Self::UnsupportedOperator(s),
}
}
}
#[async_trait]
impl GroupDocsService for GroupDocsServiceImpl {
async fn create(
&self,
cx: &SdkCallCx,
collection: &str,
data: serde_json::Value,
) -> Result<DocId, GroupDocsError> {
let group_id = self.owning_group(cx, collection).await?;
validate_data(&data)?;
self.check_data_size(&data)?;
self.check_write(cx, group_id).await?;
// §11.6 quota: a new doc must fit under the group's row ceiling.
let count = self.repo.count_rows(group_id).await?;
if count >= self.max_rows {
return Err(GroupDocsError::QuotaExceeded {
limit: usize::try_from(self.max_rows).unwrap_or(usize::MAX),
actual: usize::try_from(count).unwrap_or(usize::MAX),
});
}
let created = self.repo.create(group_id, collection, data.clone()).await?;
self.emit_shared(
cx,
group_id,
"create",
collection,
&created.id.to_string(),
Some(data),
None,
)
.await;
Ok(created.id)
}
async fn get(
&self,
cx: &SdkCallCx,
collection: &str,
id: DocId,
) -> Result<Option<DocRow>, GroupDocsError> {
let group_id = self.owning_group(cx, collection).await?;
self.check_read(cx, group_id).await?;
Ok(self.repo.get(group_id, collection, id).await?)
}
async fn find(
&self,
cx: &SdkCallCx,
collection: &str,
filter: serde_json::Value,
) -> Result<Vec<DocRow>, GroupDocsError> {
let group_id = self.owning_group(cx, collection).await?;
self.check_read(cx, group_id).await?;
let parsed = parse_filter(&filter)?;
Ok(self.repo.find(group_id, collection, &parsed).await?)
}
async fn find_one(
&self,
cx: &SdkCallCx,
collection: &str,
filter: serde_json::Value,
) -> Result<Option<DocRow>, GroupDocsError> {
let group_id = self.owning_group(cx, collection).await?;
self.check_read(cx, group_id).await?;
let mut parsed = parse_filter(&filter)?;
if parsed.limit.is_none() {
parsed.limit = Some(1);
}
let rows = self.repo.find(group_id, collection, &parsed).await?;
Ok(rows.into_iter().next())
}
async fn update(
&self,
cx: &SdkCallCx,
collection: &str,
id: DocId,
data: serde_json::Value,
) -> Result<(), GroupDocsError> {
let group_id = self.owning_group(cx, collection).await?;
validate_data(&data)?;
self.check_data_size(&data)?;
self.check_write(cx, group_id).await?;
match self
.repo
.update(group_id, collection, id, data.clone())
.await?
{
Some(prev) => {
self.emit_shared(
cx,
group_id,
"update",
collection,
&id.to_string(),
Some(data),
Some(prev),
)
.await;
Ok(())
}
None => Err(GroupDocsError::NotFound),
}
}
async fn delete(
&self,
cx: &SdkCallCx,
collection: &str,
id: DocId,
) -> Result<bool, GroupDocsError> {
let group_id = self.owning_group(cx, collection).await?;
self.check_write(cx, group_id).await?;
match self.repo.delete(group_id, collection, id).await? {
Some(prev) => {
self.emit_shared(
cx,
group_id,
"delete",
collection,
&id.to_string(),
None,
Some(prev),
)
.await;
Ok(true)
}
None => Ok(false),
}
}
async fn list(
&self,
cx: &SdkCallCx,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<DocsListPage, GroupDocsError> {
let group_id = self.owning_group(cx, collection).await?;
self.check_read(cx, group_id).await?;
Ok(self.repo.list(group_id, collection, cursor, limit).await?)
}
}
// ----------------------------------------------------------------------------
// Tests — in-memory repo + a fake resolver so unit tests don't need Postgres.
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::authz::{AuthzError, AuthzRepo};
use crate::docs_filter::DocsFilter;
use chrono::Utc;
use picloud_shared::{
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
UserId,
};
use std::collections::HashMap;
use tokio::sync::Mutex;
use uuid::Uuid;
#[derive(Default)]
struct InMemoryGroupDocsRepo {
// (group, collection) -> id -> data
data: Mutex<HashMap<(GroupId, String), HashMap<Uuid, serde_json::Value>>>,
}
fn row(id: Uuid, data: serde_json::Value) -> DocRow {
DocRow {
id,
data,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[async_trait]
impl GroupDocsRepo for InMemoryGroupDocsRepo {
async fn create(
&self,
group_id: GroupId,
collection: &str,
data: serde_json::Value,
) -> Result<DocRow, GroupDocsRepoError> {
let id = Uuid::new_v4();
self.data
.lock()
.await
.entry((group_id, collection.to_string()))
.or_default()
.insert(id, data.clone());
Ok(row(id, data))
}
async fn get(
&self,
group_id: GroupId,
collection: &str,
id: DocId,
) -> Result<Option<DocRow>, GroupDocsRepoError> {
Ok(self
.data
.lock()
.await
.get(&(group_id, collection.to_string()))
.and_then(|m| m.get(&id).cloned())
.map(|d| row(id, d)))
}
// The fake ignores the filter (filter SQL is covered by docs_repo tests
// + the journey); returns all docs in the collection.
async fn find(
&self,
group_id: GroupId,
collection: &str,
_filter: &DocsFilter,
) -> Result<Vec<DocRow>, GroupDocsRepoError> {
Ok(self
.data
.lock()
.await
.get(&(group_id, collection.to_string()))
.map(|m| m.iter().map(|(id, d)| row(*id, d.clone())).collect())
.unwrap_or_default())
}
async fn update(
&self,
group_id: GroupId,
collection: &str,
id: DocId,
data: serde_json::Value,
) -> Result<Option<serde_json::Value>, GroupDocsRepoError> {
let mut guard = self.data.lock().await;
let coll = guard.entry((group_id, collection.to_string())).or_default();
Ok(coll.insert(id, data))
}
async fn delete(
&self,
group_id: GroupId,
collection: &str,
id: DocId,
) -> Result<Option<serde_json::Value>, GroupDocsRepoError> {
Ok(self
.data
.lock()
.await
.get_mut(&(group_id, collection.to_string()))
.and_then(|m| m.remove(&id)))
}
async fn list(
&self,
_group_id: GroupId,
_collection: &str,
_cursor: Option<&str>,
_limit: u32,
) -> Result<DocsListPage, GroupDocsRepoError> {
Ok(DocsListPage {
docs: vec![],
next_cursor: None,
})
}
}
#[derive(Default)]
struct FakeResolver {
map: HashMap<(AppId, String), GroupId>,
}
#[async_trait]
impl GroupCollectionResolver for FakeResolver {
async fn resolve_owning_group(
&self,
app_id: AppId,
name: &str,
_kind: &str,
) -> Result<Option<GroupId>, sqlx::Error> {
Ok(self.map.get(&(app_id, name.to_lowercase())).copied())
}
}
#[derive(Default)]
struct DenyingAuthzRepo;
#[async_trait]
impl AuthzRepo for DenyingAuthzRepo {
async fn membership(
&self,
_user_id: UserId,
_app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(None)
}
}
fn cx_with(app_id: AppId, principal: Option<Principal>) -> SdkCallCx {
SdkCallCx {
app_id,
script_id: ScriptId::new(),
principal,
execution_id: ExecutionId::new(),
request_id: RequestId::new(),
trigger_depth: 0,
root_execution_id: ExecutionId::new(),
is_dead_letter_handler: false,
event: None,
}
}
fn owner() -> Principal {
Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: None,
app_binding: None,
}
}
fn svc(resolver: FakeResolver) -> GroupDocsServiceImpl {
GroupDocsServiceImpl::new(
Arc::new(InMemoryGroupDocsRepo::default()),
Arc::new(resolver),
Arc::new(DenyingAuthzRepo),
)
}
#[tokio::test]
async fn unrelated_app_gets_collection_not_shared() {
let app_a = AppId::new();
let app_b = AppId::new();
let group = GroupId::new();
let mut resolver = FakeResolver::default();
resolver.map.insert((app_a, "articles".into()), group);
let docs = svc(resolver);
let cx_a = cx_with(app_a, Some(owner()));
let id = docs
.create(&cx_a, "articles", serde_json::json!({"t": "hi"}))
.await
.unwrap();
assert!(docs.get(&cx_a, "articles", id).await.unwrap().is_some());
let cx_b = cx_with(app_b, Some(owner()));
let err = docs
.find(&cx_b, "articles", serde_json::json!({}))
.await
.unwrap_err();
assert!(matches!(err, GroupDocsError::CollectionNotShared(c) if c == "articles"));
}
#[tokio::test]
async fn reads_open_writes_require_auth() {
let app = AppId::new();
let group = GroupId::new();
let mut resolver = FakeResolver::default();
resolver.map.insert((app, "articles".into()), group);
let docs = svc(resolver);
let owner_cx = cx_with(app, Some(owner()));
docs.create(&owner_cx, "articles", serde_json::json!({"t": "hi"}))
.await
.unwrap();
// Anonymous READ (find) is allowed.
let anon = cx_with(app, None);
let hits = docs
.find(&anon, "articles", serde_json::json!({}))
.await
.unwrap();
assert_eq!(hits.len(), 1);
// Anonymous WRITE (create) fails closed.
let err = docs
.create(&anon, "articles", serde_json::json!({"t": "x"}))
.await
.unwrap_err();
assert!(matches!(err, GroupDocsError::Forbidden));
}
#[tokio::test]
async fn non_object_data_rejected() {
let app = AppId::new();
let group = GroupId::new();
let mut resolver = FakeResolver::default();
resolver.map.insert((app, "articles".into()), group);
let docs = svc(resolver);
let cx = cx_with(app, Some(owner()));
let err = docs
.create(&cx, "articles", serde_json::json!("not-an-object"))
.await
.unwrap_err();
assert!(matches!(err, GroupDocsError::InvalidData));
}
}

View File

@@ -0,0 +1,416 @@
//! Low-level metadata (Postgres `group_files`) + blob bytes (filesystem) storage
//! for §11.6 group-shared FILES collections. A near-clone of [`crate::files_repo`]
//! keyed by the owning `group_id` instead of `app_id`.
//!
//! The security-sensitive disk mechanics — the atomic write+checksum protocol and
//! checksum-on-read — are **not** duplicated: they come from the owner-relative
//! free functions in [`crate::files_repo`] (`write_atomic_at` / `read_verify_at` /
//! `final_path_at`), called with this repo's owner sub-path. Group blobs shard at
//! `<root>/files/groups/<group_id>/<collection>/<id[0:2]>/<id>` — a `groups/` infix
//! disjoint from the per-app `files/<app_id>/...` subtree, so the orphan sweeper
//! ([crate::files_sweep]) covers both with one walk. Authorization, group
//! resolution, value validation, and content-type sanitization live one layer up
//! in `GroupFilesServiceImpl`.
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{FileMeta, FileUpdate, FilesListPage, GroupId, NewFile};
use sqlx::PgPool;
use uuid::Uuid;
use crate::files_repo::{
decode_cursor, encode_cursor, final_path_at, read_verify_at, write_atomic_at, FileUpdated,
FilesRepoError,
};
const FILES_LIST_MAX_LIMIT: u32 = 1_000;
const FILES_LIST_DEFAULT_LIMIT: u32 = 100;
#[derive(Debug, thiserror::Error)]
pub enum GroupFilesRepoError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("filesystem error: {0}")]
Io(String),
#[error("invalid collection name: {0}")]
InvalidCollection(String),
/// Bytes on disk no longer match the stored checksum (or are missing).
#[error("file content corrupted (checksum mismatch)")]
Corrupted,
#[error("invalid pagination cursor")]
InvalidCursor,
}
impl From<FilesRepoError> for GroupFilesRepoError {
fn from(e: FilesRepoError) -> Self {
match e {
FilesRepoError::Db(e) => Self::Db(e),
FilesRepoError::Io(s) => Self::Io(s),
FilesRepoError::InvalidCollection(c) => Self::InvalidCollection(c),
FilesRepoError::Corrupted => Self::Corrupted,
FilesRepoError::InvalidCursor => Self::InvalidCursor,
}
}
}
/// The owner-relative subdirectory of a **group**'s shared blobs under
/// `<root>/files/`: `groups/<group_id>`. A UUID app-dir can never equal the
/// literal `groups`, so app and group blob trees can't collide.
pub(crate) fn group_owner_dir(group_id: GroupId) -> PathBuf {
Path::new("groups").join(group_id.into_inner().to_string())
}
#[async_trait]
pub trait GroupFilesRepo: Send + Sync {
async fn create(
&self,
group_id: GroupId,
collection: &str,
new: NewFile,
) -> Result<FileMeta, GroupFilesRepoError>;
async fn head(
&self,
group_id: GroupId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, GroupFilesRepoError>;
/// Reads + checksum-verifies the bytes. `Ok(None)` when no row exists;
/// `Err(Corrupted)` when the row exists but the bytes are missing/mismatched.
async fn get(
&self,
group_id: GroupId,
collection: &str,
id: Uuid,
) -> Result<Option<Vec<u8>>, GroupFilesRepoError>;
/// `Ok(None)` when no row exists (the service maps that to `NotFound`).
async fn update(
&self,
group_id: GroupId,
collection: &str,
id: Uuid,
upd: FileUpdate,
) -> Result<Option<FileUpdated>, GroupFilesRepoError>;
async fn delete(
&self,
group_id: GroupId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, GroupFilesRepoError>;
async fn list(
&self,
group_id: GroupId,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<FilesListPage, GroupFilesRepoError>;
/// §11.6 quota: total stored bytes across the group's shared-files
/// collections. Default `Ok(0)` so non-Postgres impls skip the check.
async fn total_bytes(&self, group_id: GroupId) -> Result<u64, GroupFilesRepoError> {
let _ = group_id;
Ok(0)
}
}
/// Filesystem-bytes + Postgres-metadata repo for group-shared files.
pub struct FsGroupFilesRepo {
pool: PgPool,
root: PathBuf,
}
impl FsGroupFilesRepo {
#[must_use]
pub fn new(pool: PgPool, root: PathBuf) -> Self {
Self { pool, root }
}
/// Belt-and-suspenders path guard (the service validates at the SDK
/// boundary). Mirrors `FsFilesRepo::guard_collection`.
fn guard_collection(collection: &str) -> Result<(), GroupFilesRepoError> {
if collection.is_empty()
|| collection.contains('/')
|| collection.contains('\\')
|| collection.contains("..")
|| collection.contains('\0')
{
return Err(GroupFilesRepoError::InvalidCollection(
collection.to_string(),
));
}
Ok(())
}
fn write_atomic(
&self,
group_id: GroupId,
collection: &str,
id: Uuid,
bytes: &[u8],
) -> Result<String, GroupFilesRepoError> {
Ok(write_atomic_at(
&self.root,
&group_owner_dir(group_id),
collection,
id,
bytes,
)?)
}
}
#[async_trait]
impl GroupFilesRepo for FsGroupFilesRepo {
async fn create(
&self,
group_id: GroupId,
collection: &str,
new: NewFile,
) -> Result<FileMeta, GroupFilesRepoError> {
Self::guard_collection(collection)?;
let id = Uuid::new_v4();
let size = i64::try_from(new.data.len()).unwrap_or(i64::MAX);
let checksum = self.write_atomic(group_id, collection, id, &new.data)?;
let row: GroupFileRow = sqlx::query_as(
"INSERT INTO group_files \
(group_id, collection, id, name, content_type, size_bytes, checksum_sha256) \
VALUES ($1, $2, $3, $4, $5, $6, $7) \
RETURNING id, collection, name, content_type, size_bytes, \
checksum_sha256, created_at, updated_at",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(id)
.bind(&new.name)
.bind(&new.content_type)
.bind(size)
.bind(&checksum)
.fetch_one(&self.pool)
.await?;
Ok(row.into_meta())
}
async fn head(
&self,
group_id: GroupId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, GroupFilesRepoError> {
Self::guard_collection(collection)?;
let row: Option<GroupFileRow> = sqlx::query_as(
"SELECT id, collection, name, content_type, size_bytes, \
checksum_sha256, created_at, updated_at \
FROM group_files WHERE group_id = $1 AND collection = $2 AND id = $3",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(GroupFileRow::into_meta))
}
async fn get(
&self,
group_id: GroupId,
collection: &str,
id: Uuid,
) -> Result<Option<Vec<u8>>, GroupFilesRepoError> {
Self::guard_collection(collection)?;
let row: Option<(String,)> = sqlx::query_as(
"SELECT checksum_sha256 FROM group_files \
WHERE group_id = $1 AND collection = $2 AND id = $3",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(id)
.fetch_optional(&self.pool)
.await?;
let Some((stored_checksum,)) = row else {
return Ok(None);
};
let bytes = read_verify_at(
&self.root,
&group_owner_dir(group_id),
collection,
id,
&stored_checksum,
)?;
Ok(Some(bytes))
}
async fn update(
&self,
group_id: GroupId,
collection: &str,
id: Uuid,
upd: FileUpdate,
) -> Result<Option<FileUpdated>, GroupFilesRepoError> {
Self::guard_collection(collection)?;
let Some(prev) = self.head(group_id, collection, id).await? else {
return Ok(None);
};
let size = i64::try_from(upd.data.len()).unwrap_or(i64::MAX);
let checksum = self.write_atomic(group_id, collection, id, &upd.data)?;
let row: GroupFileRow = sqlx::query_as(
"UPDATE group_files SET \
name = COALESCE($4, name), \
content_type = COALESCE($5, content_type), \
size_bytes = $6, \
checksum_sha256 = $7, \
updated_at = NOW() \
WHERE group_id = $1 AND collection = $2 AND id = $3 \
RETURNING id, collection, name, content_type, size_bytes, \
checksum_sha256, created_at, updated_at",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(id)
.bind(upd.name.as_deref())
.bind(upd.content_type.as_deref())
.bind(size)
.bind(&checksum)
.fetch_one(&self.pool)
.await?;
Ok(Some(FileUpdated {
new: row.into_meta(),
prev,
}))
}
async fn delete(
&self,
group_id: GroupId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, GroupFilesRepoError> {
Self::guard_collection(collection)?;
let mut tx = self.pool.begin().await?;
let row: Option<GroupFileRow> = sqlx::query_as(
"SELECT id, collection, name, content_type, size_bytes, \
checksum_sha256, created_at, updated_at \
FROM group_files WHERE group_id = $1 AND collection = $2 AND id = $3 \
FOR UPDATE",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(id)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
tx.rollback().await?;
return Ok(None);
};
sqlx::query("DELETE FROM group_files WHERE group_id = $1 AND collection = $2 AND id = $3")
.bind(group_id.into_inner())
.bind(collection)
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
// Row gone; unlink the bytes. A failure here leaves an orphan file
// (reclaimed by the sweep) — not fatal.
let path = final_path_at(&self.root, &group_owner_dir(group_id), collection, id);
if let Err(e) = std::fs::remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(path = %path.display(), error = %e, "group files: unlink after delete failed (orphan)");
}
}
Ok(Some(row.into_meta()))
}
async fn list(
&self,
group_id: GroupId,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<FilesListPage, GroupFilesRepoError> {
Self::guard_collection(collection)?;
let limit = if limit == 0 {
FILES_LIST_DEFAULT_LIMIT
} else {
limit.min(FILES_LIST_MAX_LIMIT)
};
let last_id = match cursor {
Some(c) => Some(decode_cursor(c)?),
None => None,
};
let take = i64::from(limit) + 1;
let rows: Vec<GroupFileRow> = sqlx::query_as(
"SELECT id, collection, name, content_type, size_bytes, \
checksum_sha256, created_at, updated_at \
FROM group_files \
WHERE group_id = $1 AND collection = $2 \
AND ($3::uuid IS NULL OR id > $3) \
ORDER BY id ASC \
LIMIT $4",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(last_id)
.bind(take)
.fetch_all(&self.pool)
.await?;
let mut files: Vec<FileMeta> = rows.into_iter().map(GroupFileRow::into_meta).collect();
let next_cursor = if files.len() > limit as usize {
files.truncate(limit as usize);
files.last().map(|m| encode_cursor(m.id))
} else {
None
};
Ok(FilesListPage { files, next_cursor })
}
async fn total_bytes(&self, group_id: GroupId) -> Result<u64, GroupFilesRepoError> {
let (n,): (i64,) = sqlx::query_as(
"SELECT COALESCE(SUM(size_bytes), 0)::bigint FROM group_files WHERE group_id = $1",
)
.bind(group_id.into_inner())
.fetch_one(&self.pool)
.await?;
Ok(u64::try_from(n).unwrap_or(0))
}
}
#[derive(sqlx::FromRow)]
struct GroupFileRow {
id: Uuid,
collection: String,
name: String,
content_type: String,
size_bytes: i64,
checksum_sha256: String,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl GroupFileRow {
fn into_meta(self) -> FileMeta {
FileMeta {
id: self.id,
collection: self.collection,
name: self.name,
content_type: self.content_type,
size: u64::try_from(self.size_bytes).unwrap_or(0),
checksum: self.checksum_sha256,
created_at: self.created_at,
updated_at: self.updated_at,
}
}
}

View File

@@ -0,0 +1,557 @@
//! `GroupFilesServiceImpl` — wires `GroupFilesRepo` + the group-collection
//! registry underneath the `picloud_shared::GroupFilesService` trait that scripts
//! reach via the `files::shared_collection("name")` Rhai handle (§11.6).
//!
//! Combines the group-KV/docs service pattern (owner resolution from `cx.app_id`,
//! reads-open / writes-authed authz) with the files surface (collection
//! path-validation, field + size-cap validation, content-type sanitization). No
//! event emission in the MVP (the "group trigger has no app to watch" deferral).
use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{
sanitize_stored_content_type, validate_files_collection, FileMeta, FileUpdate, FilesListPage,
GroupFilesError, GroupFilesService, GroupId, NewFile, NoopEventEmitter, SdkCallCx,
ServiceEvent, ServiceEventEmitter,
};
use uuid::Uuid;
use crate::authz::{self, AuthzRepo, Capability};
use crate::files_repo::FileUpdated;
use crate::group_collection_repo::GroupCollectionResolver;
use crate::group_files_repo::{GroupFilesRepo, GroupFilesRepoError};
/// The registry `kind` this service resolves.
const KIND_FILES: &str = "files";
pub struct GroupFilesServiceImpl {
repo: Arc<dyn GroupFilesRepo>,
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
max_file_size_bytes: usize,
/// §11.6 per-group quota: max total stored bytes across the group's
/// shared-files collections (`PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES`).
max_total_bytes: u64,
/// §11.6: fires `shared = true` files triggers on a shared-collection write.
events: Arc<dyn ServiceEventEmitter>,
}
impl GroupFilesServiceImpl {
#[must_use]
pub fn new(
repo: Arc<dyn GroupFilesRepo>,
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
max_file_size_bytes: usize,
) -> Self {
Self {
repo,
resolver,
authz,
max_file_size_bytes,
max_total_bytes: crate::group_quota::group_files_max_total_bytes_from_env(),
events: Arc::new(NoopEventEmitter),
}
}
/// Wire the event emitter (§11.6 shared-collection triggers).
#[must_use]
pub fn with_events(mut self, events: Arc<dyn ServiceEventEmitter>) -> Self {
self.events = events;
self
}
/// §11.6: fire `shared = true` files triggers on the owning group.
/// Best-effort; an emit failure is logged, never surfaced.
async fn emit_shared(
&self,
cx: &SdkCallCx,
group_id: GroupId,
op: &'static str,
collection: &str,
meta: &FileMeta,
old: Option<&FileMeta>,
) {
if let Err(e) = self
.events
.emit_shared(
cx,
group_id,
ServiceEvent {
source: "files",
op,
collection: Some(collection.to_string()),
key: Some(meta.id.to_string()),
payload: serde_json::to_value(meta).ok(),
old_payload: old.and_then(|m| serde_json::to_value(m).ok()),
},
)
.await
{
tracing::error!(error = %e, source = "files", op, event_emit_failure = true, "shared event emit failed");
}
}
/// The structural boundary: resolve the collection to its owning group
/// (kind=`files`) on the calling app's chain. `CollectionNotShared` when no
/// ancestor group declares it.
async fn owning_group(
&self,
cx: &SdkCallCx,
collection: &str,
) -> Result<GroupId, GroupFilesError> {
self.resolver
.resolve_owning_group(cx.app_id, collection, KIND_FILES)
.await
.map_err(|e| GroupFilesError::Backend(e.to_string()))?
.ok_or_else(|| GroupFilesError::CollectionNotShared(collection.to_string()))
}
async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupFilesError> {
authz::script_gate(
&*self.authz,
cx,
Capability::GroupFilesRead(group_id),
|| GroupFilesError::Forbidden,
GroupFilesError::Backend,
)
.await
}
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupFilesError> {
// Fails closed for an anonymous principal — shared mutation always needs
// an authenticated editor+ on the owning group.
authz::script_gate_require_principal(
&*self.authz,
cx,
Capability::GroupFilesWrite(group_id),
|| GroupFilesError::Forbidden,
GroupFilesError::Backend,
)
.await
}
}
/// Invalid UUIDs aren't an error shape the SDK exposes — for reads/deletes they
/// simply mean "no such file" (mirrors `files_service::parse_id`).
fn parse_id(id: &str) -> Option<Uuid> {
Uuid::parse_str(id).ok()
}
impl From<GroupFilesRepoError> for GroupFilesError {
fn from(e: GroupFilesRepoError) -> Self {
match e {
GroupFilesRepoError::Corrupted => Self::Corrupted,
GroupFilesRepoError::InvalidCollection(c) => Self::InvalidCollection(c),
other => Self::Backend(other.to_string()),
}
}
}
#[async_trait]
impl GroupFilesService for GroupFilesServiceImpl {
async fn create(
&self,
cx: &SdkCallCx,
collection: &str,
mut new: NewFile,
) -> Result<Uuid, GroupFilesError> {
validate_files_collection(collection)?;
let group_id = self.owning_group(cx, collection).await?;
new.validate(self.max_file_size_bytes)?;
// Coerce dangerous render types to application/octet-stream after the
// shape checks pass (same as app files, audit 2026-06-11 C-2).
new.content_type = sanitize_stored_content_type(&new.content_type);
self.check_write(cx, group_id).await?;
// §11.6 quota: the new file's bytes must fit under the group's total.
let used = self.repo.total_bytes(group_id).await?;
let incoming = new.data.len() as u64;
if used.saturating_add(incoming) > self.max_total_bytes {
return Err(GroupFilesError::QuotaExceeded {
limit: self.max_total_bytes,
actual: used.saturating_add(incoming),
});
}
let meta = self.repo.create(group_id, collection, new).await?;
self.emit_shared(cx, group_id, "create", collection, &meta, None)
.await;
Ok(meta.id)
}
async fn head(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
) -> Result<Option<FileMeta>, GroupFilesError> {
validate_files_collection(collection)?;
let group_id = self.owning_group(cx, collection).await?;
self.check_read(cx, group_id).await?;
let Some(uuid) = parse_id(id) else {
return Ok(None);
};
Ok(self.repo.head(group_id, collection, uuid).await?)
}
async fn get(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
) -> Result<Option<Vec<u8>>, GroupFilesError> {
validate_files_collection(collection)?;
let group_id = self.owning_group(cx, collection).await?;
self.check_read(cx, group_id).await?;
let Some(uuid) = parse_id(id) else {
return Ok(None);
};
Ok(self.repo.get(group_id, collection, uuid).await?)
}
async fn update(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
mut upd: FileUpdate,
) -> Result<(), GroupFilesError> {
validate_files_collection(collection)?;
let group_id = self.owning_group(cx, collection).await?;
upd.validate(self.max_file_size_bytes)?;
if let Some(ct) = upd.content_type.as_deref() {
upd.content_type = Some(sanitize_stored_content_type(ct));
}
self.check_write(cx, group_id).await?;
let Some(uuid) = parse_id(id) else {
return Err(GroupFilesError::NotFound);
};
match self.repo.update(group_id, collection, uuid, upd).await? {
Some(FileUpdated { new, prev }) => {
self.emit_shared(cx, group_id, "update", collection, &new, Some(&prev))
.await;
Ok(())
}
None => Err(GroupFilesError::NotFound),
}
}
async fn delete(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
) -> Result<bool, GroupFilesError> {
validate_files_collection(collection)?;
let group_id = self.owning_group(cx, collection).await?;
self.check_write(cx, group_id).await?;
let Some(uuid) = parse_id(id) else {
return Ok(false);
};
match self.repo.delete(group_id, collection, uuid).await? {
Some(meta) => {
self.emit_shared(cx, group_id, "delete", collection, &meta, Some(&meta))
.await;
Ok(true)
}
None => Ok(false),
}
}
async fn list(
&self,
cx: &SdkCallCx,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<FilesListPage, GroupFilesError> {
validate_files_collection(collection)?;
let group_id = self.owning_group(cx, collection).await?;
self.check_read(cx, group_id).await?;
Ok(self.repo.list(group_id, collection, cursor, limit).await?)
}
}
// ----------------------------------------------------------------------------
// Tests — in-memory repo + a fake resolver so unit tests don't need Postgres or
// a filesystem. The on-disk atomic-write/checksum mechanics are covered by the
// tempdir tests in `files_repo.rs`.
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::authz::{AuthzError, AuthzRepo};
use crate::group_files_repo::GroupFilesRepoError;
use chrono::Utc;
use picloud_shared::{
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
UserId,
};
use std::collections::HashMap;
use tokio::sync::Mutex;
#[derive(Default)]
struct InMemoryGroupFilesRepo {
#[allow(clippy::type_complexity)]
data: Mutex<HashMap<(GroupId, String, Uuid), (FileMeta, Vec<u8>)>>,
}
fn meta(id: Uuid, collection: &str, new: &NewFile) -> FileMeta {
FileMeta {
id,
collection: collection.to_string(),
name: new.name.clone(),
content_type: new.content_type.clone(),
size: new.data.len() as u64,
checksum: String::new(),
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[async_trait]
impl GroupFilesRepo for InMemoryGroupFilesRepo {
async fn create(
&self,
group_id: GroupId,
collection: &str,
new: NewFile,
) -> Result<FileMeta, GroupFilesRepoError> {
let id = Uuid::new_v4();
let m = meta(id, collection, &new);
self.data.lock().await.insert(
(group_id, collection.to_string(), id),
(m.clone(), new.data),
);
Ok(m)
}
async fn head(
&self,
group_id: GroupId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, GroupFilesRepoError> {
Ok(self
.data
.lock()
.await
.get(&(group_id, collection.to_string(), id))
.map(|(m, _)| m.clone()))
}
async fn get(
&self,
group_id: GroupId,
collection: &str,
id: Uuid,
) -> Result<Option<Vec<u8>>, GroupFilesRepoError> {
Ok(self
.data
.lock()
.await
.get(&(group_id, collection.to_string(), id))
.map(|(_, b)| b.clone()))
}
async fn update(
&self,
group_id: GroupId,
collection: &str,
id: Uuid,
upd: FileUpdate,
) -> Result<Option<FileUpdated>, GroupFilesRepoError> {
let mut data = self.data.lock().await;
let key = (group_id, collection.to_string(), id);
let Some((prev, _)) = data.get(&key).cloned() else {
return Ok(None);
};
let mut m = prev.clone();
m.size = upd.data.len() as u64;
if let Some(n) = &upd.name {
m.name = n.clone();
}
data.insert(key, (m.clone(), upd.data));
Ok(Some(FileUpdated { new: m, prev }))
}
async fn delete(
&self,
group_id: GroupId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, GroupFilesRepoError> {
Ok(self
.data
.lock()
.await
.remove(&(group_id, collection.to_string(), id))
.map(|(m, _)| m))
}
async fn list(
&self,
group_id: GroupId,
collection: &str,
_cursor: Option<&str>,
_limit: u32,
) -> Result<FilesListPage, GroupFilesRepoError> {
let files = self
.data
.lock()
.await
.iter()
.filter(|((g, c, _), _)| *g == group_id && c == collection)
.map(|(_, (m, _))| m.clone())
.collect();
Ok(FilesListPage {
files,
next_cursor: None,
})
}
}
#[derive(Default)]
struct FakeResolver {
map: HashMap<(AppId, String), GroupId>,
}
#[async_trait]
impl GroupCollectionResolver for FakeResolver {
async fn resolve_owning_group(
&self,
app_id: AppId,
name: &str,
_kind: &str,
) -> Result<Option<GroupId>, sqlx::Error> {
Ok(self.map.get(&(app_id, name.to_lowercase())).copied())
}
}
#[derive(Default)]
struct DenyingAuthzRepo;
#[async_trait]
impl AuthzRepo for DenyingAuthzRepo {
async fn membership(
&self,
_user_id: UserId,
_app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(None)
}
}
fn cx_with(app_id: AppId, principal: Option<Principal>) -> SdkCallCx {
SdkCallCx {
app_id,
script_id: ScriptId::new(),
principal,
execution_id: ExecutionId::new(),
request_id: RequestId::new(),
trigger_depth: 0,
root_execution_id: ExecutionId::new(),
is_dead_letter_handler: false,
event: None,
}
}
fn owner() -> Principal {
Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: None,
app_binding: None,
}
}
fn svc(resolver: FakeResolver) -> GroupFilesServiceImpl {
GroupFilesServiceImpl::new(
Arc::new(InMemoryGroupFilesRepo::default()),
Arc::new(resolver),
Arc::new(DenyingAuthzRepo),
10 * 1024 * 1024,
)
}
fn new_file(name: &str, data: &[u8]) -> NewFile {
NewFile {
name: name.to_string(),
content_type: "application/octet-stream".to_string(),
data: data.to_vec(),
}
}
#[tokio::test]
async fn unrelated_app_gets_collection_not_shared() {
let app_a = AppId::new();
let app_b = AppId::new();
let group = GroupId::new();
let mut resolver = FakeResolver::default();
resolver.map.insert((app_a, "assets".into()), group);
let files = svc(resolver);
let cx_a = cx_with(app_a, Some(owner()));
let id = files
.create(&cx_a, "assets", new_file("a.txt", b"hi"))
.await
.unwrap();
assert!(files
.get(&cx_a, "assets", &id.to_string())
.await
.unwrap()
.is_some());
let cx_b = cx_with(app_b, Some(owner()));
let err = files.list(&cx_b, "assets", None, 100).await.unwrap_err();
assert!(matches!(err, GroupFilesError::CollectionNotShared(c) if c == "assets"));
}
#[tokio::test]
async fn reads_open_writes_require_auth() {
let app = AppId::new();
let group = GroupId::new();
let mut resolver = FakeResolver::default();
resolver.map.insert((app, "assets".into()), group);
let files = svc(resolver);
let owner_cx = cx_with(app, Some(owner()));
let id = files
.create(&owner_cx, "assets", new_file("a.txt", b"hi"))
.await
.unwrap();
// Anonymous READ (get) is allowed.
let anon = cx_with(app, None);
let bytes = files.get(&anon, "assets", &id.to_string()).await.unwrap();
assert_eq!(bytes, Some(b"hi".to_vec()));
// Anonymous WRITE (create) fails closed.
let err = files
.create(&anon, "assets", new_file("x.txt", b"x"))
.await
.unwrap_err();
assert!(matches!(err, GroupFilesError::Forbidden));
}
#[tokio::test]
async fn oversize_blob_rejected() {
let app = AppId::new();
let group = GroupId::new();
let mut resolver = FakeResolver::default();
resolver.map.insert((app, "assets".into()), group);
let files = GroupFilesServiceImpl::new(
Arc::new(InMemoryGroupFilesRepo::default()),
Arc::new(resolver),
Arc::new(DenyingAuthzRepo),
8, // tiny cap
);
let cx = cx_with(app, Some(owner()));
let err = files
.create(&cx, "assets", new_file("big", b"123456789"))
.await
.unwrap_err();
assert!(matches!(err, GroupFilesError::TooLarge { limit: 8, .. }));
}
}

View File

@@ -0,0 +1,238 @@
//! Low-level Postgres CRUD over `group_kv_entries` (§11.6). A near-clone of
//! [`crate::kv_repo`] keyed by the owning `group_id` instead of `app_id` —
//! authorization, group resolution, event policy, and empty-collection
//! validation live one layer up in `GroupKvServiceImpl`.
use async_trait::async_trait;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use picloud_shared::{GroupId, KvListPage};
use sqlx::PgPool;
#[derive(Debug, thiserror::Error)]
pub enum GroupKvRepoError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("invalid pagination cursor")]
InvalidCursor,
}
/// Repo surface. The trait is exposed so tests can substitute an in-memory
/// backing without spinning up Postgres.
#[async_trait]
pub trait GroupKvRepo: Send + Sync {
async fn get(
&self,
group_id: GroupId,
collection: &str,
key: &str,
) -> Result<Option<serde_json::Value>, GroupKvRepoError>;
/// Upserts the row. Returns the previous value (if any) so callers can
/// determine whether this was an `insert` or an `update`.
async fn set(
&self,
group_id: GroupId,
collection: &str,
key: &str,
value: serde_json::Value,
) -> Result<Option<serde_json::Value>, GroupKvRepoError>;
/// Returns the deleted value if present, `None` if the row didn't exist.
async fn delete(
&self,
group_id: GroupId,
collection: &str,
key: &str,
) -> Result<Option<serde_json::Value>, GroupKvRepoError>;
async fn has(
&self,
group_id: GroupId,
collection: &str,
key: &str,
) -> Result<bool, GroupKvRepoError>;
async fn list(
&self,
group_id: GroupId,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<KvListPage, GroupKvRepoError>;
/// §11.6 quota: total key count across all of the group's shared-KV
/// collections. Default `Ok(0)` so non-Postgres impls skip the quota check.
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupKvRepoError> {
let _ = group_id;
Ok(0)
}
}
pub struct PostgresGroupKvRepo {
pool: PgPool,
}
impl PostgresGroupKvRepo {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
/// Hard ceiling on `list` page size (mirrors `kv_repo`).
const KV_LIST_MAX_LIMIT: u32 = 1_000;
const KV_LIST_DEFAULT_LIMIT: u32 = 100;
#[async_trait]
impl GroupKvRepo for PostgresGroupKvRepo {
async fn get(
&self,
group_id: GroupId,
collection: &str,
key: &str,
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
let row: Option<(serde_json::Value,)> = sqlx::query_as(
"SELECT value FROM group_kv_entries \
WHERE group_id = $1 AND collection = $2 AND key = $3",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(key)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|(v,)| v))
}
async fn set(
&self,
group_id: GroupId,
collection: &str,
key: &str,
value: serde_json::Value,
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
let row: Option<(Option<serde_json::Value>,)> = sqlx::query_as(
"WITH prev AS (\
SELECT value FROM group_kv_entries \
WHERE group_id = $1 AND collection = $2 AND key = $3\
), \
upserted AS (\
INSERT INTO group_kv_entries (group_id, collection, key, value) \
VALUES ($1, $2, $3, $4) \
ON CONFLICT (group_id, collection, key) DO UPDATE \
SET value = EXCLUDED.value, updated_at = NOW() \
RETURNING 1\
) \
SELECT (SELECT value FROM prev) FROM upserted",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(key)
.bind(value)
.fetch_optional(&self.pool)
.await?;
Ok(row.and_then(|(v,)| v))
}
async fn delete(
&self,
group_id: GroupId,
collection: &str,
key: &str,
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
let row: Option<(serde_json::Value,)> = sqlx::query_as(
"DELETE FROM group_kv_entries \
WHERE group_id = $1 AND collection = $2 AND key = $3 \
RETURNING value",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(key)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|(v,)| v))
}
async fn has(
&self,
group_id: GroupId,
collection: &str,
key: &str,
) -> Result<bool, GroupKvRepoError> {
let row: Option<(i64,)> = sqlx::query_as(
"SELECT 1 FROM group_kv_entries \
WHERE group_id = $1 AND collection = $2 AND key = $3",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(key)
.fetch_optional(&self.pool)
.await?;
Ok(row.is_some())
}
async fn list(
&self,
group_id: GroupId,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<KvListPage, GroupKvRepoError> {
let limit = if limit == 0 {
KV_LIST_DEFAULT_LIMIT
} else {
limit.min(KV_LIST_MAX_LIMIT)
};
let last_key = match cursor {
Some(c) => Some(decode_cursor(c)?),
None => None,
};
let take = i64::from(limit) + 1;
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT key FROM group_kv_entries \
WHERE group_id = $1 AND collection = $2 \
AND ($3::text IS NULL OR key > $3) \
ORDER BY key ASC \
LIMIT $4",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(last_key.as_deref())
.bind(take)
.fetch_all(&self.pool)
.await?;
let mut keys: Vec<String> = rows.into_iter().map(|(k,)| k).collect();
let next_cursor = if keys.len() > limit as usize {
keys.truncate(limit as usize);
keys.last().map(|k| encode_cursor(k))
} else {
None
};
Ok(KvListPage { keys, next_cursor })
}
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupKvRepoError> {
let (n,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM group_kv_entries WHERE group_id = $1")
.bind(group_id.into_inner())
.fetch_one(&self.pool)
.await?;
Ok(u64::try_from(n).unwrap_or(0))
}
}
fn encode_cursor(last_key: &str) -> String {
URL_SAFE_NO_PAD.encode(last_key.as_bytes())
}
fn decode_cursor(cursor: &str) -> Result<String, GroupKvRepoError> {
let bytes = URL_SAFE_NO_PAD
.decode(cursor)
.map_err(|_| GroupKvRepoError::InvalidCursor)?;
String::from_utf8(bytes).map_err(|_| GroupKvRepoError::InvalidCursor)
}

View File

@@ -0,0 +1,558 @@
//! `GroupKvServiceImpl` — wires `GroupKvRepo` + the group-collection registry
//! underneath the `picloud_shared::GroupKvService` trait that scripts reach via
//! the `kv::shared_collection("name")` Rhai handle (§11.6).
//!
//! Layers added over the raw repo:
//!
//! 1. Empty-collection rejection.
//! 2. **Owner resolution** (the structural isolation boundary): the collection
//! name is resolved to the nearest ancestor group that declares it shared,
//! walking the calling app's chain. No declaration on the chain → a clean
//! `CollectionNotShared`, BEFORE any authz or storage access.
//! 3. **Reads-open / writes-authed authz**: reads use `script_gate`
//! (anonymous public scripts skip); writes use `script_gate_require_principal`
//! (anonymous fails closed — shared mutation always needs an authenticated
//! editor+ on the owning group).
//! 4. Per-value size cap (reuses `PICLOUD_KV_MAX_VALUE_BYTES`) + a per-group
//! row quota (§11.6 M3, `PICLOUD_GROUP_KV_MAX_ROWS`).
//! 5. §11.6 M2 shared-collection triggers: a write fires `shared = true`
//! triggers on the owning group, under the writer app (`emit_shared`).
use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{
GroupId, GroupKvError, GroupKvService, KvListPage, NoopEventEmitter, SdkCallCx, ServiceEvent,
ServiceEventEmitter,
};
use crate::authz::{self, AuthzRepo, Capability};
use crate::group_collection_repo::GroupCollectionResolver;
use crate::group_kv_repo::{GroupKvRepo, GroupKvRepoError};
use crate::kv_service::kv_max_value_bytes_from_env;
/// The registry `kind` this service resolves.
const KIND_KV: &str = "kv";
pub struct GroupKvServiceImpl {
repo: Arc<dyn GroupKvRepo>,
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
max_value_bytes: usize,
/// §11.6 per-group quota: max total keys across the group's shared-KV
/// collections (`PICLOUD_GROUP_KV_MAX_ROWS`). Checked only when inserting a
/// NEW key.
max_rows: u64,
/// §11.6: fires `shared = true` triggers on a shared-collection write.
/// Defaults to the noop emitter; the host wires the outbox emitter via
/// [`Self::with_events`].
events: Arc<dyn ServiceEventEmitter>,
}
impl GroupKvServiceImpl {
#[must_use]
pub fn new(
repo: Arc<dyn GroupKvRepo>,
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
) -> Self {
Self::with_max_value_bytes(repo, resolver, authz, kv_max_value_bytes_from_env())
}
/// Wire the event emitter (§11.6 shared-collection triggers). Without this
/// the service uses the noop emitter and shared writes fire nothing.
#[must_use]
pub fn with_events(mut self, events: Arc<dyn ServiceEventEmitter>) -> Self {
self.events = events;
self
}
/// Override the per-group row quota (§11.6). Mainly for tests; the host uses
/// the env-var default.
#[must_use]
pub fn with_max_rows(mut self, max_rows: u64) -> Self {
self.max_rows = max_rows;
self
}
#[must_use]
pub fn with_max_value_bytes(
repo: Arc<dyn GroupKvRepo>,
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
max_value_bytes: usize,
) -> Self {
Self {
events: Arc::new(NoopEventEmitter),
max_rows: crate::group_quota::group_kv_max_rows_from_env(),
repo,
resolver,
authz,
max_value_bytes,
}
}
/// The structural boundary: resolve the collection to its owning group on
/// the calling app's chain. `CollectionNotShared` when no ancestor group
/// declares it — the same outcome a foreign app gets, by construction.
async fn owning_group(
&self,
cx: &SdkCallCx,
collection: &str,
) -> Result<GroupId, GroupKvError> {
if collection.is_empty() {
return Err(GroupKvError::InvalidCollection);
}
self.resolver
.resolve_owning_group(cx.app_id, collection, KIND_KV)
.await
.map_err(|e| GroupKvError::Backend(e.to_string()))?
.ok_or_else(|| GroupKvError::CollectionNotShared(collection.to_string()))
}
/// §11.6: fire `shared = true` kv triggers on the owning group. Best-effort
/// — an emit failure is logged, never surfaced (the write already
/// committed), matching the per-app `KvServiceImpl` behaviour.
async fn emit_shared(
&self,
cx: &SdkCallCx,
group_id: GroupId,
op: &'static str,
collection: &str,
key: &str,
payload: Option<serde_json::Value>,
) {
if let Err(e) = self
.events
.emit_shared(
cx,
group_id,
ServiceEvent {
source: "kv",
op,
collection: Some(collection.to_string()),
key: Some(key.to_string()),
payload,
old_payload: None,
},
)
.await
{
tracing::error!(error = %e, source = "kv", op, event_emit_failure = true, "shared event emit failed");
}
}
async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupKvError> {
authz::script_gate(
&*self.authz,
cx,
Capability::GroupKvRead(group_id),
|| GroupKvError::Forbidden,
GroupKvError::Backend,
)
.await
}
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupKvError> {
// Fails closed for an anonymous principal: shared mutation always needs
// an authenticated editor+ on the owning group.
authz::script_gate_require_principal(
&*self.authz,
cx,
Capability::GroupKvWrite(group_id),
|| GroupKvError::Forbidden,
GroupKvError::Backend,
)
.await
}
}
impl From<GroupKvRepoError> for GroupKvError {
fn from(e: GroupKvRepoError) -> Self {
Self::Backend(e.to_string())
}
}
#[async_trait]
impl GroupKvService for GroupKvServiceImpl {
async fn get(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
) -> Result<Option<serde_json::Value>, GroupKvError> {
let group_id = self.owning_group(cx, collection).await?;
self.check_read(cx, group_id).await?;
Ok(self.repo.get(group_id, collection, key).await?)
}
async fn set(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
value: serde_json::Value,
) -> Result<(), GroupKvError> {
let group_id = self.owning_group(cx, collection).await?;
let encoded_len = serde_json::to_vec(&value)
.map(|v| v.len())
.map_err(|e| GroupKvError::Backend(format!("encode value: {e}")))?;
if encoded_len > self.max_value_bytes {
return Err(GroupKvError::ValueTooLarge {
limit: self.max_value_bytes,
actual: encoded_len,
});
}
self.check_write(cx, group_id).await?;
// Determine insert vs update for the event op (best-effort, like the
// per-app path — the has+set is non-transactional but triggers are
// fire-and-forget).
let existed = self.repo.has(group_id, collection, key).await?;
// §11.6 quota: a NEW key must fit under the group's row ceiling; an
// update of an existing key is net-zero and exempt.
if !existed {
let count = self.repo.count_rows(group_id).await?;
if count >= self.max_rows {
return Err(GroupKvError::QuotaExceeded {
limit: usize::try_from(self.max_rows).unwrap_or(usize::MAX),
actual: usize::try_from(count).unwrap_or(usize::MAX),
});
}
}
self.repo
.set(group_id, collection, key, value.clone())
.await?;
self.emit_shared(
cx,
group_id,
if existed { "update" } else { "insert" },
collection,
key,
Some(value),
)
.await;
Ok(())
}
async fn delete(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
) -> Result<bool, GroupKvError> {
let group_id = self.owning_group(cx, collection).await?;
self.check_write(cx, group_id).await?;
let deleted = self.repo.delete(group_id, collection, key).await?.is_some();
if deleted {
self.emit_shared(cx, group_id, "delete", collection, key, None)
.await;
}
Ok(deleted)
}
async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, GroupKvError> {
let group_id = self.owning_group(cx, collection).await?;
self.check_read(cx, group_id).await?;
Ok(self.repo.has(group_id, collection, key).await?)
}
async fn list(
&self,
cx: &SdkCallCx,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<KvListPage, GroupKvError> {
let group_id = self.owning_group(cx, collection).await?;
self.check_read(cx, group_id).await?;
Ok(self.repo.list(group_id, collection, cursor, limit).await?)
}
}
// ----------------------------------------------------------------------------
// Tests — in-memory repo + a fake resolver so unit tests don't need Postgres.
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::authz::{AuthzError, AuthzRepo};
use picloud_shared::{
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
UserId,
};
use std::collections::{BTreeMap, HashMap};
use tokio::sync::Mutex;
#[derive(Default)]
struct InMemoryGroupKvRepo {
data: Mutex<BTreeMap<(GroupId, String, String), serde_json::Value>>,
}
#[async_trait]
impl GroupKvRepo for InMemoryGroupKvRepo {
async fn get(
&self,
group_id: GroupId,
collection: &str,
key: &str,
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
Ok(self
.data
.lock()
.await
.get(&(group_id, collection.to_string(), key.to_string()))
.cloned())
}
async fn set(
&self,
group_id: GroupId,
collection: &str,
key: &str,
value: serde_json::Value,
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
Ok(self
.data
.lock()
.await
.insert((group_id, collection.to_string(), key.to_string()), value))
}
async fn delete(
&self,
group_id: GroupId,
collection: &str,
key: &str,
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
Ok(self
.data
.lock()
.await
.remove(&(group_id, collection.to_string(), key.to_string())))
}
async fn has(
&self,
group_id: GroupId,
collection: &str,
key: &str,
) -> Result<bool, GroupKvRepoError> {
Ok(self.data.lock().await.contains_key(&(
group_id,
collection.to_string(),
key.to_string(),
)))
}
async fn list(
&self,
group_id: GroupId,
collection: &str,
_cursor: Option<&str>,
limit: u32,
) -> Result<KvListPage, GroupKvRepoError> {
let data = self.data.lock().await;
let mut keys: Vec<String> = data
.iter()
.filter(|((g, c, _), _)| *g == group_id && c == collection)
.map(|((_, _, k), _)| k.clone())
.collect();
keys.sort();
keys.truncate((limit as usize).max(1));
Ok(KvListPage {
keys,
next_cursor: None,
})
}
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupKvRepoError> {
let data = self.data.lock().await;
Ok(data.keys().filter(|(g, _, _)| *g == group_id).count() as u64)
}
}
/// Maps `(app_id, lowercased name) -> owning group`. Anything absent is
/// "not shared with you".
#[derive(Default)]
struct FakeResolver {
map: HashMap<(AppId, String), GroupId>,
}
#[async_trait]
impl GroupCollectionResolver for FakeResolver {
async fn resolve_owning_group(
&self,
app_id: AppId,
name: &str,
_kind: &str,
) -> Result<Option<GroupId>, sqlx::Error> {
Ok(self.map.get(&(app_id, name.to_lowercase())).copied())
}
}
#[derive(Default)]
struct DenyingAuthzRepo;
#[async_trait]
impl AuthzRepo for DenyingAuthzRepo {
async fn membership(
&self,
_user_id: UserId,
_app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(None)
}
}
fn cx_with(app_id: AppId, principal: Option<Principal>) -> SdkCallCx {
SdkCallCx {
app_id,
script_id: ScriptId::new(),
principal,
execution_id: ExecutionId::new(),
request_id: RequestId::new(),
trigger_depth: 0,
root_execution_id: ExecutionId::new(),
is_dead_letter_handler: false,
event: None,
}
}
fn owner() -> Principal {
Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: None,
app_binding: None,
}
}
fn member_no_role() -> Principal {
Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Member,
scopes: None,
app_binding: None,
}
}
fn svc(resolver: FakeResolver) -> GroupKvServiceImpl {
GroupKvServiceImpl::new(
Arc::new(InMemoryGroupKvRepo::default()),
Arc::new(resolver),
Arc::new(DenyingAuthzRepo),
)
}
#[tokio::test]
async fn per_group_row_quota_rejects_new_keys_but_allows_updates() {
// §11.6: a group's shared-KV store has a row ceiling; a NEW key over the
// cap is rejected, but updating an existing key (net-zero) still works.
let app = AppId::new();
let group = GroupId::new();
let mut resolver = FakeResolver::default();
resolver.map.insert((app, "catalog".into()), group);
let kv = GroupKvServiceImpl::new(
Arc::new(InMemoryGroupKvRepo::default()),
Arc::new(resolver),
Arc::new(DenyingAuthzRepo),
)
.with_max_rows(2);
let cx = cx_with(app, Some(owner()));
kv.set(&cx, "catalog", "a", serde_json::json!(1))
.await
.unwrap();
kv.set(&cx, "catalog", "b", serde_json::json!(2))
.await
.unwrap();
// A third distinct key exceeds the cap.
let err = kv
.set(&cx, "catalog", "c", serde_json::json!(3))
.await
.unwrap_err();
assert!(
matches!(
err,
GroupKvError::QuotaExceeded {
limit: 2,
actual: 2
}
),
"got {err:?}"
);
// Updating an existing key is exempt (net-zero).
kv.set(&cx, "catalog", "a", serde_json::json!(11))
.await
.expect("update of an existing key must be allowed at the cap");
}
#[tokio::test]
async fn unrelated_app_gets_collection_not_shared() {
// app_a's chain declares "catalog"; app_b's does not.
let app_a = AppId::new();
let app_b = AppId::new();
let group = GroupId::new();
let mut resolver = FakeResolver::default();
resolver.map.insert((app_a, "catalog".into()), group);
let kv = svc(resolver);
// app_a (owner principal) can write+read.
let cx_a = cx_with(app_a, Some(owner()));
kv.set(&cx_a, "catalog", "k", serde_json::json!(1))
.await
.unwrap();
assert_eq!(
kv.get(&cx_a, "catalog", "k").await.unwrap(),
Some(serde_json::json!(1))
);
// app_b is off-chain — the name does not resolve.
let cx_b = cx_with(app_b, Some(owner()));
let err = kv.get(&cx_b, "catalog", "k").await.unwrap_err();
assert!(matches!(err, GroupKvError::CollectionNotShared(c) if c == "catalog"));
}
#[tokio::test]
async fn reads_open_writes_require_auth() {
let app = AppId::new();
let group = GroupId::new();
let mut resolver = FakeResolver::default();
resolver.map.insert((app, "catalog".into()), group);
let kv = svc(resolver);
// Seed a value as owner.
let owner_cx = cx_with(app, Some(owner()));
kv.set(&owner_cx, "catalog", "k", serde_json::json!("v"))
.await
.unwrap();
// Anonymous (principal=None) READ is allowed (reads-open).
let anon = cx_with(app, None);
assert_eq!(
kv.get(&anon, "catalog", "k").await.unwrap(),
Some(serde_json::json!("v"))
);
// Anonymous WRITE fails closed.
let err = kv
.set(&anon, "catalog", "k", serde_json::json!("x"))
.await
.unwrap_err();
assert!(matches!(err, GroupKvError::Forbidden));
// Authenticated member with no group role: write forbidden.
let member = cx_with(app, Some(member_no_role()));
let err = kv.delete(&member, "catalog", "k").await.unwrap_err();
assert!(matches!(err, GroupKvError::Forbidden));
}
#[tokio::test]
async fn empty_collection_rejected_before_resolve() {
let kv = svc(FakeResolver::default());
let cx = cx_with(AppId::new(), None);
let err = kv.get(&cx, "", "k").await.unwrap_err();
assert!(matches!(err, GroupKvError::InvalidCollection));
}
}

View File

@@ -0,0 +1,128 @@
//! `GroupPubsubServiceImpl` — wires the group-collection registry + the pubsub
//! fan-out repo underneath the `picloud_shared::GroupPubsubService` trait that
//! scripts reach via the `pubsub::shared_topic("name")` Rhai handle (§11.6 D2).
//!
//! A shared topic is storeless — a publish resolves the OWNING GROUP (the
//! nearest ancestor group declaring the topic shared, kind `topic`), then fans
//! out to that group's `shared = true` pubsub triggers, each delivery stamped
//! with the WRITER `cx.app_id` so the handler runs under the publishing app
//! (matching the M2 shared-collection trigger model). Owner resolution is the
//! isolation boundary — a foreign app's chain never reaches the owning group.
//!
//! Trust model: a publish is a WRITE, so it uses `script_gate_require_principal`
//! (anonymous fails closed — shared publish always needs an authenticated
//! editor+ on the owning group).
use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{GroupId, GroupPubsubError, GroupPubsubService, SdkCallCx, TriggerEvent};
use crate::authz::{self, AuthzRepo, Capability};
use crate::group_collection_repo::GroupCollectionResolver;
use crate::pubsub_repo::{PublishCtx, PubsubRepo};
use crate::pubsub_service::pubsub_max_message_bytes_from_env;
/// The registry `kind` this service resolves.
const KIND_TOPIC: &str = "topic";
pub struct GroupPubsubServiceImpl {
repo: Arc<dyn PubsubRepo>,
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
max_message_bytes: usize,
}
impl GroupPubsubServiceImpl {
#[must_use]
pub fn new(
repo: Arc<dyn PubsubRepo>,
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
) -> Self {
Self {
repo,
resolver,
authz,
max_message_bytes: pubsub_max_message_bytes_from_env(),
}
}
/// The structural boundary: resolve the topic namespace to its owning group
/// on the calling app's chain. `CollectionNotShared` when no ancestor group
/// declares it — the same outcome a foreign app gets, by construction.
async fn owning_group(
&self,
cx: &SdkCallCx,
namespace: &str,
) -> Result<GroupId, GroupPubsubError> {
self.resolver
.resolve_owning_group(cx.app_id, namespace, KIND_TOPIC)
.await
.map_err(|e| GroupPubsubError::Backend(e.to_string()))?
.ok_or_else(|| GroupPubsubError::CollectionNotShared(namespace.to_string()))
}
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupPubsubError> {
authz::script_gate_require_principal(
&*self.authz,
cx,
Capability::GroupPubsubPublish(group_id),
|| GroupPubsubError::Forbidden,
GroupPubsubError::Backend,
)
.await
}
}
#[async_trait]
impl GroupPubsubService for GroupPubsubServiceImpl {
async fn publish(
&self,
cx: &SdkCallCx,
namespace: &str,
subtopic: &str,
message: serde_json::Value,
) -> Result<u32, GroupPubsubError> {
if namespace.trim().is_empty() {
return Err(GroupPubsubError::InvalidTopic);
}
// Size cap first (no DB) — a cheap reject before the resolve/authz work.
let encoded_len = serde_json::to_vec(&message)
.map(|v| v.len())
.map_err(|e| GroupPubsubError::Backend(format!("encode message: {e}")))?;
if encoded_len > self.max_message_bytes {
return Err(GroupPubsubError::MessageTooLarge {
limit: self.max_message_bytes,
actual: encoded_len,
});
}
let group_id = self.owning_group(cx, namespace).await?;
self.check_write(cx, group_id).await?;
// The full topic is `namespace` or `namespace.subtopic`; shared triggers
// match it with the same `topic_matches` semantics as the per-app path.
let topic = if subtopic.trim().is_empty() {
namespace.to_string()
} else {
format!("{namespace}.{subtopic}")
};
let event = TriggerEvent::Pubsub {
topic: topic.clone(),
message,
published_at: chrono::Utc::now(),
};
let payload = serde_json::to_value(&event)
.map_err(|e| GroupPubsubError::Backend(format!("event serialize: {e}")))?;
let publish_ctx = PublishCtx {
app_id: cx.app_id,
origin_principal: cx.principal.as_ref().map(|p| p.user_id),
trigger_depth: cx.trigger_depth,
root_execution_id: cx.root_execution_id,
};
self.repo
.fan_out_shared_publish(publish_ctx, group_id, &topic, payload)
.await
.map_err(|e| GroupPubsubError::Backend(e.to_string()))
}
}

View File

@@ -0,0 +1,294 @@
//! `GroupQueueRepo` — CRUD over `group_queue_messages`, the §11.6 D3 group-
//! shared durable queue store.
//!
//! The group counterpart to [`crate::queue_repo::QueueRepo`], keyed by
//! `(group_id, collection)` instead of `(app_id, queue_name)`. Producers enqueue
//! via `GroupQueueServiceImpl` (`queue::shared_collection(name).enqueue(...)`);
//! the dispatcher's queue arm claims from here for a materialized shared-queue
//! consumer (competing consumers — every descendant app runs a consumer copy,
//! all claiming this one store with `FOR UPDATE SKIP LOCKED`, so each message is
//! delivered at-most-once across the subtree).
//!
//! Deferred (documented): shared-queue dead-lettering — an exhausted message is
//! nacked with backoff by the dispatcher (never silently dropped), but there is
//! no group dead-letter store yet.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, GroupId, QueueMessageId};
use sqlx::PgPool;
use uuid::Uuid;
/// Cap on `depth`/`depth_pending` scans (mirrors `queue_repo`).
const QUEUE_DEPTH_SCAN_CAP: i64 = 10_000;
#[derive(Debug, thiserror::Error)]
pub enum GroupQueueRepoError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
}
/// Insert payload — what `GroupQueueService::enqueue` hands the repo.
#[derive(Debug, Clone)]
pub struct NewGroupQueueMessage {
pub group_id: GroupId,
pub collection: String,
pub payload: serde_json::Value,
pub deliver_after: Option<DateTime<Utc>>,
pub max_attempts: u32,
pub enqueued_by_principal: Option<AdminUserId>,
}
/// One claimed message ready for handler dispatch.
#[derive(Debug, Clone)]
pub struct ClaimedGroupMessage {
pub id: QueueMessageId,
pub group_id: GroupId,
pub collection: String,
pub payload: serde_json::Value,
pub enqueued_at: DateTime<Utc>,
pub attempt: u32,
pub max_attempts: u32,
pub claim_token: Uuid,
}
#[async_trait]
pub trait GroupQueueRepo: Send + Sync {
async fn enqueue(
&self,
msg: NewGroupQueueMessage,
) -> Result<QueueMessageId, GroupQueueRepoError>;
/// Atomic claim of one ready message from `(group_id, collection)` with
/// `FOR UPDATE SKIP LOCKED` — the competing-consumer primitive. `Ok(None)`
/// when nothing is claimable.
async fn claim(
&self,
group_id: GroupId,
collection: &str,
) -> Result<Option<ClaimedGroupMessage>, GroupQueueRepoError>;
/// Handler succeeded: delete the row iff `claim_token` matches.
async fn ack(
&self,
message_id: QueueMessageId,
claim_token: Uuid,
) -> Result<bool, GroupQueueRepoError>;
/// Handler threw: clear the claim, defer redelivery by `retry_delay`.
async fn nack(
&self,
message_id: QueueMessageId,
claim_token: Uuid,
retry_delay: chrono::Duration,
) -> Result<bool, GroupQueueRepoError>;
/// Drop a message that exhausted its attempts (no group dead-letter store
/// yet — see the module deferral note). Deletes iff `claim_token` matches.
async fn drop_exhausted(
&self,
message_id: QueueMessageId,
claim_token: Uuid,
) -> Result<bool, GroupQueueRepoError>;
/// Periodic safety net: clear claims older than a shared-queue consumer's
/// `visibility_timeout_secs`. Returns the number of rows reclaimed.
async fn reclaim_visibility_timeouts(&self) -> Result<u64, GroupQueueRepoError>;
async fn depth(&self, group_id: GroupId, collection: &str) -> Result<u64, GroupQueueRepoError>;
async fn depth_pending(
&self,
group_id: GroupId,
collection: &str,
) -> Result<u64, GroupQueueRepoError>;
}
pub struct PostgresGroupQueueRepo {
pool: PgPool,
}
impl PostgresGroupQueueRepo {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl GroupQueueRepo for PostgresGroupQueueRepo {
async fn enqueue(
&self,
msg: NewGroupQueueMessage,
) -> Result<QueueMessageId, GroupQueueRepoError> {
let (id,): (Uuid,) = sqlx::query_as(
"INSERT INTO group_queue_messages ( \
group_id, collection, payload, deliver_after, \
max_attempts, enqueued_by_principal \
) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id",
)
.bind(msg.group_id.into_inner())
.bind(&msg.collection)
.bind(&msg.payload)
.bind(msg.deliver_after)
.bind(i32::try_from(msg.max_attempts).unwrap_or(3))
.bind(msg.enqueued_by_principal.map(AdminUserId::into_inner))
.fetch_one(&self.pool)
.await?;
Ok(id.into())
}
async fn claim(
&self,
group_id: GroupId,
collection: &str,
) -> Result<Option<ClaimedGroupMessage>, GroupQueueRepoError> {
let token = Uuid::new_v4();
let row: Option<ClaimedRow> = sqlx::query_as(
"UPDATE group_queue_messages \
SET claim_token = $3, claimed_at = NOW(), attempt = attempt + 1 \
WHERE id = ( \
SELECT id FROM group_queue_messages \
WHERE group_id = $1 AND collection = $2 \
AND claim_token IS NULL \
AND (deliver_after IS NULL OR deliver_after <= NOW()) \
ORDER BY enqueued_at \
FOR UPDATE SKIP LOCKED \
LIMIT 1 \
) \
RETURNING id, group_id, collection, payload, enqueued_at, attempt, max_attempts",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(token)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| ClaimedGroupMessage {
id: r.id.into(),
group_id: r.group_id.into(),
collection: r.collection,
payload: r.payload,
enqueued_at: r.enqueued_at,
attempt: u32::try_from(r.attempt).unwrap_or(1),
max_attempts: u32::try_from(r.max_attempts).unwrap_or(3),
claim_token: token,
}))
}
async fn ack(
&self,
message_id: QueueMessageId,
claim_token: Uuid,
) -> Result<bool, GroupQueueRepoError> {
let res =
sqlx::query("DELETE FROM group_queue_messages WHERE id = $1 AND claim_token = $2")
.bind(message_id.into_inner())
.bind(claim_token)
.execute(&self.pool)
.await?;
Ok(res.rows_affected() == 1)
}
async fn nack(
&self,
message_id: QueueMessageId,
claim_token: Uuid,
retry_delay: chrono::Duration,
) -> Result<bool, GroupQueueRepoError> {
let next = Utc::now() + retry_delay;
let res = sqlx::query(
"UPDATE group_queue_messages \
SET claim_token = NULL, claimed_at = NULL, deliver_after = $3 \
WHERE id = $1 AND claim_token = $2",
)
.bind(message_id.into_inner())
.bind(claim_token)
.bind(next)
.execute(&self.pool)
.await?;
Ok(res.rows_affected() == 1)
}
async fn drop_exhausted(
&self,
message_id: QueueMessageId,
claim_token: Uuid,
) -> Result<bool, GroupQueueRepoError> {
let res =
sqlx::query("DELETE FROM group_queue_messages WHERE id = $1 AND claim_token = $2")
.bind(message_id.into_inner())
.bind(claim_token)
.execute(&self.pool)
.await?;
Ok(res.rows_affected() == 1)
}
async fn reclaim_visibility_timeouts(&self) -> Result<u64, GroupQueueRepoError> {
// A shared-queue consumer is a materialized app-owned trigger
// (`shared = TRUE`, `group_id` on the SOURCE template) whose
// queue_trigger_details.queue_name IS the shared collection name. Join
// the group template to recover the owning group + visibility timeout.
let res = sqlx::query(
"UPDATE group_queue_messages m \
SET claim_token = NULL, claimed_at = NULL \
FROM triggers copy \
JOIN triggers tmpl ON tmpl.id = copy.materialized_from \
JOIN queue_trigger_details d ON d.trigger_id = copy.id \
WHERE tmpl.group_id = m.group_id \
AND d.queue_name = m.collection \
AND tmpl.shared = TRUE \
AND m.claim_token IS NOT NULL \
AND m.claimed_at < NOW() - (d.visibility_timeout_secs || ' seconds')::INTERVAL",
)
.execute(&self.pool)
.await?;
Ok(res.rows_affected())
}
async fn depth(&self, group_id: GroupId, collection: &str) -> Result<u64, GroupQueueRepoError> {
let (n,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM ( \
SELECT 1 FROM group_queue_messages \
WHERE group_id = $1 AND collection = $2 LIMIT $3 \
) sub",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(QUEUE_DEPTH_SCAN_CAP)
.fetch_one(&self.pool)
.await?;
Ok(u64::try_from(n).unwrap_or(0))
}
async fn depth_pending(
&self,
group_id: GroupId,
collection: &str,
) -> Result<u64, GroupQueueRepoError> {
let (n,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM ( \
SELECT 1 FROM group_queue_messages \
WHERE group_id = $1 AND collection = $2 \
AND claim_token IS NULL \
AND (deliver_after IS NULL OR deliver_after <= NOW()) LIMIT $3 \
) sub",
)
.bind(group_id.into_inner())
.bind(collection)
.bind(QUEUE_DEPTH_SCAN_CAP)
.fetch_one(&self.pool)
.await?;
Ok(u64::try_from(n).unwrap_or(0))
}
}
#[derive(sqlx::FromRow)]
struct ClaimedRow {
id: Uuid,
group_id: Uuid,
collection: String,
payload: serde_json::Value,
enqueued_at: DateTime<Utc>,
attempt: i32,
max_attempts: i32,
}

View File

@@ -0,0 +1,152 @@
//! `GroupQueueServiceImpl` — wires `GroupQueueRepo` + the group-collection
//! registry underneath the `picloud_shared::GroupQueueService` trait that
//! scripts reach via the `queue::shared_collection("name")` Rhai handle
//! (§11.6 D3).
//!
//! Resolves the OWNING GROUP (kind `queue`) from `cx.app_id`'s chain (the
//! isolation boundary), enforces editor+ (`GroupQueueEnqueue`, fails closed on
//! anon — enqueue is a shared write), caps the payload size, then writes to the
//! group-keyed store. Consumption is out of band (competing per-descendant
//! materialized consumers, in the dispatcher).
use std::sync::Arc;
use async_trait::async_trait;
use chrono::Utc;
use picloud_shared::{
EnqueueOpts, GroupId, GroupQueueError, GroupQueueService, QueueMessageId, SdkCallCx,
};
use crate::authz::{self, AuthzRepo, Capability};
use crate::group_collection_repo::GroupCollectionResolver;
use crate::group_queue_repo::{GroupQueueRepo, NewGroupQueueMessage};
use crate::queue_service::queue_max_payload_bytes_from_env;
/// The registry `kind` this service resolves.
const KIND_QUEUE: &str = "queue";
pub struct GroupQueueServiceImpl {
repo: Arc<dyn GroupQueueRepo>,
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
max_payload_bytes: usize,
}
impl GroupQueueServiceImpl {
#[must_use]
pub fn new(
repo: Arc<dyn GroupQueueRepo>,
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
) -> Self {
Self {
repo,
resolver,
authz,
max_payload_bytes: queue_max_payload_bytes_from_env(),
}
}
async fn owning_group(
&self,
cx: &SdkCallCx,
collection: &str,
) -> Result<GroupId, GroupQueueError> {
if collection.is_empty() {
return Err(GroupQueueError::InvalidCollection);
}
self.resolver
.resolve_owning_group(cx.app_id, collection, KIND_QUEUE)
.await
.map_err(|e| GroupQueueError::Backend(e.to_string()))?
.ok_or_else(|| GroupQueueError::CollectionNotShared(collection.to_string()))
}
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupQueueError> {
authz::script_gate_require_principal(
&*self.authz,
cx,
Capability::GroupQueueEnqueue(group_id),
|| GroupQueueError::Forbidden,
GroupQueueError::Backend,
)
.await
}
}
#[async_trait]
impl GroupQueueService for GroupQueueServiceImpl {
async fn enqueue(
&self,
cx: &SdkCallCx,
collection: &str,
payload: serde_json::Value,
opts: EnqueueOpts,
) -> Result<QueueMessageId, GroupQueueError> {
if collection.is_empty() {
return Err(GroupQueueError::InvalidCollection);
}
// Size cap first (no DB) — a cheap reject before resolve/authz.
let encoded_len = serde_json::to_vec(&payload)
.map(|v| v.len())
.map_err(|e| GroupQueueError::Backend(format!("encode payload: {e}")))?;
if encoded_len > self.max_payload_bytes {
return Err(GroupQueueError::PayloadTooLarge {
limit: self.max_payload_bytes,
actual: encoded_len,
});
}
let max_attempts = opts.max_attempts.unwrap_or(3);
if !(1..=20).contains(&max_attempts) {
return Err(GroupQueueError::InvalidOpts(
"queue::enqueue: max_attempts must be in [1, 20]".into(),
));
}
if let Some(delay) = opts.delay_ms {
if !(0..=86_400_000).contains(&delay) {
return Err(GroupQueueError::InvalidOpts(
"queue::enqueue: delay_ms must be in [0, 86_400_000]".into(),
));
}
}
let group_id = self.owning_group(cx, collection).await?;
self.check_write(cx, group_id).await?;
let deliver_after = opts
.delay_ms
.and_then(chrono::Duration::try_milliseconds)
.map(|d| Utc::now() + d);
self.repo
.enqueue(NewGroupQueueMessage {
group_id,
collection: collection.to_string(),
payload,
deliver_after,
max_attempts,
enqueued_by_principal: cx.principal.as_ref().map(|p| p.user_id),
})
.await
.map_err(|e| GroupQueueError::Backend(e.to_string()))
}
async fn depth(&self, cx: &SdkCallCx, collection: &str) -> Result<u64, GroupQueueError> {
let group_id = self.owning_group(cx, collection).await?;
// Depth is a read — reads are open (any subtree script).
self.repo
.depth(group_id, collection)
.await
.map_err(|e| GroupQueueError::Backend(e.to_string()))
}
async fn depth_pending(
&self,
cx: &SdkCallCx,
collection: &str,
) -> Result<u64, GroupQueueError> {
let group_id = self.owning_group(cx, collection).await?;
self.repo
.depth_pending(group_id, collection)
.await
.map_err(|e| GroupQueueError::Backend(e.to_string()))
}
}

View File

@@ -0,0 +1,44 @@
//! §11.6 per-group quotas — global env-var ceilings on a group's shared
//! collections, enforced in the group write path (mirrors the per-value
//! `PICLOUD_*_MAX_*_BYTES` caps). One default applies to every group; per-group
//! configurable limits are deferred.
//!
//! - KV / docs: a per-group ROW-count ceiling (across the group's collections of
//! that kind). An update of an existing key/doc is net-zero and exempt.
//! - files: a per-group TOTAL-BYTES ceiling (sum of stored blob sizes).
/// Default per-group shared-KV row ceiling. Override `PICLOUD_GROUP_KV_MAX_ROWS`.
pub const DEFAULT_GROUP_KV_MAX_ROWS: u64 = 100_000;
/// Default per-group shared-docs row ceiling. Override `PICLOUD_GROUP_DOCS_MAX_ROWS`.
pub const DEFAULT_GROUP_DOCS_MAX_ROWS: u64 = 100_000;
/// Default per-group shared-files total-bytes ceiling (10 GiB). Override
/// `PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES`.
pub const DEFAULT_GROUP_FILES_MAX_TOTAL_BYTES: u64 = 10 * 1024 * 1024 * 1024;
fn u64_from_env(var: &str, default: u64) -> u64 {
if let Ok(v) = std::env::var(var) {
match v.trim().parse::<u64>() {
Ok(n) if n > 0 => return n,
_ => tracing::warn!(value = %v, "ignoring invalid {var} (want a positive integer)"),
}
}
default
}
#[must_use]
pub fn group_kv_max_rows_from_env() -> u64 {
u64_from_env("PICLOUD_GROUP_KV_MAX_ROWS", DEFAULT_GROUP_KV_MAX_ROWS)
}
#[must_use]
pub fn group_docs_max_rows_from_env() -> u64 {
u64_from_env("PICLOUD_GROUP_DOCS_MAX_ROWS", DEFAULT_GROUP_DOCS_MAX_ROWS)
}
#[must_use]
pub fn group_files_max_total_bytes_from_env() -> u64 {
u64_from_env(
"PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES",
DEFAULT_GROUP_FILES_MAX_TOTAL_BYTES,
)
}

View File

@@ -0,0 +1,240 @@
//! `/api/v1/admin/groups/{id}/scripts*` — group-owned script admin (Phase 4).
//!
//! * `GET /groups/{id}/scripts` — list the group's own scripts (not
//! inherited; just rows owned directly by this group). Gated by
//! `GroupScriptsRead` (viewer+ on the group).
//! * `POST /groups/{id}/scripts` — create a group-owned script. Gated by
//! `GroupScriptsWrite` (editor+ on the group).
//!
//! A group script is a **template** inherited by every descendant app,
//! resolved by name with nearest-owner-wins (CoW). Get / update / delete of an
//! existing group script go through the by-id `/scripts/{id}` endpoints, which
//! are owner-polymorphic — a group script there gates on `GroupScripts*`.
//!
//! **Phase 4-lite scope:** group ENDPOINT scripts only, and they must be
//! self-contained — `kind=module` and any `import` are rejected here, because
//! the origin-aware (lexical) module resolver is Phase 4b. The owner is
//! resolved FIRST (slug-or-uuid) and `authz::require` binds the capability to
//! the resolved group id, never to a caller-controlled path param.
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::get;
use axum::{Extension, Router};
use picloud_shared::{
GroupId, Principal, Script, ScriptKind, ScriptSandbox, ScriptValidator, ValidationError,
};
use serde::Deserialize;
use serde_json::json;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::group_repo::GroupRepository;
use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError};
use crate::sandbox::SandboxCeiling;
#[derive(Clone)]
pub struct GroupScriptsState {
pub scripts: Arc<dyn ScriptRepository>,
pub groups: Arc<dyn GroupRepository>,
pub authz: Arc<dyn AuthzRepo>,
pub validator: Arc<dyn ScriptValidator>,
pub sandbox_ceiling: SandboxCeiling,
}
pub fn group_scripts_router(state: GroupScriptsState) -> Router {
Router::new()
.route(
"/groups/{group_id}/scripts",
get(list_group_scripts).post(create_group_script),
)
.with_state(state)
}
#[derive(Debug, Deserialize)]
pub struct CreateGroupScriptRequest {
pub name: String,
pub description: Option<String>,
pub source: String,
/// Phase 4-lite accepts only `endpoint`. A `module` is rejected (group
/// modules + the lexical import resolver are Phase 4b).
#[serde(default)]
pub kind: ScriptKind,
pub timeout_seconds: Option<i32>,
pub memory_limit_mb: Option<i32>,
#[serde(default)]
pub sandbox: ScriptSandbox,
}
async fn list_group_scripts(
State(s): State<GroupScriptsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<Script>>, GroupScriptsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupScriptsRead(group_id),
)
.await?;
Ok(Json(s.scripts.list_for_group(group_id).await?))
}
async fn create_group_script(
State(s): State<GroupScriptsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<CreateGroupScriptRequest>,
) -> Result<(StatusCode, Json<Script>), GroupScriptsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupScriptsWrite(group_id),
)
.await?;
// Phase 4b: group modules + imports are allowed. Validate per kind,
// mirroring the app-script create path (`api.rs`): a module gets the
// stricter shape check + the reserved-name guard; an endpoint the
// parse-only path. Without the kind branch a malformed-shape group
// module would be accepted here and only fail later at import time,
// and a reserved name (`kv`, `log`, …) would slip through. Imports
// resolve lexically from this group's chain at runtime (§5.5); the
// recorded edges feed the declarative dangling-import plan check.
let validated = if input.kind == ScriptKind::Module {
if crate::api::RESERVED_MODULE_NAMES.contains(&input.name.as_str()) {
return Err(GroupScriptsApiError::Invalid(format!(
"{:?} is a reserved module name (shadows a built-in SDK namespace)",
input.name
)));
}
s.validator.validate_module(&input.source)?
} else {
s.validator.validate(&input.source)?
};
s.sandbox_ceiling
.check(&input.sandbox)
.map_err(|e| GroupScriptsApiError::Invalid(e.to_string()))?;
let created = s
.scripts
.create(NewScript {
app_id: None,
group_id: Some(group_id),
name: input.name,
description: input.description,
source: input.source,
kind: input.kind,
timeout_seconds: input.timeout_seconds,
memory_limit_mb: input.memory_limit_mb,
sandbox: if input.sandbox.is_empty() {
None
} else {
Some(input.sandbox)
},
enabled: true,
imports: validated.imports,
})
.await?;
Ok((StatusCode::CREATED, Json(created)))
}
async fn resolve_group(
groups: &dyn GroupRepository,
ident: &str,
) -> Result<GroupId, GroupScriptsApiError> {
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
groups
.get_by_id(uuid.into())
.await
.map_err(|e| GroupScriptsApiError::Backend(e.to_string()))?
} else {
groups
.get_by_slug(ident)
.await
.map_err(|e| GroupScriptsApiError::Backend(e.to_string()))?
};
found
.map(|g| g.id)
.ok_or(GroupScriptsApiError::GroupNotFound)
}
#[derive(Debug, thiserror::Error)]
pub enum GroupScriptsApiError {
#[error("group not found")]
GroupNotFound,
#[error("invalid request: {0}")]
Invalid(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("scripts backend: {0}")]
Backend(String),
}
impl From<AuthzDenied> for GroupScriptsApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl From<AuthzError> for GroupScriptsApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl From<ValidationError> for GroupScriptsApiError {
fn from(e: ValidationError) -> Self {
Self::Invalid(e.to_string())
}
}
impl From<ScriptRepositoryError> for GroupScriptsApiError {
fn from(e: ScriptRepositoryError) -> Self {
match e {
ScriptRepositoryError::Conflict(m) => Self::Conflict(m),
ScriptRepositoryError::NotFound(id) => Self::Invalid(format!("script {id} not found")),
ScriptRepositoryError::Db(e) => Self::Backend(e.to_string()),
}
}
}
impl IntoResponse for GroupScriptsApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::GroupNotFound => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
Self::Invalid(_) => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::Conflict(_) => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "group-scripts admin authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Backend(e) => {
tracing::error!(error = %e, "group-scripts admin backend error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

@@ -22,6 +22,8 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use picloud_orchestrator_core::routing::RouteTable;
use crate::admin_user_repo::{AdminUserRepository, AdminUserRow};
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
@@ -29,6 +31,7 @@ use crate::group_members_repo::{
GroupMembersRepository, GroupMembersRepositoryError, GroupMembershipDetail, GroupMembershipRow,
};
use crate::group_repo::{GroupRepository, GroupRepositoryError};
use crate::route_repo::RouteRepository;
const SLUG_MAX: usize = 63;
const RESERVED_SLUGS: &[&str] = &[
@@ -42,6 +45,15 @@ pub struct GroupsState {
pub apps: Arc<dyn AppRepository>,
pub users: Arc<dyn AdminUserRepository>,
pub authz: Arc<dyn AuthzRepo>,
/// §11 tail full-live invalidation: reparenting a group moves a whole
/// subtree, changing which ancestor-group route TEMPLATES its descendant
/// apps inherit — so the route snapshot is rebuilt after a reparent.
pub routes: Arc<dyn RouteRepository>,
pub route_table: Arc<RouteTable>,
/// §4.5 M5: reparenting moves a subtree, changing which ancestor-group
/// stateful templates its apps inherit — their materialized cron/queue
/// copies are reconciled after a reparent.
pub pool: sqlx::PgPool,
}
pub fn groups_router(state: GroupsState) -> Router {
@@ -280,6 +292,18 @@ async fn reparent_group(
}
};
let moved = s.groups.reparent(group.id, new_parent).await?;
// §11 tail: the moved subtree now inherits a different set of ancestor-group
// route TEMPLATES — rebuild the snapshot (best-effort; self-heals on the
// next route write or restart if it fails).
if let Err(e) = crate::route_admin::rebuild_route_table(s.routes.as_ref(), &s.route_table).await
{
tracing::warn!(error = %e, "groups: route table refresh after reparent failed; it will self-heal");
}
// §4.5 M5: the moved subtree inherits a different set of ancestor-group
// stateful templates — reconcile its materialized copies.
if let Err(e) = crate::materialize::rematerialize_stateful_templates(&s.pool).await {
tracing::warn!(error = %e, "groups: stateful-template materialization after reparent failed; it will self-heal");
}
Ok(Json(moved))
}

View File

@@ -80,7 +80,11 @@ impl InvokeServiceImpl {
.await
.map_err(|e| InvokeError::Backend(e.to_string()))?
.ok_or_else(|| InvokeError::NotFound(format!("id {script_id}")))?;
if script.app_id != cx.app_id {
// Phase 4: group scripts (`app_id: None`) fail closed here — invoke()
// by-id stays app-owned until chain-aware resolution lands. The
// execution context is the *caller's* app (`cx.app_id`), never read
// off the target script.
if !script.is_owned_by_app(cx.app_id) {
return Err(InvokeError::CrossApp);
}
if !script.enabled {
@@ -90,7 +94,8 @@ impl InvokeServiceImpl {
}
Ok(ResolvedScript {
script_id: script.id,
app_id: script.app_id,
app_id: cx.app_id,
owner: script.owner(),
source: script.source,
updated_at: script.updated_at,
name: script.name,
@@ -102,9 +107,13 @@ impl InvokeServiceImpl {
cx: &SdkCallCx,
name: &str,
) -> Result<ResolvedScript, InvokeError> {
// Phase 4: resolve down the app's chain — an inherited group script
// of this name is reachable, nearest-owner-wins (an app's own script
// shadows it). The chain only contains the app + its ancestors, so the
// resolved script is invocable by `cx.app_id` by construction.
let script = self
.scripts
.get_by_name(cx.app_id, name)
.get_by_name_inherited(cx.app_id, name)
.await
.map_err(|e| InvokeError::Backend(e.to_string()))?
.ok_or_else(|| InvokeError::NotFound(format!("name {name:?}")))?;
@@ -113,7 +122,12 @@ impl InvokeServiceImpl {
}
Ok(ResolvedScript {
script_id: script.id,
app_id: script.app_id,
// The execution-context app is always the caller's app — a group
// script runs under the inheriting app, never its owner.
app_id: cx.app_id,
// …but its `import`s resolve from its *defining node* (the group
// for an inherited script) — the lexical origin (§5.5).
owner: script.owner(),
source: script.source,
updated_at: script.updated_at,
name: script.name,
@@ -227,19 +241,40 @@ mod tests {
name: &str,
) -> Result<Option<Script>, ScriptRepositoryError> {
Ok(
if app_id == self.script.app_id && name == self.script.name {
if self.script.app_id == Some(app_id) && name == self.script.name {
Some(self.script.clone())
} else {
None
},
)
}
async fn get_by_name_inherited(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<Script>, ScriptRepositoryError> {
// This one-script repo has no hierarchy; inherited == own-scope.
self.get_by_name(app_id, name).await
}
async fn is_invocable_by_app(
&self,
script_id: ScriptId,
_app_id: AppId,
) -> Result<bool, ScriptRepositoryError> {
Ok(script_id == self.script.id)
}
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
Ok(vec![self.script.clone()])
}
async fn list_for_app(&self, _app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
unimplemented!()
}
async fn list_for_group(
&self,
_group_id: picloud_shared::GroupId,
) -> Result<Vec<Script>, ScriptRepositoryError> {
unimplemented!()
}
async fn list_for_user(
&self,
_user_id: AdminUserId,
@@ -312,7 +347,8 @@ mod tests {
fn make_script(app_id: AppId, name: &str) -> Script {
Script {
id: ScriptId::new(),
app_id,
app_id: Some(app_id),
group_id: None,
name: name.into(),
description: None,
version: 1,

View File

@@ -31,6 +31,8 @@ pub mod auth_api;
pub mod auth_bootstrap;
pub mod auth_middleware;
pub mod authz;
pub mod config_api;
pub mod config_resolver;
pub mod cron_scheduler;
pub mod dead_letter_repo;
pub mod dead_letter_service;
@@ -42,13 +44,27 @@ pub mod docs_repo;
pub mod docs_service;
pub mod email_inbound_api;
pub mod email_service;
pub mod extension_point_repo;
pub mod files_api;
pub mod files_repo;
pub mod files_service;
pub mod files_sweep;
pub mod gc;
pub mod group_blobs_api;
pub mod group_collection_repo;
pub mod group_docs_repo;
pub mod group_docs_service;
pub mod group_files_repo;
pub mod group_files_service;
pub mod group_kv_repo;
pub mod group_kv_service;
pub mod group_members_repo;
pub mod group_pubsub_service;
pub mod group_queue_repo;
pub mod group_queue_service;
pub mod group_quota;
pub mod group_repo;
pub mod group_scripts_api;
pub mod groups_api;
pub mod http_service;
pub mod invoke_service;
@@ -57,6 +73,7 @@ pub mod kv_repo;
pub mod kv_service;
pub mod log_sink;
pub mod login_rate_limit;
pub mod materialize;
pub mod migrations;
pub mod module_source;
pub mod outbox_event_emitter;
@@ -77,6 +94,7 @@ pub mod secrets_api;
pub mod secrets_repo;
pub mod secrets_service;
pub mod ssrf;
pub mod suppression_repo;
pub mod topic_repo;
pub mod topics_api;
pub mod trigger_config;
@@ -84,6 +102,9 @@ pub mod trigger_repo;
pub mod triggers_api;
pub mod users_admin_api;
pub mod users_service;
pub mod vars_api;
pub mod vars_repo;
pub mod vars_service;
pub use abandoned_repo::{
AbandonedRepo, AbandonedRepoError, NewAbandonedExecution, PostgresAbandonedRepo,
@@ -146,6 +167,7 @@ pub use auth_middleware::{
API_KEY_PREFIX, API_KEY_PREFIX_LEN,
};
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
pub use config_api::{config_router, ConfigApiError, ConfigApiState};
pub use cron_scheduler::spawn_cron_scheduler;
pub use dead_letter_repo::{
DeadLetterRepo, DeadLetterRepoError, DeadLetterRow, NewDeadLetter, PostgresDeadLetterRepo,
@@ -168,14 +190,27 @@ pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo};
pub use files_service::FilesServiceImpl;
pub use files_sweep::{spawn_files_orphan_sweep, sweep_orphan_tmp_files, SweepStats};
pub use gc::{spawn_abandoned_gc, spawn_app_user_token_gc, spawn_dead_letter_gc};
pub use group_collection_repo::{GroupCollectionResolver, PostgresGroupCollectionResolver};
pub use group_docs_repo::{GroupDocsRepo, GroupDocsRepoError, PostgresGroupDocsRepo};
pub use group_docs_service::GroupDocsServiceImpl;
pub use group_files_repo::{FsGroupFilesRepo, GroupFilesRepo, GroupFilesRepoError};
pub use group_files_service::GroupFilesServiceImpl;
pub use group_kv_repo::{GroupKvRepo, GroupKvRepoError, PostgresGroupKvRepo};
pub use group_kv_service::GroupKvServiceImpl;
pub use group_members_repo::{
GroupMembersRepository, GroupMembersRepositoryError, GroupMembershipDetail, GroupMembershipRow,
PostgresGroupMembersRepository,
};
pub use group_pubsub_service::GroupPubsubServiceImpl;
pub use group_queue_repo::{
GroupQueueRepo, GroupQueueRepoError, NewGroupQueueMessage, PostgresGroupQueueRepo,
};
pub use group_queue_service::GroupQueueServiceImpl;
pub use group_repo::{
GroupChildCounts, GroupRepository, GroupRepositoryError, PostgresGroupRepository,
ROOT_GROUP_SLUG,
};
pub use group_scripts_api::{group_scripts_router, GroupScriptsApiError, GroupScriptsState};
pub use groups_api::{groups_router, GroupsApiError, GroupsState};
pub use http_service::{HttpConfig, HttpServiceImpl};
pub use kv_api::{kv_admin_router, KvAdminState};
@@ -195,16 +230,18 @@ pub use repo::{
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
RepoResolver, ScriptPatch, ScriptRepository, ScriptRepositoryError,
};
pub use route_admin::{compile_routes, route_admin_router, RouteAdminState};
pub use route_admin::{
compile_effective_routes, rebuild_route_table, route_admin_router, RouteAdminState,
};
pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository};
pub use sandbox::{CeilingError, SandboxCeiling};
pub use secrets_api::{secrets_router, SecretsApiError, SecretsState};
pub use secrets_repo::{
PostgresSecretsRepo, SecretMeta, SecretsMetaPage, SecretsNamePage, SecretsRepo,
PostgresSecretsRepo, ResolvedSecret, SecretMeta, SecretsMetaPage, SecretsNamePage, SecretsRepo,
SecretsRepoError, StoredSecret,
};
pub use secrets_service::{
open as open_secret, seal as seal_secret, SecretsConfig, SecretsServiceImpl,
open as open_secret, seal as seal_secret, SecretOwner, SecretsConfig, SecretsServiceImpl,
DEFAULT_SECRET_MAX_VALUE_BYTES,
};
pub use topic_repo::{PostgresTopicRepo, Topic, TopicAuthMode, TopicRepo, TopicRepoError};
@@ -219,3 +256,6 @@ pub use trigger_repo::{
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};
pub use users_admin_api::{app_users_router, AppUsersApiError, AppUsersState};
pub use users_service::{UsersServiceConfig, UsersServiceImpl};
pub use vars_api::{vars_router, VarsApiError, VarsApiState};
pub use vars_repo::{PostgresVarsRepo, VarOwner, VarRow, VarsRepo, VarsRepoError};
pub use vars_service::VarsServiceImpl;

View File

@@ -0,0 +1,247 @@
//! §4.5 M5: materialization of STATEFUL group trigger templates.
//!
//! A group-owned cron / queue / email trigger is a TEMPLATE that is never
//! dispatched directly (the scheduler / queue consumer / email webhook all
//! filter `t.app_id IS NOT NULL`). Instead this reconciler expands each template
//! into an app-owned copy — `materialized_from = template.id` — for every
//! descendant app, and removes copies whose (app, template) pair no longer
//! inherits. The copy owns its per-app state (cron `last_fired_at`, the queue
//! advisory lock), so the unchanged dispatch paths work against it as if it were
//! hand-authored. An EMAIL copy is a verbatim byte-copy of the group template's
//! sealed inbound secret — the group resolved + sealed it against its own store
//! once at apply (shared-group-secret model), and email secrets are v0/no-AAD so
//! the ciphertext is portable across rows; no master key is needed here.
//!
//! Full-live like `rebuild_route_table`: called at the same chokepoints (apply,
//! app create/delete, group reparent). A precise create/delete diff (not
//! delete-all-then-recreate) preserves `last_fired_at` on unchanged cron copies.
//!
//! Returns per-app WARNINGS (e.g. a queue whose consumer slot is already taken)
//! — the caller surfaces them; materialization of the other pairs still
//! proceeds.
use std::collections::HashSet;
use sqlx::PgPool;
use uuid::Uuid;
/// The stateful kinds that materialize (M5.2 cron; M5.4 queue; M5.5 email).
const MATERIALIZED_KINDS: &[&str] = &["cron", "queue", "email"];
/// Fixed advisory-lock key serializing all materialization reconcilers (§4.5
/// M5). Two reconcilers running concurrently (e.g. two parallel app-creates)
/// would otherwise (a) read `should` and `existing` at different READ-COMMITTED
/// snapshots — a copy created by one between the two reads could be spuriously
/// DELETEd by the other (a lost cron copy that only self-heals on the next
/// mutation), and (b) both pass the queue one-consumer check and double-fill a
/// slot. An xact-scoped advisory lock makes each reconcile atomic w.r.t. the
/// others; reconciles are infrequent + fast, so serializing is cheap.
const REMATERIALIZE_LOCK_KEY: i64 = 0x0004_0005_0041_054d; // "M5" materialize marker
/// (descendant app, source template, kind) that SHOULD have a materialized copy.
#[derive(sqlx::FromRow)]
struct ShouldRow {
effective_app_id: Uuid,
template_id: Uuid,
kind: String,
/// §11.6 D3: a `shared` queue template's copies drain the GROUP store as
/// COMPETING consumers, so the one-consumer-per-(app, queue) slot check is
/// skipped for them (each descendant intentionally gets a consumer).
shared: bool,
}
/// Reconcile all materialized stateful-template copies. Idempotent; safe to call
/// on every tree/apply mutation. Best-effort per pair — a pair that can't
/// materialize (queue slot taken, missing email secret) yields a warning and is
/// skipped, not an error.
pub async fn rematerialize_stateful_templates(pool: &PgPool) -> Result<Vec<String>, sqlx::Error> {
let mut tx = pool.begin().await?;
// Serialize reconcilers (see REMATERIALIZE_LOCK_KEY): the lock is held until
// this transaction commits, so the `should`/`existing` reads below are
// snapshot-consistent relative to any other reconcile and the queue slot
// check can't race.
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(REMATERIALIZE_LOCK_KEY)
.execute(&mut *tx)
.await?;
// Every (descendant app × ancestor-group stateful template) that should
// exist — the all-apps `app_chain` CTE (each app × its ancestor chain) ⋈
// group-owned stateful templates.
let kinds: Vec<String> = MATERIALIZED_KINDS
.iter()
.map(|s| (*s).to_string())
.collect();
let should: Vec<ShouldRow> = sqlx::query_as(
"WITH RECURSIVE app_chain AS ( \
SELECT a.id AS effective_app_id, a.group_id AS owner_group, \
a.group_id AS next_group, 0 AS depth \
FROM apps a WHERE a.group_id IS NOT NULL \
UNION ALL \
SELECT ac.effective_app_id, g.parent_id, g.parent_id, ac.depth + 1 \
FROM groups g JOIN app_chain ac ON g.id = ac.next_group \
WHERE ac.depth < 64 AND g.parent_id IS NOT NULL \
) \
SELECT DISTINCT ac.effective_app_id, t.id AS template_id, t.kind, t.shared \
FROM app_chain ac \
JOIN triggers t ON t.group_id = ac.owner_group \
WHERE t.group_id IS NOT NULL AND t.enabled = TRUE AND t.kind = ANY($1)",
)
.bind(&kinds)
.fetch_all(&mut *tx)
.await?;
let should_set: HashSet<(Uuid, Uuid)> = should
.iter()
.map(|r| (r.effective_app_id, r.template_id))
.collect();
// Existing managed copies.
let existing: Vec<(Uuid, Uuid)> = sqlx::query_as(
"SELECT app_id, materialized_from FROM triggers WHERE materialized_from IS NOT NULL",
)
.fetch_all(&mut *tx)
.await?;
let existing_set: HashSet<(Uuid, Uuid)> = existing.iter().copied().collect();
// Delete stale copies (template gone or app no longer a descendant).
for (app_id, template_id) in &existing_set {
if !should_set.contains(&(*app_id, *template_id)) {
sqlx::query("DELETE FROM triggers WHERE app_id = $1 AND materialized_from = $2")
.bind(app_id)
.bind(template_id)
.execute(&mut *tx)
.await?;
}
}
// Create missing copies.
let mut warnings = Vec::new();
for r in &should {
if existing_set.contains(&(r.effective_app_id, r.template_id)) {
continue;
}
if let Some(w) = materialize_one(
&mut tx,
r.effective_app_id,
r.template_id,
&r.kind,
r.shared,
)
.await?
{
warnings.push(w);
}
}
tx.commit().await?;
Ok(warnings)
}
/// Create one app-owned copy of a group template + its per-kind detail. Returns
/// `Some(warning)` when the pair is intentionally skipped (e.g. a queue slot the
/// app already fills). `name` uses the column default (a fresh UUID) so it can't
/// collide with the app's own trigger names.
async fn materialize_one(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
app_id: Uuid,
template_id: Uuid,
kind: &str,
shared: bool,
) -> Result<Option<String>, sqlx::Error> {
// §M5.4: a per-app queue copy would violate the one-consumer-per-(app_id,
// queue_name) invariant if the app already has a consumer on that queue —
// skip with a warning. §11.6 D3: a SHARED queue copy drains the group store
// as a competing consumer (a different store), so the slot check is skipped
// — every descendant intentionally gets a consumer.
if kind == "queue" && !shared {
let taken: Option<(Uuid,)> = sqlx::query_as(
"SELECT t.id FROM triggers t \
JOIN queue_trigger_details d ON d.trigger_id = t.id \
WHERE t.app_id = $1 AND t.kind = 'queue' \
AND d.queue_name = ( \
SELECT queue_name FROM queue_trigger_details WHERE trigger_id = $2)",
)
.bind(app_id)
.bind(template_id)
.fetch_optional(&mut **tx)
.await?;
if taken.is_some() {
return Ok(Some(format!(
"queue template not materialized for one app — it already has a \
consumer on that queue (app_id={app_id})"
)));
}
}
// Parent row: copy the template's settings, owned by the app, linked back.
// `ON CONFLICT DO NOTHING` (against the (app_id, materialized_from) partial
// unique index) makes this idempotent under concurrent reconciles — the
// loser gets no RETURNING row and skips the detail insert.
let new_id: Option<(Uuid,)> = sqlx::query_as(
"INSERT INTO triggers ( \
app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, materialized_from) \
SELECT $1, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, id \
FROM triggers WHERE id = $2 \
ON CONFLICT DO NOTHING RETURNING id",
)
.bind(app_id)
.bind(template_id)
.fetch_optional(&mut **tx)
.await?;
let Some(new_id) = new_id else {
// A concurrent reconcile already created this copy.
return Ok(None);
};
match kind {
"cron" => {
// A fresh copy starts with last_fired_at NULL → fires on next due.
sqlx::query(
"INSERT INTO cron_trigger_details (trigger_id, schedule, timezone) \
SELECT $1, schedule, timezone FROM cron_trigger_details WHERE trigger_id = $2",
)
.bind(new_id.0)
.bind(template_id)
.execute(&mut **tx)
.await?;
}
"queue" => {
sqlx::query(
"INSERT INTO queue_trigger_details \
(trigger_id, queue_name, visibility_timeout_secs) \
SELECT $1, queue_name, visibility_timeout_secs \
FROM queue_trigger_details WHERE trigger_id = $2",
)
.bind(new_id.0)
.bind(template_id)
.execute(&mut **tx)
.await?;
}
"email" => {
// §M5.5: the group template sealed its inbound HMAC secret against
// the GROUP's own store once at apply (shared-group-secret model).
// The secret is v0 (no AAD) — bound only to the master key, not the
// owning row — so a verbatim byte-copy is a valid per-app secret; no
// master key is needed here. Each copy has its own trigger_id (its
// own inbound webhook URL).
sqlx::query(
"INSERT INTO email_trigger_details \
(trigger_id, inbound_secret_encrypted, inbound_secret_nonce) \
SELECT $1, inbound_secret_encrypted, inbound_secret_nonce \
FROM email_trigger_details WHERE trigger_id = $2",
)
.bind(new_id.0)
.bind(template_id)
.execute(&mut **tx)
.await?;
}
_ => {}
}
Ok(None)
}

View File

@@ -1,18 +1,29 @@
//! `PostgresModuleSource` — the Postgres-backed `ModuleSource` impl.
//!
//! Mirrors the structure of [`crate::kv_repo::PostgresKvRepo`] /
//! [`crate::docs_repo::PostgresDocsRepo`]: thin wrapper around a
//! `PgPool` that owns a single statement returning the module by
//! `(cx.app_id, name, kind = 'module')`. The resolver lives in
//! `executor-core` and consumes this trait through the `Services`
//! bundle, so manager-core stays the only crate that touches
//! Postgres.
//! `resolve` is **lexical** (Phase 4b): a module is looked up by walking the
//! group chain rooted at the **importing script's defining node** (`origin`),
//! nearest-owner-wins — *not* the inheriting app's effective view. An `App`
//! origin walks `app → its group → ancestors` (reusing [`CHAIN_LEVELS_CTE`]);
//! a `Group` origin walks `group → ancestors` ([`GROUP_CHAIN_LEVELS_CTE`]) and
//! never sees app-owned modules below it (the trust boundary).
//!
//! `resolve_policy` adds §5.5 **extension points**: the nearest declaration of
//! a name on `origin`'s chain decides lexical (concrete module) vs **dynamic**
//! (an extension-point marker → resolve against the inheriting app so it can
//! override, falling back to a default body up-chain). EP wins a depth tie.
//!
//! The resolver lives in `executor-core` and consumes this trait through the
//! `Services` bundle, so manager-core stays the only crate that touches Postgres.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{ModuleScript, ModuleSource, ModuleSourceError, SdkCallCx};
use picloud_shared::{
AppId, ModuleResolution, ModuleScript, ModuleSource, ModuleSourceError, ScriptOwner,
};
use sqlx::PgPool;
use crate::config_resolver::{CHAIN_LEVELS_CTE, GROUP_CHAIN_LEVELS_CTE};
pub struct PostgresModuleSource {
pool: PgPool,
}
@@ -27,7 +38,8 @@ impl PostgresModuleSource {
#[derive(sqlx::FromRow)]
struct ModuleRow {
id: uuid::Uuid,
app_id: uuid::Uuid,
app_id: Option<uuid::Uuid>,
group_id: Option<uuid::Uuid>,
name: String,
source: String,
updated_at: DateTime<Utc>,
@@ -37,7 +49,8 @@ impl From<ModuleRow> for ModuleScript {
fn from(r: ModuleRow) -> Self {
Self {
script_id: r.id.into(),
app_id: r.app_id.into(),
app_id: r.app_id.map(Into::into),
group_id: r.group_id.map(Into::into),
name: r.name,
source: r.source,
updated_at: r.updated_at,
@@ -47,28 +60,116 @@ impl From<ModuleRow> for ModuleScript {
#[async_trait]
impl ModuleSource for PostgresModuleSource {
async fn lookup(
async fn resolve(
&self,
cx: &SdkCallCx,
origin: ScriptOwner,
name: &str,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
// The query is the cross-app isolation boundary: app_id comes
// from cx (never from the script-passed argument), and the
// CHECK constraint `kind IN ('endpoint','module')` plus the
// `kind = 'module'` filter together guarantee endpoint scripts
// are never importable. The `(app_id, kind)` index from
// migration 0015 makes this an index scan returning at most
// one row (per-app uniqueness on `name`).
let row: Option<ModuleRow> = sqlx::query_as(
"SELECT id, app_id, name, source, updated_at \
FROM scripts \
WHERE app_id = $1 AND kind = 'module' AND name = $2",
)
.bind(cx.app_id.into_inner())
.bind(name)
.fetch_optional(&self.pool)
.await
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
// Lexical lookup: JOIN module scripts against the chain rooted at
// `origin`, take the nearest level (lowest depth). The `kind =
// 'module'` filter + the CHECK constraint guarantee endpoint
// scripts are never importable. Case-insensitive to match the
// per-owner `LOWER(name)` partial-unique indexes (0050). `origin`
// is a trusted dispatch-derived value, so the chain it roots is
// the script's true ancestry — the cross-app isolation boundary
// is preserved (a group origin can't reach an app's modules).
let query = match origin {
ScriptOwner::App(_) => format!(
"{CHAIN_LEVELS_CTE} \
SELECT s.id, s.app_id, s.group_id, s.name, s.source, s.updated_at \
FROM scripts s \
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2) \
ORDER BY c.depth ASC LIMIT 1",
),
ScriptOwner::Group(_) => format!(
"{GROUP_CHAIN_LEVELS_CTE} \
SELECT s.id, s.app_id, s.group_id, s.name, s.source, s.updated_at \
FROM scripts s \
JOIN chain c ON s.group_id = c.group_owner \
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2) \
ORDER BY c.depth ASC LIMIT 1",
),
};
let root = match origin {
ScriptOwner::App(a) => a.into_inner(),
ScriptOwner::Group(g) => g.into_inner(),
};
let row: Option<ModuleRow> = sqlx::query_as(&query)
.bind(root)
.bind(name)
.fetch_optional(&self.pool)
.await
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
Ok(row.map(Into::into))
}
async fn resolve_policy(
&self,
origin: ScriptOwner,
inheriting_app: AppId,
name: &str,
) -> Result<ModuleResolution, ModuleSourceError> {
// §5.5 nearest-declaration-kind-wins. One round trip: the min chain
// depth at which `name` is declared as an extension-point marker vs a
// concrete module, walking up `origin`'s chain. The EP wins a tie (a
// marker + its co-located default body live at the same node).
let query = match origin {
ScriptOwner::App(_) => format!(
"{CHAIN_LEVELS_CTE} \
SELECT \
(SELECT MIN(c.depth) FROM extension_points e \
JOIN chain c ON (e.app_id = c.app_owner OR e.group_id = c.group_owner) \
WHERE LOWER(e.name) = LOWER($2)) AS ep_depth, \
(SELECT MIN(c.depth) FROM scripts s \
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2)) AS mod_depth",
),
ScriptOwner::Group(_) => format!(
"{GROUP_CHAIN_LEVELS_CTE} \
SELECT \
(SELECT MIN(c.depth) FROM extension_points e \
JOIN chain c ON e.group_id = c.group_owner \
WHERE LOWER(e.name) = LOWER($2)) AS ep_depth, \
(SELECT MIN(c.depth) FROM scripts s \
JOIN chain c ON s.group_id = c.group_owner \
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2)) AS mod_depth",
),
};
let root = match origin {
ScriptOwner::App(a) => a.into_inner(),
ScriptOwner::Group(g) => g.into_inner(),
};
let (ep_depth, mod_depth): (Option<i32>, Option<i32>) = sqlx::query_as(&query)
.bind(root)
.bind(name)
.fetch_one(&self.pool)
.await
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
// EP wins when it is declared and is no farther than the nearest
// concrete module (tie → EP). Then resolve dynamically against the
// inheriting app (its own override, else the default body up-chain).
let ep_wins = match (ep_depth, mod_depth) {
(Some(ep), Some(m)) => ep <= m,
(Some(_), None) => true,
_ => false,
};
if ep_wins {
return Ok(
match self.resolve(ScriptOwner::App(inheriting_app), name).await? {
Some(m) => ModuleResolution::Module(m),
None => ModuleResolution::NoProvider,
},
);
}
if mod_depth.is_some() {
// Concrete module nearest → lexical, exactly as Phase 4b.
return Ok(match self.resolve(origin, name).await? {
Some(m) => ModuleResolution::Module(m),
None => ModuleResolution::NotFound,
});
}
Ok(ModuleResolution::NotFound)
}
}

View File

@@ -19,7 +19,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{
DocsEventOp, EmitError, FileMeta, FilesEventOp, KvEventOp, SdkCallCx, ServiceEvent,
DocsEventOp, EmitError, FileMeta, FilesEventOp, GroupId, KvEventOp, SdkCallCx, ServiceEvent,
ServiceEventEmitter, TriggerEvent,
};
@@ -51,6 +51,130 @@ impl ServiceEventEmitter for OutboxEventEmitter {
_ => Ok(()),
}
}
#[allow(clippy::too_many_lines)] // one match arm per source kind (kv/docs/files)
async fn emit_shared(
&self,
cx: &SdkCallCx,
owning_group: GroupId,
event: ServiceEvent,
) -> Result<(), EmitError> {
// §11.6: a shared-collection write. Match `shared = true` triggers on
// the OWNING group; each fires under the WRITER app (`cx.app_id`), the
// same "group template runs under the firing app" model.
let source_kind = match event.source {
"kv" => OutboxSourceKind::Kv,
"docs" => OutboxSourceKind::Docs,
"files" => OutboxSourceKind::Files,
_ => return Ok(()),
};
let Some(collection) = event.collection.clone() else {
return Ok(());
};
let (matches, trigger_event) = match event.source {
"kv" => {
let Some(op) = KvEventOp::from_wire(event.op) else {
return Ok(());
};
let m = self
.triggers
.list_matching_shared_kv(owning_group, &collection, op)
.await
.map_err(|e| EmitError::Unavailable(format!("trigger lookup: {e}")))?;
let ev = TriggerEvent::Kv {
op,
collection,
key: event.key.clone().unwrap_or_default(),
value: event.payload.clone(),
};
(
m.into_iter()
.map(|x| (x.trigger_id, x.script_id))
.collect::<Vec<_>>(),
ev,
)
}
"docs" => {
let Some(op) = DocsEventOp::from_wire(event.op) else {
return Ok(());
};
let m = self
.triggers
.list_matching_shared_docs(owning_group, &collection, op)
.await
.map_err(|e| EmitError::Unavailable(format!("trigger lookup: {e}")))?;
let ev = TriggerEvent::Docs {
op,
collection,
id: event.key.clone().unwrap_or_default(),
data: event.payload.clone(),
prev_data: event.old_payload.clone(),
};
(
m.into_iter()
.map(|x| (x.trigger_id, x.script_id))
.collect::<Vec<_>>(),
ev,
)
}
"files" => {
let Some(op) = FilesEventOp::from_wire(event.op) else {
return Ok(());
};
let Some(meta) = event
.payload
.clone()
.and_then(|v| serde_json::from_value::<FileMeta>(v).ok())
else {
return Ok(());
};
let m = self
.triggers
.list_matching_shared_files(owning_group, &collection, op)
.await
.map_err(|e| EmitError::Unavailable(format!("trigger lookup: {e}")))?;
let ev = TriggerEvent::Files {
op,
collection,
id: meta.id.to_string(),
name: meta.name,
content_type: meta.content_type,
size: meta.size,
checksum: meta.checksum,
prev: event.old_payload.clone(),
};
(
m.into_iter()
.map(|x| (x.trigger_id, x.script_id))
.collect::<Vec<_>>(),
ev,
)
}
_ => return Ok(()),
};
if matches.is_empty() {
return Ok(());
}
let payload = serde_json::to_value(&trigger_event)
.map_err(|e| EmitError::Rejected(format!("event serialize: {e}")))?;
for (trigger_id, script_id) in matches {
self.outbox
.insert(NewOutboxRow {
app_id: cx.app_id,
source_kind,
trigger_id: Some(trigger_id),
script_id: Some(script_id),
reply_to: None,
payload: payload.clone(),
origin_principal: cx.principal.as_ref().map(|p| p.user_id),
trigger_depth: cx.trigger_depth.saturating_add(1),
root_execution_id: Some(cx.root_execution_id),
})
.await
.map_err(|e| EmitError::Unavailable(format!("outbox insert: {e}")))?;
}
Ok(())
}
}
impl OutboxEventEmitter {

View File

@@ -12,10 +12,12 @@
//! becomes a hot path.
use async_trait::async_trait;
use picloud_shared::{topic_matches, AdminUserId, AppId, ExecutionId};
use picloud_shared::{topic_matches, AdminUserId, AppId, ExecutionId, GroupId};
use sqlx::PgPool;
use uuid::Uuid;
use crate::config_resolver::CHAIN_LEVELS_CTE;
#[derive(Debug, thiserror::Error)]
pub enum PubsubRepoError {
#[error("database error: {0}")]
@@ -45,6 +47,20 @@ pub trait PubsubRepo: Send + Sync {
topic: &str,
event_payload: serde_json::Value,
) -> Result<u32, PubsubRepoError>;
/// §11.6 D2: fan out a SHARED-topic publish to every `shared = true` pubsub
/// trigger on `owning_group` (the group resolved from the shared-topic
/// declaration), inserting one outbox row each stamped with the WRITER
/// `ctx.app_id` — so the handler runs under the publishing app, matching the
/// M2 shared-collection trigger model. No chain union (the owning group is
/// already resolved) and no suppression (shared triggers aren't suppressible).
async fn fan_out_shared_publish(
&self,
ctx: PublishCtx,
owning_group: GroupId,
topic: &str,
event_payload: serde_json::Value,
) -> Result<u32, PubsubRepoError>;
}
pub struct PostgresPubsubRepo {
@@ -78,12 +94,21 @@ impl PubsubRepo for PostgresPubsubRepo {
// Load all enabled pubsub triggers for the app; filter by topic
// pattern in Rust (keeps the query simple, honours the
// empty/`*`/prefix semantics without teaching SQL about globs).
let rows: Vec<PubsubTriggerRow> = sqlx::query_as(
"SELECT t.id, t.script_id, d.topic_pattern \
// §11 tail: the chain union picks up the app's own pubsub triggers AND
// ancestor-group pubsub TEMPLATES (live, no materialization). The outbox
// row below stamps the firing `ctx.app_id`, so a template runs under the
// publishing app — the inheriting-app boundary. The suppression
// anti-join drops an inherited template whose handler the app opts out
// of (`$1` = ctx.app_id).
let rows: Vec<PubsubTriggerRow> = sqlx::query_as(&format!(
"{CHAIN_LEVELS_CTE} \
SELECT t.id, t.script_id, d.topic_pattern \
FROM triggers t \
JOIN pubsub_trigger_details d ON d.trigger_id = t.id \
WHERE t.app_id = $1 AND t.kind = 'pubsub' AND t.enabled = TRUE",
)
JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner) \
WHERE t.kind = 'pubsub' AND t.enabled = TRUE AND t.shared = FALSE{ANTIJOIN}",
ANTIJOIN = crate::trigger_repo::TRIGGER_SUPPRESSION_ANTIJOIN,
))
.bind(ctx.app_id.into_inner())
.fetch_all(&mut *tx)
.await?;
@@ -93,21 +118,7 @@ impl PubsubRepo for PostgresPubsubRepo {
if !topic_matches(&r.topic_pattern, topic) {
continue;
}
sqlx::query(
"INSERT INTO outbox ( \
app_id, source_kind, trigger_id, script_id, reply_to, \
payload, origin_principal, trigger_depth, root_execution_id \
) VALUES ($1, 'pubsub', $2, $3, NULL, $4, $5, $6, $7)",
)
.bind(ctx.app_id.into_inner())
.bind(r.id)
.bind(r.script_id)
.bind(&event_payload)
.bind(ctx.origin_principal.map(AdminUserId::into_inner))
.bind(i32::try_from(ctx.trigger_depth.saturating_add(1)).unwrap_or(1))
.bind(ctx.root_execution_id.into_inner())
.execute(&mut *tx)
.await?;
insert_pubsub_outbox_row(&mut tx, &ctx, r.id, r.script_id, &event_payload).await?;
written += 1;
}
@@ -115,4 +126,69 @@ impl PubsubRepo for PostgresPubsubRepo {
tx.commit().await?;
Ok(written)
}
async fn fan_out_shared_publish(
&self,
ctx: PublishCtx,
owning_group: GroupId,
topic: &str,
event_payload: serde_json::Value,
) -> Result<u32, PubsubRepoError> {
let mut tx = self.pool.begin().await?;
// §11.6 D2: only `shared = true` pubsub triggers on the RESOLVED owning
// group — the shared-topic namespace boundary (a per-app or non-owning
// trigger never matches a shared publish). Topic-pattern match in Rust
// like the per-app path.
let rows: Vec<PubsubTriggerRow> = sqlx::query_as(
"SELECT t.id, t.script_id, d.topic_pattern \
FROM triggers t \
JOIN pubsub_trigger_details d ON d.trigger_id = t.id \
WHERE t.kind = 'pubsub' AND t.enabled = TRUE AND t.shared = TRUE \
AND t.group_id = $1",
)
.bind(owning_group.into_inner())
.fetch_all(&mut *tx)
.await?;
let mut written: u32 = 0;
for r in rows {
if !topic_matches(&r.topic_pattern, topic) {
continue;
}
insert_pubsub_outbox_row(&mut tx, &ctx, r.id, r.script_id, &event_payload).await?;
written += 1;
}
tx.commit().await?;
Ok(written)
}
}
/// Insert one pubsub delivery row, stamped with the writer `ctx.app_id` so the
/// handler runs under the publishing app. Shared by the per-app + shared
/// fan-outs.
async fn insert_pubsub_outbox_row(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
ctx: &PublishCtx,
trigger_id: Uuid,
script_id: Uuid,
event_payload: &serde_json::Value,
) -> Result<(), PubsubRepoError> {
sqlx::query(
"INSERT INTO outbox ( \
app_id, source_kind, trigger_id, script_id, reply_to, \
payload, origin_principal, trigger_depth, root_execution_id \
) VALUES ($1, 'pubsub', $2, $3, NULL, $4, $5, $6, $7)",
)
.bind(ctx.app_id.into_inner())
.bind(trigger_id)
.bind(script_id)
.bind(event_payload)
.bind(ctx.origin_principal.map(AdminUserId::into_inner))
.bind(i32::try_from(ctx.trigger_depth.saturating_add(1)).unwrap_or(1))
.bind(ctx.root_execution_id.into_inner())
.execute(&mut **tx)
.await?;
Ok(())
}

View File

@@ -386,6 +386,17 @@ mod tests {
self.written.lock().unwrap().extend(staged);
Ok(u32::try_from(n).unwrap_or(u32::MAX))
}
async fn fan_out_shared_publish(
&self,
_ctx: PublishCtx,
_owning_group: picloud_shared::GroupId,
_topic: &str,
_event_payload: serde_json::Value,
) -> Result<u32, PubsubRepoError> {
// The per-app PubsubService tests don't exercise the shared path.
Ok(0)
}
}
#[derive(Default)]

View File

@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
use async_trait::async_trait;
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
use picloud_shared::{
AdminUserId, AppId, ExecutionLog, ExecutionSource, ExecutionStatus, RequestId, Script,
AdminUserId, AppId, ExecutionLog, ExecutionSource, ExecutionStatus, GroupId, RequestId, Script,
ScriptId, ScriptKind, ScriptSandbox,
};
use sqlx::PgPool;
@@ -34,10 +34,34 @@ pub trait ScriptRepository: Send + Sync {
app_id: AppId,
name: &str,
) -> Result<Option<Script>, ScriptRepositoryError>;
/// Phase 4: resolve `name → Script` down `app_id`'s ownership chain —
/// the app itself, then its ancestor groups nearest-first. Nearest owner
/// wins (an app's own script shadows an inherited group one of the same
/// name: CoW). Returns `None` if no script in the chain has that name.
/// Backs inherited `invoke("name")` and declarative name→id binding.
async fn get_by_name_inherited(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<Script>, ScriptRepositoryError>;
/// Phase 4: is `script_id` invocable in `app_id`'s context? True iff the
/// script is owned by `app_id` directly, OR owned by a group on `app_id`'s
/// ancestor chain. The cross-app isolation predicate generalized to the
/// hierarchy — the runtime backstop for trigger/queue/invoke dispatch and
/// trigger-bind validation. A missing script returns `false` (fail closed).
async fn is_invocable_by_app(
&self,
script_id: ScriptId,
app_id: AppId,
) -> Result<bool, ScriptRepositoryError>;
/// Every script across all apps. Mostly for tests and admin
/// "global" views; the dashboard reaches scripts via `list_for_app`.
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
/// Phase 4: every script owned directly by `group_id` (not inherited —
/// just the group's own rows). Backs the group-script admin list.
async fn list_for_group(&self, group_id: GroupId)
-> Result<Vec<Script>, ScriptRepositoryError>;
/// Every script in any app the user is a member of. Drives
/// `GET /admin/scripts` for `member` instance-role callers so the
/// API never returns scripts they shouldn't see — even before the
@@ -93,12 +117,32 @@ impl<T: ScriptRepository + ?Sized> ScriptRepository for std::sync::Arc<T> {
) -> Result<Option<Script>, ScriptRepositoryError> {
(**self).get_by_name(app_id, name).await
}
async fn get_by_name_inherited(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<Script>, ScriptRepositoryError> {
(**self).get_by_name_inherited(app_id, name).await
}
async fn is_invocable_by_app(
&self,
script_id: ScriptId,
app_id: AppId,
) -> Result<bool, ScriptRepositoryError> {
(**self).is_invocable_by_app(script_id, app_id).await
}
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
(**self).list().await
}
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
(**self).list_for_app(app_id).await
}
async fn list_for_group(
&self,
group_id: GroupId,
) -> Result<Vec<Script>, ScriptRepositoryError> {
(**self).list_for_group(group_id).await
}
async fn list_for_user(
&self,
user_id: AdminUserId,
@@ -142,7 +186,13 @@ impl<T: ScriptRepository + ?Sized> ScriptRepository for std::sync::Arc<T> {
/// constraints; the repo enforces them in the DB regardless.
#[derive(Debug, Clone)]
pub struct NewScript {
pub app_id: AppId,
/// App owner (the common case). Exactly one of `app_id`/`group_id` must
/// be `Some` — the DB CHECK is the backstop, but callers should uphold
/// it. App-owned creation continues to pass `Some(app_id)`, `group_id:
/// None`; group-owned creation (Phase 4) inverts that.
pub app_id: Option<AppId>,
/// Group owner (Phase 4). See [`NewScript::app_id`].
pub group_id: Option<GroupId>,
pub name: String,
pub description: Option<String>,
pub source: String,
@@ -206,7 +256,7 @@ impl PostgresScriptRepository {
/// Columns selected from `scripts` everywhere — kept in one constant so
/// adding `kind` (v1.1.3) and future columns can't accidentally skip
/// one query.
const SCRIPT_SELECT_COLS: &str = "id, app_id, name, description, version, source, kind, \
const SCRIPT_SELECT_COLS: &str = "id, app_id, group_id, name, description, version, source, kind, \
timeout_seconds, memory_limit_mb, sandbox, enabled, \
created_at, updated_at";
@@ -237,6 +287,58 @@ impl ScriptRepository for PostgresScriptRepository {
Ok(row.map(Into::into))
}
async fn get_by_name_inherited(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<Script>, ScriptRepositoryError> {
// Walk app → ancestor groups (CHAIN_LEVELS_CTE binds $1 = app_id),
// join scripts owned at each level, and take the nearest (lowest
// depth) row named `name`. Case-insensitive to match the per-owner
// `LOWER(name)` unique indexes. Mirrors the vars/secrets resolvers.
let cols = SCRIPT_SELECT_COLS
.split(", ")
.map(|c| format!("s.{c}"))
.collect::<Vec<_>>()
.join(", ");
let row = sqlx::query_as::<_, ScriptRow>(&format!(
"{cte} SELECT {cols} FROM scripts s \
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
WHERE LOWER(s.name) = LOWER($2) \
ORDER BY c.depth ASC \
LIMIT 1",
cte = crate::config_resolver::CHAIN_LEVELS_CTE,
))
.bind(app_id.into_inner())
.bind(name)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn is_invocable_by_app(
&self,
script_id: ScriptId,
app_id: AppId,
) -> Result<bool, ScriptRepositoryError> {
// EXISTS over the same chain: the script's owner (app or group) must
// appear on `app_id`'s app→ancestor-group chain. CHAIN_LEVELS_CTE
// binds $1 = app_id; $2 = script_id.
let exists: (bool,) = sqlx::query_as(&format!(
"{cte} SELECT EXISTS ( \
SELECT 1 FROM scripts s \
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
WHERE s.id = $2 \
)",
cte = crate::config_resolver::CHAIN_LEVELS_CTE,
))
.bind(app_id.into_inner())
.bind(script_id.into_inner())
.fetch_one(&self.pool)
.await?;
Ok(exists.0)
}
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
"SELECT {SCRIPT_SELECT_COLS} FROM scripts ORDER BY name"
@@ -256,6 +358,19 @@ impl ScriptRepository for PostgresScriptRepository {
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_for_group(
&self,
group_id: GroupId,
) -> Result<Vec<Script>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
"SELECT {SCRIPT_SELECT_COLS} FROM scripts WHERE group_id = $1 ORDER BY name"
))
.bind(group_id.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_for_user(
&self,
user_id: AdminUserId,
@@ -397,12 +512,13 @@ pub(crate) async fn insert_script_tx(
.unwrap_or_else(|_| serde_json::json!({}));
let res = sqlx::query_as::<_, ScriptRow>(&format!(
"INSERT INTO scripts ( \
app_id, name, description, source, kind, \
app_id, group_id, name, description, source, kind, \
timeout_seconds, memory_limit_mb, sandbox, enabled \
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8, $9) \
) VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 30), COALESCE($8, 256), $9, $10) \
RETURNING {SCRIPT_SELECT_COLS}"
))
.bind(input.app_id.into_inner())
.bind(input.app_id.map(AppId::into_inner))
.bind(input.group_id.map(GroupId::into_inner))
.bind(&input.name)
.bind(input.description.as_deref())
.bind(&input.source)
@@ -417,13 +533,18 @@ pub(crate) async fn insert_script_tx(
Ok(row) => row.into(),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
return Err(ScriptRepositoryError::Conflict(format!(
"a script named {:?} already exists in this app",
"a script named {:?} already exists in this owner",
input.name
)));
}
Err(e) => return Err(e.into()),
};
replace_imports_tx(tx, script.id, script.app_id, &input.imports).await?;
// Module imports are resolved within the owning app's script set. Group
// scripts must be self-contained in Phase 4-lite (the origin-aware import
// resolver is Phase 4b), so only app-owned scripts wire up import edges.
if let Some(app_id) = script.app_id {
replace_imports_tx(tx, script.id, app_id, &input.imports).await?;
}
Ok(script)
}
@@ -470,13 +591,15 @@ pub(crate) async fn update_script_tx(
Ok(None) => return Err(ScriptRepositoryError::NotFound(id)),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
return Err(ScriptRepositoryError::Conflict(
"a script with that name already exists in this app".into(),
"a script with that name already exists in this owner".into(),
));
}
Err(e) => return Err(e.into()),
};
if let Some(imports) = patch.imports.as_deref() {
replace_imports_tx(tx, script.id, script.app_id, imports).await?;
if let Some(app_id) = script.app_id {
replace_imports_tx(tx, script.id, app_id, imports).await?;
}
}
Ok(script)
}
@@ -501,7 +624,10 @@ pub(crate) async fn delete_script_tx(
#[derive(sqlx::FromRow)]
struct ScriptRow {
id: uuid::Uuid,
app_id: uuid::Uuid,
/// Polymorphic owner (Phase 4): exactly one of `app_id`/`group_id` is
/// non-NULL (DB CHECK). App-owned rows keep `app_id` set as before.
app_id: Option<uuid::Uuid>,
group_id: Option<uuid::Uuid>,
name: String,
description: Option<String>,
version: i32,
@@ -531,7 +657,8 @@ impl From<ScriptRow> for Script {
let kind = ScriptKind::parse_str(&r.kind).unwrap_or(ScriptKind::Endpoint);
Self {
id: r.id.into(),
app_id: r.app_id.into(),
app_id: r.app_id.map(Into::into),
group_id: r.group_id.map(Into::into),
name: r.name,
description: r.description,
version: r.version,

View File

@@ -13,14 +13,14 @@ use axum::{
Extension, Json, Router,
};
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId};
use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId, ScriptOwner};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::app_domain_repo::AppDomainRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::repo::{ScriptRepository, ScriptRepositoryError};
use crate::route_repo::{NewRoute, RouteRepository};
use crate::route_repo::{EffectiveRoute, NewRoute, RouteRepository};
pub struct RouteAdminState<RR, SR> {
pub routes: Arc<RR>,
@@ -148,10 +148,16 @@ async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
.get(script_id)
.await?
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
// Phase 4: routes bind to app-owned scripts; a group-owned script
// (`app_id: None`) is not addressable here (binding lands in C3), so it
// reads as a missing script.
let app_id = script
.app_id
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
require(
state.authz.as_ref(),
&principal,
Capability::AppRead(script.app_id),
Capability::AppRead(app_id),
)
.await?;
Ok(Json(state.routes.list_for_script(script_id).await?))
@@ -176,7 +182,11 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
.get(script_id)
.await?
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
let app_id = script.app_id;
// Phase 4: only app-owned scripts are route-bindable for now (group
// binding is C3); a group script reads as missing here.
let app_id = script
.app_id
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
require(
state.authz.as_ref(),
&principal,
@@ -220,7 +230,7 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
let created = state
.routes
.create(NewRoute {
app_id,
owner: ScriptOwner::App(app_id),
script_id,
host_kind: input.host_kind,
host: input.host,
@@ -231,6 +241,9 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
dispatch_mode: input.dispatch_mode,
// Routes are created active; toggling is a dedicated path.
enabled: true,
// Sealing is a group-template property (§11 tail); an interactively
// created app route is never sealed.
sealed: false,
})
.await?;
refresh_table(&state).await?;
@@ -249,10 +262,14 @@ async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
.get(route_id)
.await?
.ok_or(RouteApiError::RouteNotFound(route_id))?;
// §11 tail: the interactive route API is app-scoped. A group-owned route
// TEMPLATE (`app_id` None) is managed declaratively via apply, not
// addressable here — treat it as not found.
let route_app_id = route.app_id.ok_or(RouteApiError::RouteNotFound(route_id))?;
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteRoute(route.app_id),
Capability::AppWriteRoute(route_app_id),
)
.await?;
state.routes.delete(route_id).await?;
@@ -374,52 +391,157 @@ fn first_conflict(
async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
state: &RouteAdminState<RR, SR>,
) -> Result<(), RouteApiError> {
let rows = state.routes.list_all().await?;
let compiled = compile_routes(&rows);
state.table.replace_all(compiled);
rebuild_route_table(state.routes.as_ref(), &state.table).await?;
Ok(())
}
/// Compile stored route rows into the in-memory match table.
/// Rebuild the entire in-memory [`RouteTable`] from the database, expanding
/// §11 tail group route TEMPLATES into every descendant app's slice.
///
/// **Lenient by design (H1).** A row that fails to parse is *skipped with
/// a warning*, not propagated as an error. The motivating case: a path
/// that was valid when created but became reserved under a later, stricter
/// validation (e.g. the case-insensitive reserved-prefix check) — but this
/// also covers any other parse failure. A single un-compilable legacy row
/// must never take down the entire data plane: this function runs at
/// startup (where a hard error aborts boot) and on every table rebuild
/// after a route edit (where it would fail an unrelated CRUD op). A skipped
/// route simply doesn't match; the warning tells the operator to delete or
/// fix it (and migration 0044 sweeps the reserved-path offenders on
/// upgrade).
#[must_use]
pub fn compile_routes(rows: &[Route]) -> Vec<CompiledRoute> {
rows.iter()
// A disabled route (§4.3) is dropped from the match table entirely, so
// a request to it 404s indistinguishably from an absent route.
.filter(|r| r.enabled)
.filter_map(|r| match compile_route(r) {
Ok(compiled) => Some(compiled),
Err(e) => {
tracing::warn!(
route_id = %r.id,
app_id = %r.app_id,
path = %r.path,
error = %e,
"skipping un-compilable stored route — it will not match; \
delete or fix it"
);
None
}
})
.collect()
/// This is the single chokepoint for route-table refresh — called from route
/// CRUD, the declarative apply reconcile, startup, and (full-live invalidation)
/// every tree mutation that changes inheritance (app create/reparent/delete,
/// group reparent). It reads the live expansion (`list_effective`) so a group
/// template lands in the slice of each app whose ancestor chain contains the
/// owning group, and nowhere else — the chain walk is the isolation boundary.
pub async fn rebuild_route_table(
routes: &dyn RouteRepository,
table: &RouteTable,
) -> Result<(), ScriptRepositoryError> {
let effective = routes.list_effective().await?;
// §11 tail: `(app_id, path)` route suppressions — an inherited route at a
// suppressed path is dropped from the app's slice (404).
let suppressed: std::collections::HashSet<(AppId, String)> = routes
.list_route_suppressions()
.await?
.into_iter()
.collect();
let compiled = compile_effective_routes(&effective, &suppressed);
table.replace_all(compiled);
Ok(())
}
fn compile_route(r: &Route) -> Result<CompiledRoute, pattern::ParseError> {
/// Compile the §11 tail effective expansion into per-app match slices, applying
/// **nearest-owner-wins shadowing**: for a given app, if the same binding tuple
/// (method + host + path) is owned at more than one chain level — e.g. the app
/// declares its own `/x` and an ancestor group also templates `/x` — the
/// nearest owner (lowest `depth`) wins and the farther one is dropped. Unlike
/// triggers (which fan out to every match), a route resolves to a single
/// winner, so identical bindings MUST dedupe; **non-identical** bindings (the
/// app's `/users/:id` + the group's `/users/admin`) coexist and the matcher's
/// existing precedence picks per request.
///
/// This is the single production entry point: app-only installs feed it routes
/// at `depth 0` (one per app, no templates), so it subsumes the former app-only
/// compile path. Disabled routes are dropped (§4.3) and un-compilable rows
/// skipped-with-warning (see [`compile_one`]).
///
/// **Disabled + inherited (§4.3 semantic):** the `enabled` filter runs *before*
/// the shadow check, so a **disabled** own-route does NOT claim its binding — an
/// enabled ancestor-group template at the same binding then falls through and
/// serves (disabled = "indistinguishable from absent"). To actually 404 an
/// inherited route, a descendant SUPPRESSES its path (§11 tail, the
/// `suppressed_paths` set) — the deliberate per-app opt-out.
///
/// `suppressed_paths` holds `(app_id, path)` route suppressions: an INHERITED
/// row (`depth > 0`) whose `(effective_app_id, path)` is present is skipped
/// entirely (the binding is absent → 404). Gated to `depth > 0` so an app can
/// only decline what it inherits, never its own route.
///
/// Requires `rows` ordered by `(effective_app_id, depth ASC)` — which
/// [`RouteRepository::list_effective`] guarantees — so first-seen is nearest.
#[must_use]
#[allow(clippy::implicit_hasher)] // every caller uses the default hasher
pub fn compile_effective_routes(
rows: &[EffectiveRoute],
suppressed_paths: &std::collections::HashSet<(AppId, String)>,
) -> Vec<CompiledRoute> {
let mut seen: std::collections::HashSet<(AppId, String)> = std::collections::HashSet::new();
let mut out = Vec::new();
for er in rows {
// A disabled route (§4.3) is dropped from the match table entirely, so a
// request to it 404s indistinguishably from an absent route.
if !er.route.enabled {
continue;
}
// §11 tail: an inherited route whose path this app suppresses is
// dropped — the binding 404s. Gated to inherited rows (`depth > 0`).
// A `sealed` template is non-suppressible: the descendant's opt-out is
// ignored, so it stays in the slice regardless of the suppression.
if er.depth > 0
&& !er.route.sealed
&& suppressed_paths.contains(&(er.effective_app_id, er.route.path.clone()))
{
continue;
}
// A nearer owner already claimed this binding for this app → shadow.
if !seen.insert((er.effective_app_id, binding_key(&er.route))) {
continue;
}
if let Some(compiled) = compile_one(&er.route, er.effective_app_id) {
out.push(compiled);
}
}
out
}
/// The shadow-identity of a route within one app's slice: the binding tuple a
/// request resolves against (method + host + path), matching the per-owner DB
/// unique index. Two routes with the same key are "the same endpoint" and only
/// the nearest owner's survives.
fn binding_key(r: &Route) -> String {
let method = r
.method
.as_deref()
.map_or("ANY".to_string(), str::to_uppercase);
let host_kind = match r.host_kind {
HostKind::Any => "any",
HostKind::Strict => "strict",
HostKind::Wildcard => "wildcard",
};
let path_kind = match r.path_kind {
PathKind::Exact => "exact",
PathKind::Prefix => "prefix",
PathKind::Param => "param",
};
format!("{method} {host_kind} {} {path_kind} {}", r.host, r.path)
}
/// Parse one stored route into a `CompiledRoute` bound to `app_id` (the
/// effective app — its own for app routes, the firing descendant for a group
/// template).
///
/// **Lenient by design (H1).** A row that fails to parse is *skipped with a
/// warning*, not propagated as an error. The motivating case: a path that was
/// valid when created but became reserved under a later, stricter validation
/// (the case-insensitive reserved-prefix check) — but this also covers any
/// other parse failure. A single un-compilable row must never take down the
/// data plane: this runs at startup (where a hard error aborts boot) and on
/// every table rebuild after an edit (where it would fail an unrelated CRUD
/// op). A skipped route simply doesn't match; the warning tells the operator to
/// delete or fix it (and migration 0044 sweeps the reserved-path offenders on
/// upgrade).
fn compile_one(r: &Route, app_id: AppId) -> Option<CompiledRoute> {
match compile_route(r, app_id) {
Ok(compiled) => Some(compiled),
Err(e) => {
tracing::warn!(
route_id = %r.id,
app_id = %app_id,
path = %r.path,
error = %e,
"skipping un-compilable stored route — it will not match; \
delete or fix it"
);
None
}
}
}
fn compile_route(r: &Route, app_id: AppId) -> Result<CompiledRoute, pattern::ParseError> {
Ok(CompiledRoute {
route_id: r.id,
app_id: r.app_id,
app_id,
script_id: r.script_id,
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
path: pattern::parse_path(r.path_kind, &r.path)?,
@@ -621,7 +743,8 @@ mod tests {
fn route_with_path(path: &str) -> Route {
Route {
id: Uuid::new_v4(),
app_id: AppId::from(Uuid::new_v4()),
app_id: Some(AppId::from(Uuid::new_v4())),
group_id: None,
script_id: ScriptId::from(Uuid::new_v4()),
host_kind: HostKind::Any,
host: String::new(),
@@ -631,22 +754,38 @@ mod tests {
method: None,
dispatch_mode: DispatchMode::default(),
enabled: true,
sealed: false,
created_at: chrono::Utc::now(),
}
}
/// Wrap an app-owned route as its own `depth 0` effective row — the shape
/// `list_effective` produces for an app-only install (no group templates).
fn eff(route: &Route) -> EffectiveRoute {
EffectiveRoute {
effective_app_id: route.app_id.expect("test route is app-owned"),
depth: 0,
route: route.clone(),
}
}
/// No route suppressions — the common case for these compile tests.
fn no_suppress() -> std::collections::HashSet<(AppId, String)> {
std::collections::HashSet::new()
}
#[test]
fn compile_routes_skips_uncompilable_rows_instead_of_failing() {
fn compile_effective_skips_uncompilable_rows_instead_of_failing() {
// H1 regression guard: a stored route whose path is now reserved
// (creatable before the case-insensitive reserved-prefix fix) must
// be skipped, not abort the whole compile — otherwise one legacy
// row bricks startup (`compile_routes` runs in `build_app`).
// row bricks startup (the effective rebuild runs in `build_app`).
let good_a = route_with_path("/ok");
let bad = route_with_path("/API/v2/x"); // now reserved, case-insensitive
let good_b = route_with_path("/items");
let rows = vec![good_a.clone(), bad.clone(), good_b.clone()];
let rows = [eff(&good_a), eff(&bad), eff(&good_b)];
let compiled = compile_routes(&rows);
let compiled = compile_effective_routes(&rows, &no_suppress());
let ids: Vec<Uuid> = compiled.iter().map(|c| c.route_id).collect();
assert_eq!(compiled.len(), 2, "the reserved row must be dropped");
@@ -665,8 +804,53 @@ mod tests {
let active = route_with_path("/on");
let mut disabled = route_with_path("/off");
disabled.enabled = false;
let compiled = compile_routes(&[active.clone(), disabled.clone()]);
let compiled = compile_effective_routes(&[eff(&active), eff(&disabled)], &no_suppress());
let ids: Vec<Uuid> = compiled.iter().map(|c| c.route_id).collect();
assert_eq!(ids, vec![active.id], "only the enabled route compiles");
}
#[test]
fn nearest_owner_shadows_identical_binding() {
// An app's own route (depth 0) and an ancestor-group template (depth 1)
// with the SAME binding collapse to the nearer one; a different binding
// coexists. Mirrors the live `group_route_templates` integration test
// at the pure-compile layer.
let app = AppId::from(Uuid::new_v4());
let own = route_with_path("/x"); // app's own /x
let mut template = route_with_path("/x"); // group template, same binding
template.app_id = None;
template.group_id = Some(picloud_shared::GroupId::from(Uuid::new_v4()));
let other_template = route_with_path("/y"); // group template, different path
let rows = [
EffectiveRoute {
effective_app_id: app,
depth: 0,
route: own.clone(),
},
EffectiveRoute {
effective_app_id: app,
depth: 1,
route: template.clone(),
},
EffectiveRoute {
effective_app_id: app,
depth: 1,
route: other_template.clone(),
},
];
let ids: Vec<Uuid> = compile_effective_routes(&rows, &no_suppress())
.iter()
.map(|c| c.route_id)
.collect();
assert!(ids.contains(&own.id), "the app's own /x must win");
assert!(
!ids.contains(&template.id),
"the inherited /x template must be shadowed by the app's own"
);
assert!(
ids.contains(&other_template.id),
"a non-identical /y template must coexist"
);
}
}

View File

@@ -4,7 +4,9 @@
//! after every write — see the route_admin module for the binding.
use async_trait::async_trait;
use picloud_shared::{AppId, DispatchMode, HostKind, PathKind, Route, ScriptId};
use picloud_shared::{
AppId, DispatchMode, GroupId, HostKind, PathKind, Route, ScriptId, ScriptOwner,
};
use sqlx::PgPool;
use uuid::Uuid;
@@ -12,7 +14,10 @@ use crate::repo::ScriptRepositoryError;
#[derive(Debug, Clone)]
pub struct NewRoute {
pub app_id: AppId,
/// §11 tail: an **app** owns a concrete route; a **group** owns a route
/// TEMPLATE. The interactive route API always passes `App`; the declarative
/// reconcile passes the bundle node's owner.
pub owner: ScriptOwner,
pub script_id: ScriptId,
pub host_kind: HostKind,
pub host: String,
@@ -23,6 +28,22 @@ pub struct NewRoute {
pub dispatch_mode: DispatchMode,
/// Three-state lifecycle (§4.3). Create active by default.
pub enabled: bool,
/// §11 tail: `true` for a sealed (non-suppressible) group route template.
/// Always `false` for an app-owned route (the reconcile rejects sealed on
/// an app owner before reaching here).
pub sealed: bool,
}
/// A route resolved for a specific app via the §11 tail expansion: the route
/// row itself plus the **effective app** it applies to (the firing
/// descendant) and the chain `depth` at which its owner sits (0 = the app's
/// own route, ≥1 = an ancestor-group template). Used only by the in-memory
/// RouteTable rebuild — never on the request hot path.
#[derive(Debug, Clone)]
pub struct EffectiveRoute {
pub effective_app_id: AppId,
pub depth: i32,
pub route: Route,
}
#[async_trait]
@@ -33,6 +54,42 @@ pub trait RouteRepository: Send + Sync {
/// (not a path param).
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError>;
/// Group-owned route TEMPLATES (§11 tail) declared directly at `group_id`.
/// Backs the declarative apply diff for a `[group]` node and
/// `pic routes ls --group`. Defaults to empty so non-Postgres impls
/// (tests) need not provide it.
async fn list_for_group(
&self,
_group_id: GroupId,
) -> Result<Vec<Route>, ScriptRepositoryError> {
Ok(Vec::new())
}
/// §11 tail per-app opt-out: all `(app_id, path)` ROUTE suppressions. The
/// route-table rebuild drops an inherited route at a suppressed path.
/// Defaults empty (non-Postgres impls have no suppressions).
async fn list_route_suppressions(&self) -> Result<Vec<(AppId, String)>, ScriptRepositoryError> {
Ok(Vec::new())
}
/// §11 tail: every route resolved for every app — each app's own routes
/// PLUS its ancestor-group templates, tagged with the effective app and
/// the owner's chain depth. The in-memory RouteTable rebuild consumes this
/// (expansion happens here, once per rebuild, never per request). Defaults
/// to `list_all` mapped at depth 0 so non-Postgres impls degrade to the
/// pre-§11 (app-only) behavior.
async fn list_effective(&self) -> Result<Vec<EffectiveRoute>, ScriptRepositoryError> {
Ok(self
.list_all()
.await?
.into_iter()
.filter_map(|route| {
route.app_id.map(|a| EffectiveRoute {
effective_app_id: a,
depth: 0,
route,
})
})
.collect())
}
async fn list_for_script(
&self,
script_id: ScriptId,
@@ -64,7 +121,7 @@ impl PostgresRouteRepository {
impl RouteRepository for PostgresRouteRepository {
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
"SELECT id, app_id, group_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, enabled, created_at \
FROM routes ORDER BY created_at",
)
@@ -75,7 +132,7 @@ impl RouteRepository for PostgresRouteRepository {
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
"SELECT id, app_id, group_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, enabled, created_at \
FROM routes WHERE id = $1",
)
@@ -87,7 +144,7 @@ impl RouteRepository for PostgresRouteRepository {
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
"SELECT id, app_id, group_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, enabled, created_at \
FROM routes WHERE app_id = $1 ORDER BY created_at",
)
@@ -97,12 +154,88 @@ impl RouteRepository for PostgresRouteRepository {
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, group_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, enabled, sealed, created_at \
FROM routes WHERE group_id = $1 ORDER BY created_at",
)
.bind(group_id.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_route_suppressions(&self) -> Result<Vec<(AppId, String)>, ScriptRepositoryError> {
// §11 tail M1: a route suppression is owned by an app (declines for
// itself) OR a group (declines for its whole subtree). Expand each
// group-owned suppression across every descendant app via the all-apps
// `app_chain` CTE (the same one `list_effective` uses): the result is
// `(effective_app_id, path)` pairs the rebuild consumes unchanged — a
// group suppression appears once per descendant app.
let rows: Vec<(Uuid, String)> = sqlx::query_as(
"WITH RECURSIVE app_chain AS ( \
SELECT a.id AS effective_app_id, a.id AS owner_app, \
NULL::uuid AS owner_group, a.group_id AS next_group, 0 AS depth \
FROM apps a \
UNION ALL \
SELECT ac.effective_app_id, NULL::uuid, g.id, g.parent_id, ac.depth + 1 \
FROM groups g JOIN app_chain ac ON g.id = ac.next_group \
WHERE ac.depth < 64 \
) \
SELECT DISTINCT ac.effective_app_id, ts.reference \
FROM app_chain ac \
JOIN template_suppressions ts \
ON (ts.app_id = ac.owner_app OR ts.group_id = ac.owner_group) \
WHERE ts.target_kind = 'route'",
)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|(a, r)| (a.into(), r)).collect())
}
async fn list_effective(&self) -> Result<Vec<EffectiveRoute>, ScriptRepositoryError> {
// The all-apps generalization of CHAIN_LEVELS_CTE: for EVERY app, walk
// its ancestor-group chain, then join routes owned at each level. A
// group template appears once per descendant app (tagged with that
// app + the owner's depth); an app's own route appears at depth 0.
// Runs only on a RouteTable rebuild, never per request.
let rows = sqlx::query_as::<_, EffectiveRouteRow>(
"WITH RECURSIVE app_chain AS ( \
SELECT a.id AS effective_app_id, a.id AS owner_app, \
NULL::uuid AS owner_group, a.group_id AS next_group, 0 AS depth \
FROM apps a \
UNION ALL \
SELECT ac.effective_app_id, NULL::uuid, g.id, g.parent_id, ac.depth + 1 \
FROM groups g JOIN app_chain ac ON g.id = ac.next_group \
WHERE ac.depth < 64 \
) \
SELECT ac.effective_app_id, ac.depth, \
r.id, r.app_id, r.group_id, r.script_id, r.host_kind, r.host, \
r.host_param_name, r.path_kind, r.path, r.method, \
r.dispatch_mode, r.enabled, r.sealed, r.created_at \
FROM app_chain ac \
JOIN routes r ON (r.app_id = ac.owner_app OR r.group_id = ac.owner_group) \
ORDER BY ac.effective_app_id, ac.depth",
)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|er| EffectiveRoute {
effective_app_id: er.effective_app_id.into(),
depth: er.depth,
route: er.row.into(),
})
.collect())
}
async fn list_for_script(
&self,
script_id: ScriptId,
) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
"SELECT id, app_id, group_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, enabled, created_at \
FROM routes WHERE script_id = $1 ORDER BY created_at",
)
@@ -172,15 +305,21 @@ pub(crate) async fn insert_route_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
input: &NewRoute,
) -> Result<Route, ScriptRepositoryError> {
// §11 tail: an app owns a concrete route, a group owns a template.
let (owner_app_id, owner_group_id) = match input.owner {
ScriptOwner::App(a) => (Some(a.into_inner()), None),
ScriptOwner::Group(g) => (None, Some(g.into_inner())),
};
let res = sqlx::query_as::<_, RouteRow>(
"INSERT INTO routes ( \
app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, enabled \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, enabled, created_at",
app_id, group_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, enabled, sealed \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) \
RETURNING id, app_id, group_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, enabled, sealed, created_at",
)
.bind(input.app_id.into_inner())
.bind(owner_app_id)
.bind(owner_group_id)
.bind(input.script_id.into_inner())
.bind(host_kind_str(input.host_kind))
.bind(&input.host)
@@ -190,6 +329,7 @@ pub(crate) async fn insert_route_tx(
.bind(input.method.as_deref())
.bind(input.dispatch_mode.as_str())
.bind(input.enabled)
.bind(input.sealed)
.fetch_one(&mut **tx)
.await;
match res {
@@ -225,7 +365,11 @@ pub(crate) async fn delete_route_tx(
#[derive(sqlx::FromRow)]
struct RouteRow {
id: Uuid,
app_id: Uuid,
app_id: Option<Uuid>,
// `default` so any RETURNING/SELECT that predates the column still
// hydrates; every query in this module now lists it explicitly.
#[sqlx(default)]
group_id: Option<Uuid>,
script_id: Uuid,
host_kind: String,
host: String,
@@ -235,14 +379,31 @@ struct RouteRow {
method: Option<String>,
dispatch_mode: String,
enabled: bool,
// §11 tail: `default` so a SELECT/RETURNING that omits `sealed` still
// hydrates (→ false). The queries that must see a true value — `list_effective`
// (the rebuild gate) and the group-route load feeding the apply diff — list
// it explicitly.
#[sqlx(default)]
sealed: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
/// Row shape for [`RouteRepository::list_effective`]: the effective app +
/// owner depth, with the underlying route columns flattened in via `flatten`.
#[derive(sqlx::FromRow)]
struct EffectiveRouteRow {
effective_app_id: Uuid,
depth: i32,
#[sqlx(flatten)]
row: RouteRow,
}
impl From<RouteRow> for Route {
fn from(r: RouteRow) -> Self {
Self {
id: r.id,
app_id: r.app_id.into(),
app_id: r.app_id.map(Into::into),
group_id: r.group_id.map(Into::into),
script_id: r.script_id.into(),
host_kind: match r.host_kind.as_str() {
"strict" => HostKind::Strict,
@@ -260,6 +421,7 @@ impl From<RouteRow> for Route {
method: r.method,
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
enabled: r.enabled,
sealed: r.sealed,
created_at: r.created_at,
}
}

View File

@@ -1,16 +1,27 @@
//! `/api/v1/admin/apps/{id}/secrets*` — secrets admin endpoints
//! (v1.1.7).
//! `/api/v1/admin/{apps,groups}/{id}/secrets*` — secrets admin endpoints.
//!
//! * `GET /apps/{id}/secrets` — list names + updated_at
//! * `GET /apps/{id}/secrets` — list app secret names + updated_at
//! (NEVER values).
//! * `POST /apps/{id}/secrets` — set/overwrite a secret.
//! * `DELETE /apps/{id}/secrets/{name}` — delete a secret.
//! * `POST /apps/{id}/secrets` — set/overwrite an app secret.
//! * `DELETE /apps/{id}/secrets/{name}` — delete an app secret.
//! * `GET /groups/{id}/secrets` — list group secret names + scope
//! + updated_at (NEVER values).
//! * `POST /groups/{id}/secrets` — set/overwrite a group secret
//! (env-scoped).
//! * `DELETE /groups/{id}/secrets/{name}` — delete a group secret.
//! * `GET /groups/{id}/secrets/{name}/value` — **human value read** —
//! the ONE endpoint that returns plaintext, gated at the owning group.
//!
//! Set/delete are gated by `AppSecretsWrite` (→ `script:write`); list by
//! `AppSecretsRead` (→ `script:read`). The list surface deliberately
//! returns only names + timestamps — the dashboard never receives
//! plaintext. Values are encrypted with the process master key before
//! they touch the database (same envelope as the script `secrets::set`).
//! App set/delete are gated by `AppSecretsWrite`, list by `AppSecretsRead`.
//! Group set/list/delete are gated by `GroupSecretsWrite` (editor+); the
//! value-read is gated by `GroupSecretsRead` (group_admin only) — that is
//! the masked-secret boundary: a descendant app's dev can see that a group
//! secret EXISTS (and consume it at runtime via `secrets::get`) but only a
//! principal with read rights AT THE OWNING GROUP can read its value. The
//! owner is resolved FIRST (slug-or-uuid), THEN `authz::require` binds the
//! capability to the resolved owner id — never to a caller-controlled path
//! param. Values are encrypted with the process master key before they
//! touch the database (owner-bound AAD; see `secrets_service`).
use std::sync::Arc;
@@ -19,19 +30,24 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::get;
use axum::{Extension, Router};
use picloud_shared::{validate_secret_name, AppId, MasterKey, Principal, SecretsError};
use picloud_shared::{validate_secret_name, AppId, GroupId, MasterKey, Principal, SecretsError};
use serde::Deserialize;
use serde_json::json;
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::secrets_repo::{SecretsRepo, SecretsRepoError};
use crate::secrets_service::seal;
use crate::group_repo::GroupRepository;
use crate::secrets_repo::{SecretOwner, SecretsRepo, SecretsRepoError};
use crate::secrets_service::{open, seal};
/// App secrets are env-agnostic; only group secrets carry a concrete scope.
const APP_SECRET_SCOPE: &str = "*";
#[derive(Clone)]
pub struct SecretsState {
pub repo: Arc<dyn SecretsRepo>,
pub apps: Arc<dyn AppRepository>,
pub groups: Arc<dyn GroupRepository>,
pub authz: Arc<dyn AuthzRepo>,
pub master_key: MasterKey,
pub max_value_bytes: usize,
@@ -39,10 +55,25 @@ pub struct SecretsState {
pub fn secrets_router(state: SecretsState) -> Router {
Router::new()
.route("/apps/{app_id}/secrets", get(list_secrets).post(set_secret))
.route(
"/apps/{app_id}/secrets",
get(list_app_secrets).post(set_app_secret),
)
.route(
"/apps/{app_id}/secrets/{name}",
axum::routing::delete(delete_secret),
axum::routing::delete(delete_app_secret),
)
.route(
"/groups/{group_id}/secrets",
get(list_group_secrets).post(set_group_secret),
)
.route(
"/groups/{group_id}/secrets/{name}",
axum::routing::delete(delete_group_secret),
)
.route(
"/groups/{group_id}/secrets/{name}/value",
get(read_group_secret_value),
)
.with_state(state)
}
@@ -55,9 +86,18 @@ pub struct ListQuery {
pub limit: Option<u32>,
}
#[derive(Debug, Deserialize)]
pub struct EnvQuery {
#[serde(default)]
pub env: Option<String>,
}
#[derive(Debug, serde::Serialize)]
struct SecretItem {
name: String,
/// Environment scope — `*` for app secrets, possibly a concrete env for
/// group secrets.
env: String,
updated_at: chrono::DateTime<chrono::Utc>,
}
@@ -67,7 +107,23 @@ struct ListSecretsResponse {
next_cursor: Option<String>,
}
async fn list_secrets(
#[derive(Debug, Deserialize)]
pub struct SetSecretRequest {
pub name: String,
/// Any JSON value — the dashboard sends a single-line string, but
/// maps/arrays/numbers round-trip too (matching `secrets::set`).
pub value: serde_json::Value,
/// Environment scope (group secrets only). `*` (env-agnostic, default)
/// or a concrete env matched against `apps.environment` at resolution.
#[serde(default)]
pub env: Option<String>,
}
// ----------------------------------------------------------------------------
// App handlers (env-agnostic, scope `*`)
// ----------------------------------------------------------------------------
async fn list_app_secrets(
State(s): State<SecretsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
@@ -80,32 +136,10 @@ async fn list_secrets(
Capability::AppSecretsRead(app_id),
)
.await?;
let page = s
.repo
.list_meta(app_id, q.cursor.as_deref(), q.limit.unwrap_or(0))
.await?;
Ok(Json(ListSecretsResponse {
secrets: page
.items
.into_iter()
.map(|m| SecretItem {
name: m.name,
updated_at: m.updated_at,
})
.collect(),
next_cursor: page.next_cursor,
}))
list_meta(&*s.repo, SecretOwner::App(app_id), &q).await
}
#[derive(Debug, Deserialize)]
pub struct SetSecretRequest {
pub name: String,
/// Any JSON value — the dashboard sends a single-line string, but
/// maps/arrays/numbers round-trip too (matching `secrets::set`).
pub value: serde_json::Value,
}
async fn set_secret(
async fn set_app_secret(
State(s): State<SecretsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
@@ -118,23 +152,17 @@ async fn set_secret(
Capability::AppSecretsWrite(app_id),
)
.await?;
validate_secret_name(&input.name)?;
// Audit 2026-06-11 H-D1 — v1 envelope with AAD bound to
// (app_id, name). Same path as the SDK secrets::set.
let (ciphertext, nonce, version) = seal(
&s.master_key,
app_id,
&input.name,
&input.value,
s.max_value_bytes,
)?;
s.repo
.set(app_id, &input.name, &ciphertext, &nonce, version)
.await?;
Ok(StatusCode::NO_CONTENT)
// App secrets are always env-agnostic; reject a stray `env`.
if input.env.as_deref().is_some_and(|e| e != APP_SECRET_SCOPE) {
return Err(SecretsApiError::Invalid(
"app secrets are env-agnostic; set an environment scope on a group secret instead"
.into(),
));
}
seal_and_store(&s, SecretOwner::App(app_id), APP_SECRET_SCOPE, input).await
}
async fn delete_secret(
async fn delete_app_secret(
State(s): State<SecretsState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, name)): Path<(String, String)>,
@@ -146,12 +174,161 @@ async fn delete_secret(
Capability::AppSecretsWrite(app_id),
)
.await?;
if !s.repo.delete(app_id, &name).await? {
delete(&*s.repo, SecretOwner::App(app_id), APP_SECRET_SCOPE, &name).await
}
// ----------------------------------------------------------------------------
// Group handlers (env-scoped)
// ----------------------------------------------------------------------------
async fn list_group_secrets(
State(s): State<SecretsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Query(q): Query<ListQuery>,
) -> Result<Json<ListSecretsResponse>, SecretsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupSecretsWrite(group_id),
)
.await?;
list_meta(&*s.repo, SecretOwner::Group(group_id), &q).await
}
async fn set_group_secret(
State(s): State<SecretsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<SetSecretRequest>,
) -> Result<StatusCode, SecretsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupSecretsWrite(group_id),
)
.await?;
let env = input.env.clone().unwrap_or_else(|| APP_SECRET_SCOPE.into());
validate_env_scope(&env)?;
seal_and_store(&s, SecretOwner::Group(group_id), &env, input).await
}
async fn delete_group_secret(
State(s): State<SecretsState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, name)): Path<(String, String)>,
Query(q): Query<EnvQuery>,
) -> Result<StatusCode, SecretsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupSecretsWrite(group_id),
)
.await?;
let env = q.env.unwrap_or_else(|| APP_SECRET_SCOPE.into());
validate_env_scope(&env)?;
delete(&*s.repo, SecretOwner::Group(group_id), &env, &name).await
}
/// The ONE plaintext-returning endpoint. Gated by `GroupSecretsRead`
/// (group_admin) — the masked-secret boundary. A descendant app's dev can
/// see the secret exists and consume it at runtime, but only a reader at the
/// owning group gets the value here.
async fn read_group_secret_value(
State(s): State<SecretsState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, name)): Path<(String, String)>,
Query(q): Query<EnvQuery>,
) -> Result<Json<serde_json::Value>, SecretsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupSecretsRead(group_id),
)
.await?;
validate_secret_name(&name)?;
let env = q.env.unwrap_or_else(|| APP_SECRET_SCOPE.into());
validate_env_scope(&env)?;
let owner = SecretOwner::Group(group_id);
let stored = s
.repo
.get(owner, &env, &name)
.await?
.ok_or(SecretsApiError::NotFound)?;
let value = open(&s.master_key, owner, &name, &stored).map_err(|e| {
tracing::error!(group_id = %group_id, secret = %name, "group secret could not be decrypted");
SecretsApiError::from(e)
})?;
Ok(Json(json!({ "name": name, "env": env, "value": value })))
}
// ----------------------------------------------------------------------------
// Shared owner-generic bodies
// ----------------------------------------------------------------------------
async fn list_meta(
repo: &dyn SecretsRepo,
owner: SecretOwner,
q: &ListQuery,
) -> Result<Json<ListSecretsResponse>, SecretsApiError> {
let page = repo
.list_meta(owner, q.cursor.as_deref(), q.limit.unwrap_or(0))
.await?;
Ok(Json(ListSecretsResponse {
secrets: page
.items
.into_iter()
.map(|m| SecretItem {
name: m.name,
env: m.environment_scope,
updated_at: m.updated_at,
})
.collect(),
next_cursor: page.next_cursor,
}))
}
async fn seal_and_store(
s: &SecretsState,
owner: SecretOwner,
env: &str,
input: SetSecretRequest,
) -> Result<StatusCode, SecretsApiError> {
validate_secret_name(&input.name)?;
// Audit 2026-06-11 H-D1 — v1 envelope with AAD bound to the owner+name.
let (ciphertext, nonce, version) = seal(
&s.master_key,
owner,
&input.name,
&input.value,
s.max_value_bytes,
)?;
s.repo
.set(owner, env, &input.name, &ciphertext, &nonce, version)
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn delete(
repo: &dyn SecretsRepo,
owner: SecretOwner,
env: &str,
name: &str,
) -> Result<StatusCode, SecretsApiError> {
if !repo.delete(owner, env, name).await? {
return Err(SecretsApiError::NotFound);
}
Ok(StatusCode::NO_CONTENT)
}
// ----------------------------------------------------------------------------
// Resolution + validation
// ----------------------------------------------------------------------------
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, SecretsApiError> {
crate::app_repo::resolve_app(apps, ident)
.await
@@ -160,10 +337,58 @@ async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, Sec
.ok_or(SecretsApiError::AppNotFound)
}
async fn resolve_group(
groups: &dyn GroupRepository,
ident: &str,
) -> Result<GroupId, SecretsApiError> {
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
groups
.get_by_id(uuid.into())
.await
.map_err(|e| SecretsApiError::Backend(e.to_string()))?
} else {
groups
.get_by_slug(ident)
.await
.map_err(|e| SecretsApiError::Backend(e.to_string()))?
};
found.map(|g| g.id).ok_or(SecretsApiError::GroupNotFound)
}
/// Env scope is `*` (env-agnostic) or a kebab env name. Mirrors the vars
/// admin validator so a secret and a var share the same env vocabulary.
fn validate_env_scope(env: &str) -> Result<(), SecretsApiError> {
if env == "*" {
return Ok(());
}
if env.is_empty() || env.len() > 63 {
return Err(SecretsApiError::Invalid(
"env must be '*' or 163 characters".into(),
));
}
let first = env.chars().next().unwrap();
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Err(SecretsApiError::Invalid(
"env must start with a lowercase letter or digit".into(),
));
}
if !env
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(SecretsApiError::Invalid(
"env may contain only lowercase letters, digits, and hyphens".into(),
));
}
Ok(())
}
#[derive(Debug, thiserror::Error)]
pub enum SecretsApiError {
#[error("app not found")]
AppNotFound,
#[error("group not found")]
GroupNotFound,
#[error("secret not found")]
NotFound,
#[error("invalid request: {0}")]
@@ -214,7 +439,7 @@ impl From<SecretsError> for SecretsApiError {
impl IntoResponse for SecretsApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound | Self::NotFound => {
Self::AppNotFound | Self::GroupNotFound | Self::NotFound => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::Invalid(_) => (

View File

@@ -2,13 +2,23 @@
//! opaque ciphertext + nonce blobs in and out. Encryption, JSON
//! encoding, authorization, name validation, and the value-size cap all
//! live one layer up in `SecretsServiceImpl` / `secrets_api`.
//!
//! Phase 3 made secrets polymorphic-owner + env-scoped (migration
//! `0049_group_secrets.sql`): a secret is owned by exactly one app OR one
//! ancestor group, and a descendant app resolves the nearest one,
//! environment-filtered (mirroring `vars` / `config_resolver`). Writes are
//! owner-keyed via [`SecretOwner`]; the SDK read path goes through
//! [`SecretsRepo::resolve`], which walks the app→group→root chain.
use async_trait::async_trait;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use chrono::{DateTime, Utc};
use picloud_shared::AppId;
use picloud_shared::{AppId, GroupId};
use sqlx::PgPool;
use uuid::Uuid;
use crate::config_resolver::CHAIN_LEVELS_CTE;
#[derive(Debug, thiserror::Error)]
pub enum SecretsRepoError {
@@ -19,14 +29,22 @@ pub enum SecretsRepoError {
InvalidCursor,
}
/// Who owns a secret (Phase 3). A secret is owned by exactly one app OR
/// one group; the owner is bound into the AES-GCM AAD (see
/// `secrets_service::secret_aad`) so a cross-owner ciphertext swap fails
/// decryption, and it selects the partial-unique conflict target on write.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecretOwner {
App(AppId),
Group(GroupId),
}
/// An encrypted secret as it lives on disk: ciphertext (auth tag
/// appended) plus the nonce it was sealed with.
///
/// Audit 2026-06-11 H-D1: `version` discriminates the envelope layout.
/// `0` = legacy AES-GCM with no AAD (pre-2026-06-11 writes); `1` =
/// AES-GCM with AAD bound to `"secret:{app_id}:{name}"`. Migration
/// `0042_secrets_envelope_version.sql` adds the column with a default
/// of `0`, so existing rows keep working.
/// AES-GCM with AAD bound to the owner+name (see `secrets_service`).
#[derive(Debug, Clone)]
pub struct StoredSecret {
pub encrypted_value: Vec<u8>,
@@ -34,11 +52,21 @@ pub struct StoredSecret {
pub version: i16,
}
/// The winner of an inherited-secret resolution: the stored ciphertext
/// plus the owner it actually came from (app-own or an ancestor group),
/// which the caller needs to pick the right AAD when decrypting.
#[derive(Debug, Clone)]
pub struct ResolvedSecret {
pub owner: SecretOwner,
pub stored: StoredSecret,
}
/// Admin-surface metadata for one secret. Values are never returned —
/// only the name and the last-modified timestamp.
/// only the name, its environment scope, and the last-modified timestamp.
#[derive(Debug, Clone)]
pub struct SecretMeta {
pub name: String,
pub environment_scope: String,
pub updated_at: DateTime<Utc>,
}
@@ -60,38 +88,62 @@ pub struct SecretsMetaPage {
/// substitute an in-memory backing without Postgres.
#[async_trait]
pub trait SecretsRepo: Send + Sync {
async fn get(
/// Resolve the effective secret for `app_id` by name: walk the
/// app→ancestor-group chain (depth 0 = the app), env-filter to the
/// app's environment or `*`, and return the nearest winner (with
/// `@E` beating `*` within a level). `None` if no level defines it.
/// This is the runtime injection path — isolation is anchored to
/// `app_id`, so an app only ever sees its own + its ancestors' secrets.
async fn resolve(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<ResolvedSecret>, SecretsRepoError>;
/// Read one owner's OWN secret at a specific env scope (no inheritance).
/// Backs the group-gated human value-read and the apply email path.
async fn get(
&self,
owner: SecretOwner,
env_scope: &str,
name: &str,
) -> Result<Option<StoredSecret>, SecretsRepoError>;
/// Upsert (overwrite if present). `version` is the AES-GCM envelope
/// discriminator from [`StoredSecret::version`].
/// Upsert (overwrite if present) one `(owner, env_scope, name)` row.
/// `version` is the AES-GCM envelope discriminator.
async fn set(
&self,
app_id: AppId,
owner: SecretOwner,
env_scope: &str,
name: &str,
encrypted_value: &[u8],
nonce: &[u8],
version: i16,
) -> Result<(), SecretsRepoError>;
/// Delete; returns whether a row was present.
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError>;
/// Delete one `(owner, env_scope, name)` row; returns whether a row
/// was present.
async fn delete(
&self,
owner: SecretOwner,
env_scope: &str,
name: &str,
) -> Result<bool, SecretsRepoError>;
/// Names only — the SDK `list` surface.
/// Distinct names of an owner's OWN secrets (NOT inherited) — the SDK
/// `list` surface. Names are de-duplicated across env scopes.
async fn list_names(
&self,
app_id: AppId,
owner: SecretOwner,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsNamePage, SecretsRepoError>;
/// Name + updated_at — the admin `GET` surface.
/// Name + scope + updated_at of an owner's OWN secrets — the admin
/// `GET` surface.
async fn list_meta(
&self,
app_id: AppId,
owner: SecretOwner,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsMetaPage, SecretsRepoError>;
@@ -131,21 +183,98 @@ pub(crate) fn decode_cursor(cursor: &str) -> Result<String, SecretsRepoError> {
String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)
}
/// `list_meta` orders by `(name, environment_scope)` — a name can now have
/// several env-scoped rows (group secrets) — so its keyset cursor must carry
/// BOTH columns, else a name whose scopes straddle a page boundary loses its
/// tail. Encoded as base64url of `name \x1f scope` (US is not a valid env or
/// secret-name char, so it's an unambiguous delimiter).
const CURSOR_SEP: char = '\u{1f}';
fn encode_meta_cursor(name: &str, scope: &str) -> String {
URL_SAFE_NO_PAD.encode(format!("{name}{CURSOR_SEP}{scope}").as_bytes())
}
fn decode_meta_cursor(cursor: &str) -> Result<(String, String), SecretsRepoError> {
let bytes = URL_SAFE_NO_PAD
.decode(cursor)
.map_err(|_| SecretsRepoError::InvalidCursor)?;
let s = String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)?;
s.split_once(CURSOR_SEP)
.map(|(n, sc)| (n.to_string(), sc.to_string()))
.ok_or(SecretsRepoError::InvalidCursor)
}
/// `(owner_column, owner_uuid)` for binding an owner into a query.
fn owner_bind(owner: SecretOwner) -> (&'static str, Uuid) {
match owner {
SecretOwner::App(a) => ("app_id", a.into_inner()),
SecretOwner::Group(g) => ("group_id", g.into_inner()),
}
}
#[async_trait]
impl SecretsRepo for PostgresSecretsRepo {
async fn get(
async fn resolve(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<StoredSecret>, SecretsRepoError> {
let row: Option<(Vec<u8>, Vec<u8>, i16)> = sqlx::query_as(
"SELECT encrypted_value, nonce, version FROM secrets \
WHERE app_id = $1 AND name = $2",
) -> Result<Option<ResolvedSecret>, SecretsRepoError> {
// Reuse the shared chain-walk ($1 = app_id), join secrets by name,
// env-filter, and take the nearest level — `@E` beating `*` within a
// level via the secondary sort key. One row out, or none.
let sql = format!(
"{CHAIN_LEVELS_CTE} \
SELECT CASE WHEN s.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
COALESCE(s.app_id, s.group_id) AS owner_id, \
s.encrypted_value, s.nonce, s.version \
FROM chain c \
JOIN secrets s ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
WHERE s.name = $2 \
AND (s.environment_scope = '*' OR s.environment_scope = c.app_env) \
ORDER BY c.depth ASC, (s.environment_scope <> '*') DESC \
LIMIT 1"
);
let row: Option<(String, Uuid, Vec<u8>, Vec<u8>, i16)> = sqlx::query_as(&sql)
.bind(app_id.into_inner())
.bind(name)
.fetch_optional(&self.pool)
.await?;
Ok(
row.map(|(owner_kind, owner_id, encrypted_value, nonce, version)| {
let owner = if owner_kind == "app" {
SecretOwner::App(AppId::from(owner_id))
} else {
SecretOwner::Group(GroupId::from(owner_id))
};
ResolvedSecret {
owner,
stored: StoredSecret {
encrypted_value,
nonce,
version,
},
}
}),
)
.bind(app_id.into_inner())
.bind(name)
.fetch_optional(&self.pool)
.await?;
}
async fn get(
&self,
owner: SecretOwner,
env_scope: &str,
name: &str,
) -> Result<Option<StoredSecret>, SecretsRepoError> {
let (col, id) = owner_bind(owner);
let sql = format!(
"SELECT encrypted_value, nonce, version FROM secrets \
WHERE {col} = $1 AND environment_scope = $2 AND name = $3"
);
let row: Option<(Vec<u8>, Vec<u8>, i16)> = sqlx::query_as(&sql)
.bind(id)
.bind(env_scope)
.bind(name)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|(encrypted_value, nonce, version)| StoredSecret {
encrypted_value,
nonce,
@@ -155,34 +284,54 @@ impl SecretsRepo for PostgresSecretsRepo {
async fn set(
&self,
app_id: AppId,
owner: SecretOwner,
env_scope: &str,
name: &str,
encrypted_value: &[u8],
nonce: &[u8],
version: i16,
) -> Result<(), SecretsRepoError> {
sqlx::query(
"INSERT INTO secrets (app_id, name, encrypted_value, nonce, version) \
VALUES ($1, $2, $3, $4, $5) \
ON CONFLICT (app_id, name) DO UPDATE \
// Owner-kind-specific SQL: write only the owner's nullable column and
// restate the partial-unique predicate as the ON CONFLICT arbiter.
let (col, id) = owner_bind(owner);
let predicate = match owner {
SecretOwner::App(_) => "app_id IS NOT NULL",
SecretOwner::Group(_) => "group_id IS NOT NULL",
};
let sql = format!(
"INSERT INTO secrets ({col}, environment_scope, name, encrypted_value, nonce, version) \
VALUES ($1, $2, $3, $4, $5, $6) \
ON CONFLICT ({col}, environment_scope, name) WHERE {predicate} DO UPDATE \
SET encrypted_value = EXCLUDED.encrypted_value, \
nonce = EXCLUDED.nonce, \
version = EXCLUDED.version, \
updated_at = NOW()",
)
.bind(app_id.into_inner())
.bind(name)
.bind(encrypted_value)
.bind(nonce)
.bind(version)
.execute(&self.pool)
.await?;
updated_at = NOW()"
);
sqlx::query(&sql)
.bind(id)
.bind(env_scope)
.bind(name)
.bind(encrypted_value)
.bind(nonce)
.bind(version)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError> {
let res = sqlx::query("DELETE FROM secrets WHERE app_id = $1 AND name = $2")
.bind(app_id.into_inner())
async fn delete(
&self,
owner: SecretOwner,
env_scope: &str,
name: &str,
) -> Result<bool, SecretsRepoError> {
let (col, id) = owner_bind(owner);
let sql = format!(
"DELETE FROM secrets WHERE {col} = $1 AND environment_scope = $2 AND name = $3"
);
let res = sqlx::query(&sql)
.bind(id)
.bind(env_scope)
.bind(name)
.execute(&self.pool)
.await?;
@@ -191,7 +340,7 @@ impl SecretsRepo for PostgresSecretsRepo {
async fn list_names(
&self,
app_id: AppId,
owner: SecretOwner,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsNamePage, SecretsRepoError> {
@@ -201,16 +350,20 @@ impl SecretsRepo for PostgresSecretsRepo {
None => None,
};
let take = i64::from(limit) + 1;
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT name FROM secrets \
WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \
ORDER BY name ASC LIMIT $3",
)
.bind(app_id.into_inner())
.bind(last_name.as_deref())
.bind(take)
.fetch_all(&self.pool)
.await?;
let (col, id) = owner_bind(owner);
// DISTINCT collapses a name that exists at several env scopes into
// one entry — the SDK `list` is a name catalogue, not per-scope.
let sql = format!(
"SELECT DISTINCT name FROM secrets \
WHERE {col} = $1 AND ($2::text IS NULL OR name > $2) \
ORDER BY name ASC LIMIT $3"
);
let rows: Vec<(String,)> = sqlx::query_as(&sql)
.bind(id)
.bind(last_name.as_deref())
.bind(take)
.fetch_all(&self.pool)
.await?;
let mut names: Vec<String> = rows.into_iter().map(|(n,)| n).collect();
let next_cursor = if names.len() > limit as usize {
@@ -224,34 +377,49 @@ impl SecretsRepo for PostgresSecretsRepo {
async fn list_meta(
&self,
app_id: AppId,
owner: SecretOwner,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsMetaPage, SecretsRepoError> {
let limit = clamp_limit(limit);
let last_name = match cursor {
Some(c) => Some(decode_cursor(c)?),
// Composite keyset on (name, environment_scope) — see encode_meta_cursor.
let last = match cursor {
Some(c) => Some(decode_meta_cursor(c)?),
None => None,
};
let (last_name, last_scope) = match &last {
Some((n, sc)) => (Some(n.as_str()), Some(sc.as_str())),
None => (None, None),
};
let take = i64::from(limit) + 1;
let rows: Vec<(String, DateTime<Utc>)> = sqlx::query_as(
"SELECT name, updated_at FROM secrets \
WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \
ORDER BY name ASC LIMIT $3",
)
.bind(app_id.into_inner())
.bind(last_name.as_deref())
.bind(take)
.fetch_all(&self.pool)
.await?;
let (col, id) = owner_bind(owner);
let sql = format!(
"SELECT name, environment_scope, updated_at FROM secrets \
WHERE {col} = $1 \
AND ($2::text IS NULL OR (name, environment_scope) > ($2, $3)) \
ORDER BY name ASC, environment_scope ASC LIMIT $4"
);
let rows: Vec<(String, String, DateTime<Utc>)> = sqlx::query_as(&sql)
.bind(id)
.bind(last_name)
.bind(last_scope)
.bind(take)
.fetch_all(&self.pool)
.await?;
let mut items: Vec<SecretMeta> = rows
.into_iter()
.map(|(name, updated_at)| SecretMeta { name, updated_at })
.map(|(name, environment_scope, updated_at)| SecretMeta {
name,
environment_scope,
updated_at,
})
.collect();
let next_cursor = if items.len() > limit as usize {
items.truncate(limit as usize);
items.last().map(|m| encode_cursor(&m.name))
items
.last()
.map(|m| encode_meta_cursor(&m.name, &m.environment_scope))
} else {
None
};

View File

@@ -22,22 +22,39 @@ use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{
crypto, validate_secret_name, AppId, MasterKey, SdkCallCx, SecretsError, SecretsListPage,
crypto, validate_secret_name, MasterKey, SdkCallCx, SecretsError, SecretsListPage,
SecretsService,
};
use crate::authz::{self, AuthzRepo, Capability};
use crate::secrets_repo::{SecretsRepo, SecretsRepoError, StoredSecret};
// `SecretOwner` is defined one layer down in `secrets_repo` (it keys both
// the storage CRUD and the AAD). Re-exported here so the historical
// `secrets_service::SecretOwner` path stays stable.
pub use crate::secrets_repo::SecretOwner;
/// Current AES-GCM envelope version for the per-app secret store.
/// `0` = legacy (no AAD); `1` = AAD-bound. New writes always emit v1.
pub const SECRET_ENVELOPE_V1: i16 = 1;
/// Audit 2026-06-11 H-D1 — AAD bound into the GCM auth tag so a
/// cross-row swap (e.g. moving one app's ciphertext under another
/// app's `(app_id, name)` slot) fails decryption.
fn secret_aad(app_id: AppId, name: &str) -> Vec<u8> {
format!("secret:{app_id}:{name}").into_bytes()
/// The env scope under which an app's OWN secrets are stored. App secrets
/// are env-agnostic — only group secrets carry a concrete environment.
const APP_SECRET_SCOPE: &str = "*";
/// Audit 2026-06-11 H-D1 — AAD bound into the GCM auth tag so a cross-row
/// swap (moving one owner's ciphertext under another's slot) fails
/// decryption. The **app** form is byte-identical to the pre-Phase-3
/// `secret:{app_id}:{name}`, so every existing v1 row keeps decrypting
/// unchanged; group secrets use a distinct `secret:group:{group_id}:{name}`
/// namespace (the `group:` infix keeps app and group AAD disjoint even if a
/// group UUID happened to equal an app UUID).
fn secret_aad(owner: SecretOwner, name: &str) -> Vec<u8> {
match owner {
SecretOwner::App(app_id) => format!("secret:{app_id}:{name}"),
SecretOwner::Group(group_id) => format!("secret:group:{group_id}:{name}"),
}
.into_bytes()
}
/// Default per-secret plaintext cap (64 KB). Override with
@@ -96,7 +113,7 @@ impl Default for SecretsConfig {
/// failure (should not happen for a `serde_json::Value`).
pub fn seal(
master_key: &MasterKey,
app_id: AppId,
owner: SecretOwner,
name: &str,
value: &serde_json::Value,
max_value_bytes: usize,
@@ -109,7 +126,7 @@ pub fn seal(
actual: plaintext.len(),
});
}
let aad = secret_aad(app_id, name);
let aad = secret_aad(owner, name);
let enc = crypto::encrypt_with_aad(&plaintext, &aad, master_key.as_bytes());
Ok((enc.ciphertext, enc.nonce, SECRET_ENVELOPE_V1))
}
@@ -125,7 +142,7 @@ pub fn seal(
/// [`SecretsError::Corrupted`] when decryption or JSON decoding fails.
pub fn open(
master_key: &MasterKey,
app_id: AppId,
owner: SecretOwner,
name: &str,
stored: &StoredSecret,
) -> Result<serde_json::Value, SecretsError> {
@@ -136,7 +153,7 @@ pub fn open(
master_key.as_bytes(),
),
SECRET_ENVELOPE_V1 => {
let aad = secret_aad(app_id, name);
let aad = secret_aad(owner, name);
crypto::decrypt_with_aad(
&stored.encrypted_value,
&stored.nonce,
@@ -259,10 +276,15 @@ impl SecretsService for SecretsServiceImpl {
) -> Result<Option<serde_json::Value>, SecretsError> {
validate_secret_name(name)?;
self.check_read(cx).await?;
let Some(stored) = self.repo.get(cx.app_id, name).await? else {
// Inherited resolution: the app's own secret, else the nearest
// ancestor group's, env-filtered. `resolve` anchors the walk to
// `cx.app_id`, so an app can only ever read its own + its ancestors'
// secrets — the cross-app isolation boundary. The winning owner comes
// back with the row so we decrypt under the AAD it was sealed with.
let Some(resolved) = self.repo.resolve(cx.app_id, name).await? else {
return Ok(None);
};
match open(&self.master_key, cx.app_id, name, &stored) {
match open(&self.master_key, resolved.owner, name, &resolved.stored) {
Ok(value) => Ok(Some(value)),
Err(e) => {
// A decrypt failure is operationally significant — surface
@@ -286,15 +308,11 @@ impl SecretsService for SecretsServiceImpl {
) -> Result<(), SecretsError> {
validate_secret_name(name)?;
self.check_write(cx).await?;
let (ciphertext, nonce, version) = seal(
&self.master_key,
cx.app_id,
name,
&value,
self.max_value_bytes,
)?;
let owner = SecretOwner::App(cx.app_id);
let (ciphertext, nonce, version) =
seal(&self.master_key, owner, name, &value, self.max_value_bytes)?;
self.repo
.set(cx.app_id, name, &ciphertext, &nonce, version)
.set(owner, APP_SECRET_SCOPE, name, &ciphertext, &nonce, version)
.await?;
Ok(())
}
@@ -302,7 +320,10 @@ impl SecretsService for SecretsServiceImpl {
async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result<bool, SecretsError> {
validate_secret_name(name)?;
self.check_write(cx).await?;
Ok(self.repo.delete(cx.app_id, name).await?)
Ok(self
.repo
.delete(SecretOwner::App(cx.app_id), APP_SECRET_SCOPE, name)
.await?)
}
async fn list(
@@ -312,7 +333,10 @@ impl SecretsService for SecretsServiceImpl {
limit: u32,
) -> Result<SecretsListPage, SecretsError> {
self.check_read(cx).await?;
let page = self.repo.list_names(cx.app_id, cursor, limit).await?;
let page = self
.repo
.list_names(SecretOwner::App(cx.app_id), cursor, limit)
.await?;
Ok(SecretsListPage {
names: page.names,
next_cursor: page.next_cursor,
@@ -328,44 +352,76 @@ impl SecretsService for SecretsServiceImpl {
mod tests {
use super::*;
use crate::authz::{AuthzError, AuthzRepo};
use crate::secrets_repo::{SecretsMetaPage, SecretsNamePage};
use crate::secrets_repo::{ResolvedSecret, SecretsMetaPage, SecretsNamePage};
use async_trait::async_trait;
use picloud_shared::{
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
UserId,
AdminUserId, AppId, AppRole, ExecutionId, GroupId, InstanceRole, Principal, RequestId,
ScriptId, UserId,
};
use std::collections::BTreeMap;
use tokio::sync::Mutex;
/// In-memory backing keyed by `(owner_key, env_scope, name)`. The owner
/// key is `"app:{uuid}"` / `"group:{uuid}"` so app and group rows never
/// collide. These unit tests exercise the app surface (scope `*`); the
/// chain-walk `resolve` is journey-tested against real Postgres, so here
/// it degrades to a plain app-own `*` lookup.
#[derive(Default)]
struct InMemorySecretsRepo {
data: Mutex<BTreeMap<(AppId, String), StoredSecret>>,
data: Mutex<BTreeMap<(String, String, String), StoredSecret>>,
}
fn owner_key(owner: SecretOwner) -> String {
match owner {
SecretOwner::App(a) => format!("app:{a}"),
SecretOwner::Group(g) => format!("group:{g}"),
}
}
#[async_trait]
impl SecretsRepo for InMemorySecretsRepo {
async fn get(
async fn resolve(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<ResolvedSecret>, SecretsRepoError> {
let owner = SecretOwner::App(app_id);
Ok(self
.data
.lock()
.await
.get(&(
owner_key(owner),
APP_SECRET_SCOPE.to_string(),
name.to_string(),
))
.cloned()
.map(|stored| ResolvedSecret { owner, stored }))
}
async fn get(
&self,
owner: SecretOwner,
env_scope: &str,
name: &str,
) -> Result<Option<StoredSecret>, SecretsRepoError> {
Ok(self
.data
.lock()
.await
.get(&(app_id, name.to_string()))
.get(&(owner_key(owner), env_scope.to_string(), name.to_string()))
.cloned())
}
async fn set(
&self,
app_id: AppId,
owner: SecretOwner,
env_scope: &str,
name: &str,
encrypted_value: &[u8],
nonce: &[u8],
version: i16,
) -> Result<(), SecretsRepoError> {
self.data.lock().await.insert(
(app_id, name.to_string()),
(owner_key(owner), env_scope.to_string(), name.to_string()),
StoredSecret {
encrypted_value: encrypted_value.to_vec(),
nonce: nonce.to_vec(),
@@ -374,29 +430,36 @@ mod tests {
);
Ok(())
}
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError> {
async fn delete(
&self,
owner: SecretOwner,
env_scope: &str,
name: &str,
) -> Result<bool, SecretsRepoError> {
Ok(self
.data
.lock()
.await
.remove(&(app_id, name.to_string()))
.remove(&(owner_key(owner), env_scope.to_string(), name.to_string()))
.is_some())
}
async fn list_names(
&self,
app_id: AppId,
owner: SecretOwner,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsNamePage, SecretsRepoError> {
let data = self.data.lock().await;
let ok = owner_key(owner);
let last = cursor.map(std::string::ToString::to_string);
let mut names: Vec<String> = data
.iter()
.filter(|((a, _), _)| *a == app_id)
.map(|((_, n), _)| n.clone())
.filter(|((o, _, _), _)| *o == ok)
.map(|((_, _, n), _)| n.clone())
.filter(|n| last.as_ref().is_none_or(|l| n > l))
.collect();
names.sort();
names.dedup();
let take = (limit as usize).max(1);
let next_cursor = if names.len() > take {
names.truncate(take);
@@ -408,7 +471,7 @@ mod tests {
}
async fn list_meta(
&self,
_app_id: AppId,
_owner: SecretOwner,
_cursor: Option<&str>,
_limit: u32,
) -> Result<SecretsMetaPage, SecretsRepoError> {
@@ -635,7 +698,11 @@ mod tests {
repo.data
.lock()
.await
.get_mut(&(app, "k".to_string()))
.get_mut(&(
owner_key(SecretOwner::App(app)),
"*".to_string(),
"k".to_string(),
))
.unwrap()
.encrypted_value[0] ^= 0xff;
let err = s.get(&anon_cx(app), "k").await.unwrap_err();
@@ -666,10 +733,21 @@ mod tests {
.data
.lock()
.await
.get(&(a, "k".to_string()))
.get(&(
owner_key(SecretOwner::App(a)),
"*".to_string(),
"k".to_string(),
))
.cloned()
.unwrap();
repo.data.lock().await.insert((b, "k".to_string()), stolen);
repo.data.lock().await.insert(
(
owner_key(SecretOwner::App(b)),
"*".to_string(),
"k".to_string(),
),
stolen,
);
let err = s.get(&anon_cx(b), "k").await.unwrap_err();
assert!(
matches!(err, SecretsError::Corrupted),
@@ -677,6 +755,55 @@ mod tests {
);
}
#[tokio::test]
async fn aad_distinguishes_app_and_group_owner() {
// Phase 3: app and group secrets live in disjoint AAD namespaces
// (`secret:{app}` vs `secret:group:{group}`). A ciphertext sealed
// for a group must not open as an app secret (and vice-versa),
// even if the raw UUIDs were equal — the `group:` infix separates
// them. Exercise the free seal/open functions directly.
let k = key();
let app = AppId::new();
let group = GroupId::new();
let value = serde_json::json!("shared-config");
// Seal under the GROUP owner.
let (ct, nonce, version) =
seal(&k, SecretOwner::Group(group), "db_url", &value, 4096).unwrap();
let stored = StoredSecret {
encrypted_value: ct,
nonce: nonce.to_vec(),
version,
};
// Opens fine as the same group owner.
assert_eq!(
open(&k, SecretOwner::Group(group), "db_url", &stored).unwrap(),
value
);
// Fails as an app owner (AAD mismatch) — even reusing the UUID.
let app_from_group = AppId::from(group.into_inner());
assert!(matches!(
open(&k, SecretOwner::App(app_from_group), "db_url", &stored),
Err(SecretsError::Corrupted)
));
// And the symmetric direction: an app-sealed row won't open as a group.
let (ct2, nonce2, v2) = seal(&k, SecretOwner::App(app), "db_url", &value, 4096).unwrap();
let stored2 = StoredSecret {
encrypted_value: ct2,
nonce: nonce2.to_vec(),
version: v2,
};
assert!(matches!(
open(
&k,
SecretOwner::Group(GroupId::from(app.into_inner())),
"db_url",
&stored2
),
Err(SecretsError::Corrupted)
));
}
#[tokio::test]
async fn aad_blocks_cross_name_ciphertext_swap() {
// Same app, different name → AAD mismatch.
@@ -695,13 +822,21 @@ mod tests {
.data
.lock()
.await
.get(&(a, "real".to_string()))
.get(&(
owner_key(SecretOwner::App(a)),
"*".to_string(),
"real".to_string(),
))
.cloned()
.unwrap();
repo.data
.lock()
.await
.insert((a, "renamed".to_string()), stolen);
repo.data.lock().await.insert(
(
owner_key(SecretOwner::App(a)),
"*".to_string(),
"renamed".to_string(),
),
stolen,
);
let err = s.get(&anon_cx(a), "renamed").await.unwrap_err();
assert!(matches!(err, SecretsError::Corrupted));
}
@@ -726,7 +861,11 @@ mod tests {
)
.unwrap();
repo.data.lock().await.insert(
(app, "old".to_string()),
(
owner_key(SecretOwner::App(app)),
"*".to_string(),
"old".to_string(),
),
StoredSecret {
encrypted_value: ct,
nonce: nonce.to_vec(),

View File

@@ -0,0 +1,108 @@
//! Template-suppression markers (§11 tail) — the `template_suppressions` table
//! (0058, made owner-polymorphic in 0060). A marker `(owner, target_kind,
//! reference)` records that an OWNER opts OUT of an inherited group template: a
//! handler script name it declines (`target_kind='trigger'`) or a path it
//! declines (`target_kind='route'`).
//!
//! Owner-polymorphic (like `extension_points`): an **app** declines for itself;
//! a **group** declines for its whole subtree. The functions take a
//! `ScriptOwner` and bind the matching owner column. Same tx-function style:
//! read over `&PgPool`, write over a `&mut Transaction`.
use picloud_shared::ScriptOwner;
use sqlx::{PgPool, Postgres, Transaction};
/// The two suppressible template kinds, as stored in `target_kind`.
pub const TARGET_TRIGGER: &str = "trigger";
pub const TARGET_ROUTE: &str = "route";
/// List the suppression markers declared at `owner`, as `(target_kind,
/// reference)` pairs, stably ordered. Backs `load_current` (the apply diff) and
/// the read-only `suppress ls`.
pub async fn list_for_owner(
pool: &PgPool,
owner: ScriptOwner,
) -> Result<Vec<(String, String)>, sqlx::Error> {
let sql = match owner {
ScriptOwner::App(_) => {
"SELECT target_kind, reference FROM template_suppressions \
WHERE app_id = $1 ORDER BY target_kind, LOWER(reference)"
}
ScriptOwner::Group(_) => {
"SELECT target_kind, reference FROM template_suppressions \
WHERE group_id = $1 ORDER BY target_kind, LOWER(reference)"
}
};
sqlx::query_as(sql)
.bind(owner_uuid(owner))
.fetch_all(pool)
.await
}
/// Insert a suppression marker in the apply transaction. Idempotent: re-apply
/// of an already-declared `(kind, reference)` is a no-op (`ON CONFLICT DO
/// NOTHING` against the per-owner partial unique index), so the marker survives
/// without a spurious version bump.
pub async fn insert_suppression_tx(
tx: &mut Transaction<'_, Postgres>,
owner: ScriptOwner,
target_kind: &str,
reference: &str,
) -> Result<(), sqlx::Error> {
let (app_id, group_id) = owner_columns(owner);
sqlx::query(
"INSERT INTO template_suppressions (app_id, group_id, target_kind, reference) \
VALUES ($1, $2, $3, $4) \
ON CONFLICT DO NOTHING",
)
.bind(app_id)
.bind(group_id)
.bind(target_kind)
.bind(reference)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Delete a suppression marker in the apply transaction. Used by `--prune` when
/// the manifest stops declaring the reference → the template re-inherits.
pub async fn delete_suppression_tx(
tx: &mut Transaction<'_, Postgres>,
owner: ScriptOwner,
target_kind: &str,
reference: &str,
) -> Result<(), sqlx::Error> {
let sql = match owner {
ScriptOwner::App(_) => {
"DELETE FROM template_suppressions \
WHERE app_id = $1 AND target_kind = $2 AND reference = $3"
}
ScriptOwner::Group(_) => {
"DELETE FROM template_suppressions \
WHERE group_id = $1 AND target_kind = $2 AND reference = $3"
}
};
sqlx::query(sql)
.bind(owner_uuid(owner))
.bind(target_kind)
.bind(reference)
.execute(&mut **tx)
.await?;
Ok(())
}
/// `(app_id, group_id)` bind pair for the polymorphic owner — exactly one set.
fn owner_columns(owner: ScriptOwner) -> (Option<uuid::Uuid>, Option<uuid::Uuid>) {
match owner {
ScriptOwner::App(a) => (Some(a.into_inner()), None),
ScriptOwner::Group(g) => (None, Some(g.into_inner())),
}
}
/// The single owner UUID (app or group), for the WHERE-on-one-column queries.
fn owner_uuid(owner: ScriptOwner) -> uuid::Uuid {
match owner {
ScriptOwner::App(a) => a.into_inner(),
ScriptOwner::Group(g) => g.into_inner(),
}
}

View File

@@ -7,14 +7,35 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use futures::stream::{self, TryStreamExt};
use picloud_shared::{
AdminUserId, AppId, DocsEventOp, FilesEventOp, KvEventOp, ScriptId, TriggerId,
AdminUserId, AppId, DocsEventOp, FilesEventOp, GroupId, KvEventOp, ScriptId, ScriptOwner,
TriggerId,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::config_resolver::CHAIN_LEVELS_CTE;
use crate::trigger_config::BackoffShape;
/// §11 tail opt-out: exclude an INHERITED (group-owned) trigger whose handler
/// script name a suppression on the firing app's chain declines. Appended to
/// each dispatch match query's WHERE clause; `$1` is the firing app_id (already
/// bound by the `CHAIN_LEVELS_CTE`). §11 tail M1: the anti-join joins the
/// `chain` CTE, so a suppression owned by the firing app OR any ANCESTOR GROUP
/// on its chain applies — a group declines a template for its whole subtree.
/// The `t.group_id IS NOT NULL` guard keeps an app's OWN trigger unsuppressable
/// (an owner can only decline what it inherits); `t.sealed = FALSE` keeps a
/// mandatory template non-declinable.
pub(crate) const TRIGGER_SUPPRESSION_ANTIJOIN: &str = " \
AND NOT EXISTS ( \
SELECT 1 FROM template_suppressions ts \
JOIN scripts s ON s.id = t.script_id \
JOIN chain sc ON (ts.app_id = sc.app_owner OR ts.group_id = sc.group_owner) \
WHERE ts.target_kind = 'trigger' \
AND LOWER(ts.reference) = LOWER(s.name) \
AND t.group_id IS NOT NULL \
AND t.sealed = FALSE)";
#[derive(Debug, thiserror::Error)]
pub enum TriggerRepoError {
#[error("database error: {0}")]
@@ -29,15 +50,35 @@ pub enum TriggerRepoError {
/// Parent-table row plus the per-kind detail merged in. Serialized
/// back to admin clients via the JSON API.
// enabled + sealed + shared + materialized are distinct trigger facets, not a
// bit-packed config — a struct is the right shape.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Trigger {
pub id: TriggerId,
pub app_id: AppId,
/// Polymorphic owner (§11 tail): an app owns a trigger; a group owns a
/// trigger TEMPLATE. Exactly one is set (DB CHECK). Mirrors `Script`.
pub app_id: Option<AppId>,
pub group_id: Option<GroupId>,
pub script_id: ScriptId,
/// §4.5 per-app trigger identifier; the manifest merge/upsert key.
pub name: String,
pub kind: TriggerKind,
pub enabled: bool,
/// §11 tail: `true` for a sealed (non-suppressible) group trigger template.
/// Always `false` for an app-owned trigger. Part of the apply diff identity
/// so toggling it re-materializes the row.
#[serde(default)]
pub sealed: bool,
/// §11.6: `true` for a shared-collection group trigger (watches a shared
/// collection, not per-app ones). Also part of the apply diff identity.
#[serde(default)]
pub shared: bool,
/// §4.5 M5: `true` for a materialized copy of a group stateful template
/// (`materialized_from` is set). Read-only — inherited, not app-authored.
/// Derived, not stored directly (see `TriggerRow::materialized_from`).
#[serde(default)]
pub materialized: bool,
pub dispatch_mode: TriggerDispatchMode,
pub retry_max_attempts: u32,
pub retry_backoff: BackoffShape,
@@ -296,6 +337,11 @@ pub struct ActiveQueueConsumer {
pub retry_backoff: BackoffShape,
pub retry_base_ms: u32,
pub registered_by_principal: AdminUserId,
/// §11.6 D3: `Some(group)` when this consumer is a materialized copy of a
/// SHARED group queue template — it drains `group_queue_messages(group,
/// queue_name)` (competing consumers) instead of the per-app store. `None`
/// for an ordinary per-app or non-shared-template consumer.
pub shared_group: Option<GroupId>,
}
/// What the inbound-email webhook receiver needs to verify + dispatch a
@@ -414,6 +460,13 @@ pub trait TriggerRepo: Send + Sync {
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError>;
/// Group-owned trigger TEMPLATES (§11 tail) declared directly at `group_id`.
/// Used by the declarative apply diff for a `[group]` node. Defaults to
/// empty so non-Postgres impls (tests) need not provide it.
async fn list_for_group(&self, _group_id: GroupId) -> Result<Vec<Trigger>, TriggerRepoError> {
Ok(Vec::new())
}
async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError>;
async fn delete(&self, id: TriggerId) -> Result<bool, TriggerRepoError>;
@@ -449,6 +502,39 @@ pub trait TriggerRepo: Send + Sync {
op: FilesEventOp,
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError>;
/// §11.6 shared-collection fan-out: enabled `shared = true` triggers on the
/// OWNING group (the group that declares the shared collection), glob-matched
/// in Rust. The dispatch runs under the writing app; these methods key on the
/// owning group, not the writer's chain. Default empty so non-Postgres impls
/// degrade to "no shared triggers".
async fn list_matching_shared_kv(
&self,
owning_group: GroupId,
collection: &str,
op: KvEventOp,
) -> Result<Vec<KvTriggerMatch>, TriggerRepoError> {
let _ = (owning_group, collection, op);
Ok(Vec::new())
}
async fn list_matching_shared_docs(
&self,
owning_group: GroupId,
collection: &str,
op: DocsEventOp,
) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError> {
let _ = (owning_group, collection, op);
Ok(Vec::new())
}
async fn list_matching_shared_files(
&self,
owning_group: GroupId,
collection: &str,
op: FilesEventOp,
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError> {
let _ = (owning_group, collection, op);
Ok(Vec::new())
}
/// Dispatcher hot path for dead-letter fan-out. Filters: source
/// (or any-source), originating trigger_id (or any), originating
/// script_id (or any). Each filter is "match OR is_null".
@@ -502,6 +588,39 @@ impl PostgresTriggerRepo {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
/// §11.6 shared-collection match: enabled `shared = true` triggers on the
/// OWNING group, glob + op filtered in Rust (like the per-app path). `kind`
/// and `detail_table` are hard-coded literals from the three call sites (no
/// injection). No chain / no suppression anti-join — a shared trigger is
/// declared on the group that owns the collection and fires under the writer.
async fn shared_match_rows(
&self,
kind: &str,
detail_table: &str,
owning_group: GroupId,
collection: &str,
op_str: &str,
) -> Result<Vec<KvMatchRow>, TriggerRepoError> {
let rows: Vec<KvMatchRow> = sqlx::query_as(&format!(
"SELECT t.id, t.script_id, t.dispatch_mode, \
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
t.registered_by_principal, \
d.collection_glob, d.ops \
FROM triggers t \
JOIN {detail_table} d ON d.trigger_id = t.id \
WHERE t.kind = '{kind}' AND t.enabled = TRUE \
AND t.shared = TRUE AND t.group_id = $1"
))
.bind(owning_group.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.filter(|r| collection_matches(&r.collection_glob, collection))
.filter(|r| r.ops.is_empty() || r.ops.iter().any(|o| o == op_str))
.collect())
}
}
/// Insert a trigger (parent row + per-kind detail) within an existing
@@ -511,13 +630,19 @@ impl PostgresTriggerRepo {
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub(crate) async fn insert_trigger_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
app_id: AppId,
owner: ScriptOwner,
script_id: ScriptId,
registered_by: AdminUserId,
dispatch_mode: TriggerDispatchMode,
retry_max_attempts: u32,
retry_backoff: BackoffShape,
retry_base_ms: u32,
// §11 tail: `true` for a sealed (non-suppressible) group trigger template.
// Always `false` for an app-owned trigger.
sealed: bool,
// §11.6: `true` for a shared-collection group trigger (watches a shared
// collection, not per-app ones). Always `false` for an app-owned trigger.
shared: bool,
details: &TriggerDetails,
) -> Result<TriggerId, TriggerRepoError> {
let kind = match details {
@@ -533,12 +658,20 @@ pub(crate) async fn insert_trigger_tx(
));
}
};
// §11 tail: a trigger is owned by an app XOR a group (the latter a
// template, unioned into descendant apps via the chain CTE at dispatch).
let (owner_app_id, owner_group_id) = match owner {
ScriptOwner::App(a) => (Some(a.into_inner()), None),
ScriptOwner::Group(g) => (None, Some(g.into_inner())),
};
// Queue: enforce the one-consumer-per-(app_id, queue_name) invariant —
// the same advisory-lock + existence guard the interactive
// `create_queue_trigger` uses. Without this, a concurrent apply +
// interactive create on disjoint locks could double-register a queue
// consumer (there is no DB unique constraint backing the invariant).
if let TriggerDetails::Queue { queue_name, .. } = details {
// Queue is app-only (rejected on a group), so this never runs for a
// group owner.
if let (TriggerDetails::Queue { queue_name, .. }, ScriptOwner::App(app_id)) = (details, owner) {
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(advisory_lock_key(app_id, queue_name))
.execute(&mut **tx)
@@ -560,12 +693,13 @@ pub(crate) async fn insert_trigger_tx(
}
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO triggers ( \
app_id, script_id, kind, enabled, dispatch_mode, \
app_id, group_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8) RETURNING id",
registered_by_principal, sealed, shared \
) VALUES ($1, $2, $3, $4, TRUE, $5, $6, $7, $8, $9, $10, $11) RETURNING id",
)
.bind(app_id.into_inner())
.bind(owner_app_id)
.bind(owner_group_id)
.bind(script_id.into_inner())
.bind(kind)
.bind(dispatch_mode.as_str())
@@ -573,6 +707,8 @@ pub(crate) async fn insert_trigger_tx(
.bind(retry_backoff.as_str())
.bind(i32::try_from(retry_base_ms).unwrap_or(1000))
.bind(registered_by.into_inner())
.bind(sealed)
.bind(shared)
.fetch_one(&mut **tx)
.await?;
let tid = row.0;
@@ -669,25 +805,35 @@ pub(crate) async fn insert_trigger_tx(
}
/// Insert an email trigger within a transaction. The inbound HMAC secret
/// is sealed by the apply engine (resolved from the app's secret store);
/// is sealed by the apply engine (resolved from the owner's secret store);
/// this writes the ciphertext. Parent retry settings match the
/// interactive `create_email_trigger` path (async, 3, exponential, 1000).
///
/// §4.5 M5: `owner` is app-XOR-group. A `ScriptOwner::Group` writes an email
/// trigger TEMPLATE (group_id set, app_id NULL) that is never dispatched
/// directly (`email_inbound_target` filters `app_id IS NOT NULL`) — the
/// materializer copies it into a per-descendant-app row.
pub(crate) async fn insert_email_trigger_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
app_id: AppId,
owner: ScriptOwner,
script_id: ScriptId,
registered_by: AdminUserId,
inbound_secret_encrypted: &[u8],
inbound_secret_nonce: &[u8],
) -> Result<TriggerId, TriggerRepoError> {
let (owner_app_id, owner_group_id) = match owner {
ScriptOwner::App(a) => (Some(a.into_inner()), None),
ScriptOwner::Group(g) => (None, Some(g.into_inner())),
};
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO triggers ( \
app_id, script_id, kind, enabled, dispatch_mode, \
app_id, group_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) RETURNING id",
) VALUES ($1, $2, $3, 'email', TRUE, 'async', 3, 'exponential', 1000, $4) RETURNING id",
)
.bind(app_id.into_inner())
.bind(owner_app_id)
.bind(owner_group_id)
.bind(script_id.into_inner())
.bind(registered_by.into_inner())
.fetch_one(&mut **tx)
@@ -766,11 +912,15 @@ impl TriggerRepo for PostgresTriggerRepo {
Ok(Trigger {
id: parent.id.into(),
app_id: parent.app_id.into(),
app_id: parent.app_id.map(Into::into),
group_id: parent.group_id.map(Into::into),
script_id: parent.script_id.into(),
name: parent.name.clone(),
kind: TriggerKind::Kv,
enabled: parent.enabled,
sealed: parent.sealed,
shared: parent.shared,
materialized: parent.materialized_from.is_some(),
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
@@ -832,11 +982,15 @@ impl TriggerRepo for PostgresTriggerRepo {
Ok(Trigger {
id: parent.id.into(),
app_id: parent.app_id.into(),
app_id: parent.app_id.map(Into::into),
group_id: parent.group_id.map(Into::into),
script_id: parent.script_id.into(),
name: parent.name.clone(),
kind: TriggerKind::Docs,
enabled: parent.enabled,
sealed: parent.sealed,
shared: parent.shared,
materialized: parent.materialized_from.is_some(),
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
@@ -893,11 +1047,15 @@ impl TriggerRepo for PostgresTriggerRepo {
Ok(Trigger {
id: parent.id.into(),
app_id: parent.app_id.into(),
app_id: parent.app_id.map(Into::into),
group_id: parent.group_id.map(Into::into),
script_id: parent.script_id.into(),
name: parent.name.clone(),
kind: TriggerKind::DeadLetter,
enabled: parent.enabled,
sealed: parent.sealed,
shared: parent.shared,
materialized: parent.materialized_from.is_some(),
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(1),
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
@@ -960,11 +1118,15 @@ impl TriggerRepo for PostgresTriggerRepo {
Ok(Trigger {
id: parent.id.into(),
app_id: parent.app_id.into(),
app_id: parent.app_id.map(Into::into),
group_id: parent.group_id.map(Into::into),
script_id: parent.script_id.into(),
name: parent.name.clone(),
kind: TriggerKind::Cron,
enabled: parent.enabled,
sealed: parent.sealed,
shared: parent.shared,
materialized: parent.materialized_from.is_some(),
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
@@ -1027,11 +1189,15 @@ impl TriggerRepo for PostgresTriggerRepo {
Ok(Trigger {
id: parent.id.into(),
app_id: parent.app_id.into(),
app_id: parent.app_id.map(Into::into),
group_id: parent.group_id.map(Into::into),
script_id: parent.script_id.into(),
name: parent.name.clone(),
kind: TriggerKind::Files,
enabled: parent.enabled,
sealed: parent.sealed,
shared: parent.shared,
materialized: parent.materialized_from.is_some(),
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
@@ -1089,11 +1255,15 @@ impl TriggerRepo for PostgresTriggerRepo {
Ok(Trigger {
id: parent.id.into(),
app_id: parent.app_id.into(),
app_id: parent.app_id.map(Into::into),
group_id: parent.group_id.map(Into::into),
script_id: parent.script_id.into(),
name: parent.name.clone(),
kind: TriggerKind::Pubsub,
enabled: parent.enabled,
sealed: parent.sealed,
shared: parent.shared,
materialized: parent.materialized_from.is_some(),
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
@@ -1149,11 +1319,15 @@ impl TriggerRepo for PostgresTriggerRepo {
Ok(Trigger {
id: parent.id.into(),
app_id: parent.app_id.into(),
app_id: parent.app_id.map(Into::into),
group_id: parent.group_id.map(Into::into),
script_id: parent.script_id.into(),
name: parent.name.clone(),
kind: TriggerKind::Email,
enabled: parent.enabled,
sealed: parent.sealed,
shared: parent.shared,
materialized: parent.materialized_from.is_some(),
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
@@ -1171,12 +1345,16 @@ impl TriggerRepo for PostgresTriggerRepo {
trigger_id: TriggerId,
) -> Result<Option<EmailInboundTarget>, TriggerRepoError> {
let row: Option<EmailInboundRow> = sqlx::query_as(
// §4.5 M5: `t.app_id IS NOT NULL` excludes a group email TEMPLATE
// row — only its materialized per-app copies are directly invocable
// (mirrors the cron-scheduler / queue-consumer guards). A template
// has no app to run under, so hitting its webhook URL must 404.
"SELECT t.app_id, t.script_id, t.enabled, t.dispatch_mode, \
t.registered_by_principal, \
d.inbound_secret_encrypted, d.inbound_secret_nonce \
FROM triggers t \
JOIN email_trigger_details d ON d.trigger_id = t.id \
WHERE t.id = $1 AND t.kind = 'email'",
WHERE t.id = $1 AND t.kind = 'email' AND t.app_id IS NOT NULL",
)
.bind(trigger_id.into_inner())
.fetch_optional(&self.pool)
@@ -1194,8 +1372,8 @@ impl TriggerRepo for PostgresTriggerRepo {
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
let parents: Vec<TriggerRow> = sqlx::query_as(
"SELECT id, app_id, script_id, name, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
"SELECT id, app_id, script_id, name, kind, enabled, sealed, shared, materialized_from, \
dispatch_mode, retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at \
FROM triggers WHERE app_id = $1 ORDER BY created_at DESC",
)
@@ -1220,9 +1398,35 @@ impl TriggerRepo for PostgresTriggerRepo {
Ok(out)
}
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<Trigger>, TriggerRepoError> {
let parents: Vec<TriggerRow> = sqlx::query_as(
"SELECT id, app_id, group_id, script_id, name, kind, enabled, sealed, shared, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at \
FROM triggers WHERE group_id = $1 ORDER BY created_at DESC",
)
.bind(group_id.into_inner())
.fetch_all(&self.pool)
.await?;
let pool = self.pool.clone();
let out = stream::iter(parents.into_iter().map(Ok))
.map_ok(|p| {
let pool = pool.clone();
async move { hydrate_one(&pool, p).await }
})
.try_buffered(8)
.try_collect::<Vec<_>>()
.await?;
Ok(out)
}
async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError> {
// §11 tail: `get` can fetch ANY trigger by id, including a group-owned
// TEMPLATE, so it must SELECT `group_id` (unlike `list_for_app`, whose
// rows are all app-owned) — else a template would hydrate with both
// owners None, breaking the exactly-one invariant in memory.
let parent: Option<TriggerRow> = sqlx::query_as(
"SELECT id, app_id, script_id, name, kind, enabled, dispatch_mode, \
"SELECT id, app_id, group_id, script_id, name, kind, enabled, sealed, shared, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at \
FROM triggers WHERE id = $1",
@@ -1255,15 +1459,18 @@ impl TriggerRepo for PostgresTriggerRepo {
// happens in Rust so we don't have to teach the query about
// `*` and `prefix:*`. Sets are tiny in practice (one app's
// worth of triggers, usually a handful).
let rows: Vec<KvMatchRow> = sqlx::query_as(
"SELECT t.id, t.script_id, t.dispatch_mode, \
let rows: Vec<KvMatchRow> = sqlx::query_as(&format!(
"{CHAIN_LEVELS_CTE} \
SELECT t.id, t.script_id, t.dispatch_mode, \
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
t.registered_by_principal, \
d.collection_glob, d.ops \
FROM triggers t \
JOIN kv_trigger_details d ON d.trigger_id = t.id \
WHERE t.app_id = $1 AND t.kind = 'kv' AND t.enabled = TRUE",
)
JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner) \
WHERE t.kind = 'kv' AND t.enabled = TRUE \
AND t.shared = FALSE{TRIGGER_SUPPRESSION_ANTIJOIN}"
))
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
@@ -1303,15 +1510,18 @@ impl TriggerRepo for PostgresTriggerRepo {
// ops check into SQL (`WHERE $op = ANY(ops)`) — that would
// exclude rows with `ops = '{}'` from the results, breaking
// the empty-array-means-any-op semantic.
let rows: Vec<KvMatchRow> = sqlx::query_as(
"SELECT t.id, t.script_id, t.dispatch_mode, \
let rows: Vec<KvMatchRow> = sqlx::query_as(&format!(
"{CHAIN_LEVELS_CTE} \
SELECT t.id, t.script_id, t.dispatch_mode, \
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
t.registered_by_principal, \
d.collection_glob, d.ops \
FROM triggers t \
JOIN docs_trigger_details d ON d.trigger_id = t.id \
WHERE t.app_id = $1 AND t.kind = 'docs' AND t.enabled = TRUE",
)
JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner) \
WHERE t.kind = 'docs' AND t.enabled = TRUE \
AND t.shared = FALSE{TRIGGER_SUPPRESSION_ANTIJOIN}"
))
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
@@ -1348,15 +1558,18 @@ impl TriggerRepo for PostgresTriggerRepo {
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError> {
// Mirrors list_matching_kv: pull every enabled files trigger,
// filter glob + ops in Rust (empty ops array means "any op").
let rows: Vec<KvMatchRow> = sqlx::query_as(
"SELECT t.id, t.script_id, t.dispatch_mode, \
let rows: Vec<KvMatchRow> = sqlx::query_as(&format!(
"{CHAIN_LEVELS_CTE} \
SELECT t.id, t.script_id, t.dispatch_mode, \
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
t.registered_by_principal, \
d.collection_glob, d.ops \
FROM triggers t \
JOIN files_trigger_details d ON d.trigger_id = t.id \
WHERE t.app_id = $1 AND t.kind = 'files' AND t.enabled = TRUE",
)
JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner) \
WHERE t.kind = 'files' AND t.enabled = TRUE \
AND t.shared = FALSE{TRIGGER_SUPPRESSION_ANTIJOIN}"
))
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
@@ -1385,6 +1598,60 @@ impl TriggerRepo for PostgresTriggerRepo {
Ok(out)
}
async fn list_matching_shared_kv(
&self,
owning_group: GroupId,
collection: &str,
op: KvEventOp,
) -> Result<Vec<KvTriggerMatch>, TriggerRepoError> {
let rows = self
.shared_match_rows(
"kv",
"kv_trigger_details",
owning_group,
collection,
op.as_str(),
)
.await?;
Ok(rows.into_iter().map(KvMatchRow::into_kv).collect())
}
async fn list_matching_shared_docs(
&self,
owning_group: GroupId,
collection: &str,
op: DocsEventOp,
) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError> {
let rows = self
.shared_match_rows(
"docs",
"docs_trigger_details",
owning_group,
collection,
op.as_str(),
)
.await?;
Ok(rows.into_iter().map(KvMatchRow::into_docs).collect())
}
async fn list_matching_shared_files(
&self,
owning_group: GroupId,
collection: &str,
op: FilesEventOp,
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError> {
let rows = self
.shared_match_rows(
"files",
"files_trigger_details",
owning_group,
collection,
op.as_str(),
)
.await?;
Ok(rows.into_iter().map(KvMatchRow::into_files).collect())
}
async fn list_matching_dead_letter(
&self,
app_id: AppId,
@@ -1501,11 +1768,15 @@ impl TriggerRepo for PostgresTriggerRepo {
Ok(Trigger {
id: parent.id.into(),
app_id: parent.app_id.into(),
app_id: parent.app_id.map(Into::into),
group_id: parent.group_id.map(Into::into),
script_id: parent.script_id.into(),
name: parent.name.clone(),
kind: TriggerKind::Queue,
enabled: parent.enabled,
sealed: parent.sealed,
shared: parent.shared,
materialized: parent.materialized_from.is_some(),
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
@@ -1526,14 +1797,21 @@ impl TriggerRepo for PostgresTriggerRepo {
&self,
) -> Result<Vec<ActiveQueueConsumer>, TriggerRepoError> {
let rows: Vec<QueueConsumerRow> = sqlx::query_as(
// §11.6 D3: LEFT JOIN the SOURCE template of a materialized copy; if
// that template is a SHARED group queue, `shared_group` is its
// owning group and this consumer drains the group store instead of
// the per-app one.
"SELECT t.id AS trigger_id, t.app_id, t.script_id, d.queue_name, \
d.visibility_timeout_secs, \
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
t.registered_by_principal \
t.registered_by_principal, tmpl.group_id AS shared_group \
FROM triggers t \
JOIN queue_trigger_details d ON d.trigger_id = t.id \
JOIN scripts s ON s.id = t.script_id \
WHERE t.kind = 'queue' AND t.enabled = TRUE AND s.enabled = TRUE",
LEFT JOIN triggers tmpl \
ON tmpl.id = t.materialized_from AND tmpl.shared = TRUE \
WHERE t.kind = 'queue' AND t.enabled = TRUE AND s.enabled = TRUE \
AND t.app_id IS NOT NULL",
)
.fetch_all(&self.pool)
.await?;
@@ -1550,6 +1828,7 @@ impl TriggerRepo for PostgresTriggerRepo {
.unwrap_or(BackoffShape::Exponential),
retry_base_ms: u32::try_from(r.retry_base_ms).unwrap_or(1000),
registered_by_principal: r.registered_by_principal.into(),
shared_group: r.shared_group.map(Into::into),
})
.collect())
}
@@ -1709,11 +1988,15 @@ async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, Trigg
Ok(Trigger {
id: parent.id.into(),
app_id: parent.app_id.into(),
app_id: parent.app_id.map(Into::into),
group_id: parent.group_id.map(Into::into),
script_id: parent.script_id.into(),
name: parent.name,
kind,
enabled: parent.enabled,
sealed: parent.sealed,
shared: parent.shared,
materialized: parent.materialized_from.is_some(),
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
@@ -1752,11 +2035,29 @@ pub fn collection_matches(glob: &str, collection: &str) -> bool {
#[derive(sqlx::FromRow)]
struct TriggerRow {
id: Uuid,
app_id: Uuid,
app_id: Option<Uuid>,
// `default` so the interactive-create RETURNING clauses (which don't list
// group_id — they only ever build app-owned rows) still hydrate; the
// group/app list queries SELECT it explicitly.
#[sqlx(default)]
group_id: Option<Uuid>,
script_id: Uuid,
name: String,
kind: String,
enabled: bool,
// `default` (§11 tail) so the interactive-create RETURNING clauses (which
// build app-owned rows, never sealed) still hydrate; the group/app list
// queries that must see a true value SELECT it explicitly.
#[sqlx(default)]
sealed: bool,
// §11.6: same `default` treatment as `sealed`.
#[sqlx(default)]
shared: bool,
// §4.5 M5: set on a materialized copy of a group stateful template; NULL on
// a hand-authored trigger. `default` like the flags above — only the app/
// group list queries SELECT it.
#[sqlx(default)]
materialized_from: Option<Uuid>,
dispatch_mode: String,
retry_max_attempts: i32,
retry_backoff: String,
@@ -1807,6 +2108,9 @@ struct QueueConsumerRow {
retry_backoff: String,
retry_base_ms: i32,
registered_by_principal: Uuid,
// §11.6 D3: the owning group of a SHARED queue template this consumer was
// materialized from; NULL for a per-app / non-shared consumer.
shared_group: Option<Uuid>,
}
#[derive(sqlx::FromRow)]
@@ -1841,6 +2145,47 @@ struct KvMatchRow {
ops: Vec<String>,
}
impl KvMatchRow {
fn into_kv(self) -> KvTriggerMatch {
KvTriggerMatch {
trigger_id: self.id.into(),
script_id: self.script_id.into(),
dispatch_mode: dispatch_from_str(&self.dispatch_mode),
retry_max_attempts: u32::try_from(self.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&self.retry_backoff)
.unwrap_or(BackoffShape::Exponential),
retry_base_ms: u32::try_from(self.retry_base_ms).unwrap_or(1000),
registered_by_principal: self.registered_by_principal.into(),
}
}
fn into_docs(self) -> DocsTriggerMatch {
let m = self.into_kv();
DocsTriggerMatch {
trigger_id: m.trigger_id,
script_id: m.script_id,
dispatch_mode: m.dispatch_mode,
retry_max_attempts: m.retry_max_attempts,
retry_backoff: m.retry_backoff,
retry_base_ms: m.retry_base_ms,
registered_by_principal: m.registered_by_principal,
}
}
fn into_files(self) -> FilesTriggerMatch {
let m = self.into_kv();
FilesTriggerMatch {
trigger_id: m.trigger_id,
script_id: m.script_id,
dispatch_mode: m.dispatch_mode,
retry_max_attempts: m.retry_max_attempts,
retry_backoff: m.retry_backoff,
retry_base_ms: m.retry_base_ms,
registered_by_principal: m.registered_by_principal,
}
}
}
#[derive(sqlx::FromRow)]
struct DlMatchRow {
id: Uuid,

View File

@@ -289,7 +289,17 @@ async fn validate_trigger_target(
.ok_or_else(|| {
TriggersApiError::Invalid(format!("script {script_id} not found in this app"))
})?;
if script.app_id != app_id {
// Phase 4: a trigger target must be invocable in this app's context —
// owned by the app, or by a group on the app's ancestor chain (inherited).
// App-owned is the in-memory fast path (no query); only a group-owned
// candidate pays the chain lookup.
let invocable = script.is_owned_by_app(app_id)
|| (script.group_id.is_some()
&& scripts
.is_invocable_by_app(script_id, app_id)
.await
.map_err(map_script_repo_err)?);
if !invocable {
return Err(TriggersApiError::Invalid(format!(
"script {script_id} does not belong to this app"
)));
@@ -688,7 +698,9 @@ async fn delete_trigger(
.get(trigger_id)
.await?
.ok_or(TriggersApiError::NotFound(trigger_id))?;
if trigger.app_id != app_id {
// Interactive trigger management is app-scoped; a group TEMPLATE
// (app_id NULL) is never addressable here, so a mismatch (incl. None) 404s.
if trigger.app_id != Some(app_id) {
return Err(TriggersApiError::NotFound(trigger_id));
}
if !s.triggers.delete(trigger_id).await? {
@@ -890,11 +902,15 @@ mod tests {
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
app_id: Some(app_id),
group_id: None,
script_id: req.script_id,
name: "mock".into(),
kind: crate::trigger_repo::TriggerKind::Kv,
enabled: true,
sealed: false,
shared: false,
materialized: false,
dispatch_mode: req.dispatch_mode,
retry_max_attempts: req.retry_max_attempts,
retry_backoff: req.retry_backoff,
@@ -919,11 +935,15 @@ mod tests {
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
app_id: Some(app_id),
group_id: None,
script_id: req.script_id,
name: "mock".into(),
kind: crate::trigger_repo::TriggerKind::Docs,
enabled: true,
sealed: false,
shared: false,
materialized: false,
dispatch_mode: req.dispatch_mode,
retry_max_attempts: req.retry_max_attempts,
retry_backoff: req.retry_backoff,
@@ -948,11 +968,15 @@ mod tests {
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
app_id: Some(app_id),
group_id: None,
script_id: req.script_id,
name: "mock".into(),
kind: crate::trigger_repo::TriggerKind::DeadLetter,
enabled: true,
sealed: false,
shared: false,
materialized: false,
dispatch_mode: TriggerDispatchMode::Async,
retry_max_attempts: 1,
retry_backoff: BackoffShape::Constant,
@@ -978,11 +1002,15 @@ mod tests {
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
app_id: Some(app_id),
group_id: None,
script_id: req.script_id,
name: "mock".into(),
kind: TriggerKind::Email,
enabled: true,
sealed: false,
shared: false,
materialized: false,
dispatch_mode: TriggerDispatchMode::Async,
retry_max_attempts: 3,
retry_backoff: BackoffShape::Exponential,
@@ -1003,9 +1031,12 @@ mod tests {
) -> Result<Option<EmailInboundTarget>, TriggerRepoError> {
let g = self.inner.lock().await;
Ok(g.get(&trigger_id)
.filter(|t| t.kind == TriggerKind::Email)
// Mirror the production guard (`email_inbound_target` filters
// `AND t.app_id IS NOT NULL`): a group-owned email TEMPLATE is
// never a valid inbound target.
.filter(|t| t.kind == TriggerKind::Email && t.app_id.is_some())
.map(|t| EmailInboundTarget {
app_id: t.app_id,
app_id: t.app_id.expect("filtered to app-owned above"),
script_id: t.script_id,
enabled: t.enabled,
dispatch_mode: t.dispatch_mode,
@@ -1023,11 +1054,15 @@ mod tests {
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
app_id: Some(app_id),
group_id: None,
script_id: req.script_id,
name: "mock".into(),
kind: crate::trigger_repo::TriggerKind::Cron,
enabled: true,
sealed: false,
shared: false,
materialized: false,
dispatch_mode: req.dispatch_mode,
retry_max_attempts: req.retry_max_attempts,
retry_backoff: req.retry_backoff,
@@ -1053,11 +1088,15 @@ mod tests {
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
app_id: Some(app_id),
group_id: None,
script_id: req.script_id,
name: "mock".into(),
kind: crate::trigger_repo::TriggerKind::Files,
enabled: true,
sealed: false,
shared: false,
materialized: false,
dispatch_mode: req.dispatch_mode,
retry_max_attempts: req.retry_max_attempts,
retry_backoff: req.retry_backoff,
@@ -1082,11 +1121,15 @@ mod tests {
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
app_id: Some(app_id),
group_id: None,
script_id: req.script_id,
name: "mock".into(),
kind: crate::trigger_repo::TriggerKind::Pubsub,
enabled: true,
sealed: false,
shared: false,
materialized: false,
dispatch_mode: req.dispatch_mode,
retry_max_attempts: req.retry_max_attempts,
retry_backoff: req.retry_backoff,
@@ -1107,7 +1150,7 @@ mod tests {
.lock()
.await
.values()
.filter(|t| t.app_id == app_id)
.filter(|t| t.app_id == Some(app_id))
.cloned()
.collect())
}
@@ -1159,11 +1202,15 @@ mod tests {
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
app_id: Some(app_id),
group_id: None,
script_id: req.script_id,
name: "mock".into(),
kind: TriggerKind::Queue,
enabled: true,
sealed: false,
shared: false,
materialized: false,
dispatch_mode: req.dispatch_mode,
retry_max_attempts: req.retry_max_attempts,
retry_backoff: req.retry_backoff,
@@ -1348,7 +1395,8 @@ mod tests {
script_id,
picloud_shared::Script {
id: script_id,
app_id,
app_id: Some(app_id),
group_id: None,
name: format!(
"{}_{}",
match kind {
@@ -1393,9 +1441,29 @@ mod tests {
.lock()
.await
.values()
.find(|s| s.app_id == app_id && s.name == name)
.find(|s| s.app_id == Some(app_id) && s.name == name)
.cloned())
}
async fn get_by_name_inherited(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
// No hierarchy in this in-memory repo; inherited == own-scope.
self.get_by_name(app_id, name).await
}
async fn is_invocable_by_app(
&self,
script_id: ScriptId,
app_id: AppId,
) -> Result<bool, crate::repo::ScriptRepositoryError> {
Ok(self
.existing
.lock()
.await
.get(&script_id)
.is_some_and(|s| s.is_owned_by_app(app_id)))
}
async fn list(
&self,
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
@@ -1407,6 +1475,12 @@ mod tests {
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
unimplemented!()
}
async fn list_for_group(
&self,
_group_id: picloud_shared::GroupId,
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
unimplemented!()
}
async fn list_for_user(
&self,
_user_id: AdminUserId,

View File

@@ -0,0 +1,410 @@
//! `/api/v1/admin/{apps,groups}/{id_or_slug}/vars*` — the Phase-3 config
//! `vars` admin surface (write/list side; resolution lives in
//! `config_resolver` + the `vars::` SDK).
//!
//! * `GET /apps/{id}/vars` — list the app's OWN vars.
//! * `PUT /apps/{id}/vars` — set/overwrite one app var.
//! * `DELETE /apps/{id}/vars/{key}` — delete one app var.
//! * `GET/PUT/DELETE /groups/{id}/vars[...]` — same, group-owned.
//!
//! App routes gate on `App{Vars}Read/Write`; group routes on
//! `Group{Vars}Read/Write`. The owner is resolved FIRST (slug-or-uuid),
//! THEN `authz::require` binds the capability to the resolved owner id —
//! never to a caller-controlled path param. Listing returns the owner's
//! OWN rows only (not the resolved/inherited view).
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, put};
use axum::{Extension, Router};
use picloud_shared::{AppId, GroupId, Principal};
use serde::Deserialize;
use serde_json::json;
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::group_repo::GroupRepository;
use crate::vars_repo::{VarOwner, VarsRepo, VarsRepoError};
#[derive(Clone)]
pub struct VarsApiState {
pub vars: Arc<dyn VarsRepo>,
pub apps: Arc<dyn AppRepository>,
pub groups: Arc<dyn GroupRepository>,
pub authz: Arc<dyn AuthzRepo>,
}
pub fn vars_router(state: VarsApiState) -> Router {
Router::new()
.route(
"/apps/{id_or_slug}/vars",
get(list_app_vars).put(set_app_var),
)
.route(
"/apps/{id_or_slug}/vars/{key}",
axum::routing::delete(delete_app_var),
)
.route(
"/groups/{id_or_slug}/vars",
put(set_group_var).get(list_group_vars),
)
.route(
"/groups/{id_or_slug}/vars/{key}",
axum::routing::delete(delete_group_var),
)
.with_state(state)
}
// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct SetVarRequest {
pub key: String,
pub value: serde_json::Value,
/// Environment scope — `*` (env-agnostic, default) or a concrete env
/// name matched against `apps.environment` at resolution time.
#[serde(default)]
pub env: Option<String>,
/// Write a tombstone (suppresses an inherited key) instead of a real
/// value. The body's `value` is ignored for a tombstone.
#[serde(default)]
pub tombstone: bool,
}
#[derive(Debug, Deserialize)]
pub struct EnvQuery {
#[serde(default)]
pub env: Option<String>,
}
#[derive(Debug, serde::Serialize)]
struct VarItem {
key: String,
env: String,
value: serde_json::Value,
is_tombstone: bool,
updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, serde::Serialize)]
struct ListVarsResponse {
vars: Vec<VarItem>,
}
// ----------------------------------------------------------------------------
// App handlers
// ----------------------------------------------------------------------------
async fn list_app_vars(
State(s): State<VarsApiState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<ListVarsResponse>, VarsApiError> {
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppVarsRead(app_id),
)
.await?;
list(&*s.vars, VarOwner::App(app_id)).await
}
async fn set_app_var(
State(s): State<VarsApiState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<SetVarRequest>,
) -> Result<StatusCode, VarsApiError> {
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppVarsWrite(app_id),
)
.await?;
set(&*s.vars, VarOwner::App(app_id), input).await
}
async fn delete_app_var(
State(s): State<VarsApiState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, key)): Path<(String, String)>,
Query(q): Query<EnvQuery>,
) -> Result<StatusCode, VarsApiError> {
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppVarsWrite(app_id),
)
.await?;
delete(&*s.vars, VarOwner::App(app_id), &key, q.env.as_deref()).await
}
// ----------------------------------------------------------------------------
// Group handlers
// ----------------------------------------------------------------------------
async fn list_group_vars(
State(s): State<VarsApiState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<ListVarsResponse>, VarsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupVarsRead(group_id),
)
.await?;
list(&*s.vars, VarOwner::Group(group_id)).await
}
async fn set_group_var(
State(s): State<VarsApiState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<SetVarRequest>,
) -> Result<StatusCode, VarsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupVarsWrite(group_id),
)
.await?;
set(&*s.vars, VarOwner::Group(group_id), input).await
}
async fn delete_group_var(
State(s): State<VarsApiState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, key)): Path<(String, String)>,
Query(q): Query<EnvQuery>,
) -> Result<StatusCode, VarsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupVarsWrite(group_id),
)
.await?;
delete(&*s.vars, VarOwner::Group(group_id), &key, q.env.as_deref()).await
}
// ----------------------------------------------------------------------------
// Shared owner-generic bodies
// ----------------------------------------------------------------------------
async fn list(
vars: &dyn VarsRepo,
owner: VarOwner,
) -> Result<Json<ListVarsResponse>, VarsApiError> {
let rows = vars.list_for_owner(owner).await?;
Ok(Json(ListVarsResponse {
vars: rows
.into_iter()
.map(|r| VarItem {
key: r.key,
env: r.environment_scope,
value: r.value,
is_tombstone: r.is_tombstone,
updated_at: r.updated_at,
})
.collect(),
}))
}
async fn set(
vars: &dyn VarsRepo,
owner: VarOwner,
input: SetVarRequest,
) -> Result<StatusCode, VarsApiError> {
validate_key(&input.key)?;
let env = input.env.as_deref().unwrap_or("*");
validate_env_scope(env)?;
// A tombstone carries no meaningful value (the resolver suppresses the
// key regardless); store JSON null so the NOT NULL column is satisfied.
let value = if input.tombstone {
serde_json::Value::Null
} else {
input.value
};
vars.set(owner, env, &input.key, &value, input.tombstone)
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn delete(
vars: &dyn VarsRepo,
owner: VarOwner,
key: &str,
env: Option<&str>,
) -> Result<StatusCode, VarsApiError> {
let env = env.unwrap_or("*");
validate_env_scope(env)?;
if !vars.delete(owner, env, key).await? {
return Err(VarsApiError::NotFound);
}
Ok(StatusCode::NO_CONTENT)
}
// ----------------------------------------------------------------------------
// Resolution + validation
// ----------------------------------------------------------------------------
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, VarsApiError> {
crate::app_repo::resolve_app(apps, ident)
.await
.map_err(|e| VarsApiError::Backend(e.to_string()))?
.map(|l| l.app.id)
.ok_or(VarsApiError::AppNotFound)
}
async fn resolve_group(groups: &dyn GroupRepository, ident: &str) -> Result<GroupId, VarsApiError> {
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
groups
.get_by_id(uuid.into())
.await
.map_err(|e| VarsApiError::Backend(e.to_string()))?
} else {
groups
.get_by_slug(ident)
.await
.map_err(|e| VarsApiError::Backend(e.to_string()))?
};
found.map(|g| g.id).ok_or(VarsApiError::GroupNotFound)
}
/// Keys are kebab identifiers (`^[a-z0-9][a-z0-9-]*$`) — same shape as the
/// manifest's var names (docs/design §4.3).
fn validate_key(key: &str) -> Result<(), VarsApiError> {
if key.is_empty() || key.len() > 128 {
return Err(VarsApiError::Invalid("key must be 1128 characters".into()));
}
let mut chars = key.chars();
let first = chars.next().unwrap();
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Err(VarsApiError::Invalid(
"key must start with a lowercase letter or digit".into(),
));
}
if !key
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(VarsApiError::Invalid(
"key may contain only lowercase letters, digits, and hyphens".into(),
));
}
Ok(())
}
/// Env scope is `*` (env-agnostic) or a kebab env name.
fn validate_env_scope(env: &str) -> Result<(), VarsApiError> {
if env == "*" {
return Ok(());
}
if env.is_empty() || env.len() > 63 {
return Err(VarsApiError::Invalid(
"env must be '*' or 163 characters".into(),
));
}
let mut chars = env.chars();
let first = chars.next().unwrap();
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Err(VarsApiError::Invalid(
"env must start with a lowercase letter or digit".into(),
));
}
if !env
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(VarsApiError::Invalid(
"env may contain only lowercase letters, digits, and hyphens".into(),
));
}
Ok(())
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum VarsApiError {
#[error("app not found")]
AppNotFound,
#[error("group not found")]
GroupNotFound,
#[error("var not found")]
NotFound,
#[error("invalid request: {0}")]
Invalid(String),
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("vars backend: {0}")]
Backend(String),
}
impl From<AuthzDenied> for VarsApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl From<AuthzError> for VarsApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl From<VarsRepoError> for VarsApiError {
fn from(e: VarsRepoError) -> Self {
match e {
VarsRepoError::Db(e) => Self::Backend(e.to_string()),
}
}
}
impl IntoResponse for VarsApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound | Self::GroupNotFound | Self::NotFound => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::Invalid(_) => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "vars admin authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Backend(e) => {
tracing::error!(error = %e, "vars admin backend error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

@@ -0,0 +1,210 @@
//! Low-level Postgres CRUD over `vars` — the write/admin side of the
//! Phase-3 config layer (the read/resolution side lives in
//! `config_resolver`). Storage-only: it upserts and lists an owner's OWN
//! rows. Authorization, env-scope validation, and value encoding live one
//! layer up in `vars_api`.
//!
//! A var is owned by exactly one group OR one app (the migration's
//! `vars_owner_exactly_one` CHECK). Because the owner is split across two
//! nullable columns (`group_id`, `app_id`), the upsert writes
//! owner-kind-specific SQL with the matching partial-unique conflict
//! target.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AppId, GroupId};
use serde_json::Value as JsonValue;
use sqlx::PgPool;
#[derive(Debug, thiserror::Error)]
pub enum VarsRepoError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
}
/// Which side of the polymorphic owner a var hangs off. The repo chooses
/// the conflict target (group vs app partial-unique index) from this.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VarOwner {
Group(GroupId),
App(AppId),
}
/// One of an owner's OWN var rows (NOT a resolved/inherited value). Backs
/// the admin list surface.
#[derive(Debug, Clone)]
pub struct VarRow {
pub environment_scope: String,
pub key: String,
pub value: JsonValue,
pub is_tombstone: bool,
pub updated_at: DateTime<Utc>,
}
/// Repo surface. A trait so service/handler tests can substitute an
/// in-memory backing without Postgres.
#[async_trait]
pub trait VarsRepo: Send + Sync {
/// Upsert one (owner, env_scope, key) row.
async fn set(
&self,
owner: VarOwner,
env_scope: &str,
key: &str,
value: &JsonValue,
is_tombstone: bool,
) -> Result<(), VarsRepoError>;
/// Delete one (owner, env_scope, key) row; returns whether a row was
/// present.
async fn delete(
&self,
owner: VarOwner,
env_scope: &str,
key: &str,
) -> Result<bool, VarsRepoError>;
/// The owner's OWN rows only (NOT resolved/inherited), ordered by
/// (key, environment_scope).
async fn list_for_owner(&self, owner: VarOwner) -> Result<Vec<VarRow>, VarsRepoError>;
}
pub struct PostgresVarsRepo {
pool: PgPool,
}
impl PostgresVarsRepo {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl VarsRepo for PostgresVarsRepo {
async fn set(
&self,
owner: VarOwner,
env_scope: &str,
key: &str,
value: &JsonValue,
is_tombstone: bool,
) -> Result<(), VarsRepoError> {
// Owner-kind-specific SQL: only one of the two nullable owner
// columns is written, and the conflict target is the matching
// partial-unique index.
match owner {
VarOwner::Group(g) => {
sqlx::query(
// The conflict target is a PARTIAL unique index, so the
// index predicate (`WHERE group_id IS NOT NULL`) must be
// restated for Postgres to infer the arbiter.
"INSERT INTO vars (group_id, environment_scope, key, value, is_tombstone) \
VALUES ($1, $2, $3, $4, $5) \
ON CONFLICT (group_id, environment_scope, key) \
WHERE group_id IS NOT NULL DO UPDATE \
SET value = EXCLUDED.value, \
is_tombstone = EXCLUDED.is_tombstone, \
updated_at = NOW()",
)
.bind(g.into_inner())
.bind(env_scope)
.bind(key)
.bind(value)
.bind(is_tombstone)
.execute(&self.pool)
.await?;
}
VarOwner::App(a) => {
sqlx::query(
// Partial-index conflict target — restate the predicate.
"INSERT INTO vars (app_id, environment_scope, key, value, is_tombstone) \
VALUES ($1, $2, $3, $4, $5) \
ON CONFLICT (app_id, environment_scope, key) \
WHERE app_id IS NOT NULL DO UPDATE \
SET value = EXCLUDED.value, \
is_tombstone = EXCLUDED.is_tombstone, \
updated_at = NOW()",
)
.bind(a.into_inner())
.bind(env_scope)
.bind(key)
.bind(value)
.bind(is_tombstone)
.execute(&self.pool)
.await?;
}
}
Ok(())
}
async fn delete(
&self,
owner: VarOwner,
env_scope: &str,
key: &str,
) -> Result<bool, VarsRepoError> {
let res = match owner {
VarOwner::Group(g) => {
sqlx::query(
"DELETE FROM vars \
WHERE group_id = $1 AND environment_scope = $2 AND key = $3",
)
.bind(g.into_inner())
.bind(env_scope)
.bind(key)
.execute(&self.pool)
.await?
}
VarOwner::App(a) => {
sqlx::query(
"DELETE FROM vars \
WHERE app_id = $1 AND environment_scope = $2 AND key = $3",
)
.bind(a.into_inner())
.bind(env_scope)
.bind(key)
.execute(&self.pool)
.await?
}
};
Ok(res.rows_affected() > 0)
}
async fn list_for_owner(&self, owner: VarOwner) -> Result<Vec<VarRow>, VarsRepoError> {
let rows: Vec<(String, String, JsonValue, bool, DateTime<Utc>)> = match owner {
VarOwner::Group(g) => {
sqlx::query_as(
"SELECT environment_scope, key, value, is_tombstone, updated_at \
FROM vars WHERE group_id = $1 \
ORDER BY key ASC, environment_scope ASC",
)
.bind(g.into_inner())
.fetch_all(&self.pool)
.await?
}
VarOwner::App(a) => {
sqlx::query_as(
"SELECT environment_scope, key, value, is_tombstone, updated_at \
FROM vars WHERE app_id = $1 \
ORDER BY key ASC, environment_scope ASC",
)
.bind(a.into_inner())
.fetch_all(&self.pool)
.await?
}
};
Ok(rows
.into_iter()
.map(
|(environment_scope, key, value, is_tombstone, updated_at)| VarRow {
environment_scope,
key,
value,
is_tombstone,
updated_at,
},
)
.collect())
}
}

View File

@@ -0,0 +1,63 @@
//! `VarsService` — the runtime read path for group-inherited config.
//!
//! Resolves the calling app's config (own rows + inherited group rows,
//! env-filtered, proximity-first; see `config_resolver`) and exposes it to
//! scripts as `vars::get(key)` / `vars::all()`. Read-only from scripts;
//! writes go through the admin API. Like every SDK service, it derives the
//! app from `cx.app_id` — never a script argument — so cross-app isolation
//! holds.
use std::collections::BTreeMap;
use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{SdkCallCx, VarsError, VarsService};
use serde_json::Value;
use sqlx::PgPool;
use crate::authz::{self, AuthzRepo, Capability};
use crate::config_resolver::{fetch_var_candidates, resolve};
pub struct VarsServiceImpl {
pool: PgPool,
authz: Arc<dyn AuthzRepo>,
}
impl VarsServiceImpl {
#[must_use]
pub fn new(pool: PgPool, authz: Arc<dyn AuthzRepo>) -> Self {
Self { pool, authz }
}
/// Authed principals need `AppVarsRead`; anonymous public-HTTP scripts
/// (`principal: None`) read freely under script-as-gate semantics.
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), VarsError> {
if let Some(ref principal) = cx.principal {
authz::require(&*self.authz, principal, Capability::AppVarsRead(cx.app_id))
.await
.map_err(|_| VarsError::Forbidden)?;
}
Ok(())
}
async fn resolved(&self, cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError> {
let candidates = fetch_var_candidates(&self.pool, cx.app_id)
.await
.map_err(|e| VarsError::Backend(e.to_string()))?;
let (values, _provenance) = resolve(candidates);
Ok(values)
}
}
#[async_trait]
impl VarsService for VarsServiceImpl {
async fn get(&self, cx: &SdkCallCx, key: &str) -> Result<Option<Value>, VarsError> {
self.check_read(cx).await?;
Ok(self.resolved(cx).await?.remove(key))
}
async fn all(&self, cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError> {
self.check_read(cx).await?;
self.resolved(cx).await
}
}

View File

@@ -132,6 +132,8 @@ table: apps
description: text NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
group_id: uuid NOT NULL
environment: text NULL
table: cron_trigger_details
trigger_id: uuid NOT NULL
@@ -196,6 +198,14 @@ table: execution_logs
app_id: uuid NOT NULL
source: text NOT NULL default='http'::text
table: extension_points
id: uuid NOT NULL default=gen_random_uuid()
group_id: uuid NULL
app_id: uuid NULL
name: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: files
app_id: uuid NOT NULL
collection: text NOT NULL
@@ -212,6 +222,72 @@ table: files_trigger_details
collection_glob: text NOT NULL
ops: ARRAY NOT NULL
table: group_collections
id: uuid NOT NULL default=gen_random_uuid()
group_id: uuid NULL
app_id: uuid NULL
name: text NOT NULL
kind: text NOT NULL default='kv'::text
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: group_docs
group_id: uuid NOT NULL
collection: text NOT NULL
id: uuid NOT NULL
data: jsonb NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: group_files
group_id: uuid NOT NULL
collection: text NOT NULL
id: uuid NOT NULL
name: text NOT NULL
content_type: text NOT NULL
size_bytes: bigint NOT NULL
checksum_sha256: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: group_kv_entries
group_id: uuid NOT NULL
collection: text NOT NULL
key: text NOT NULL
value: jsonb NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: group_members
group_id: uuid NOT NULL
user_id: uuid NOT NULL
role: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
table: group_queue_messages
id: uuid NOT NULL default=gen_random_uuid()
group_id: uuid NOT NULL
collection: text NOT NULL
payload: jsonb NOT NULL
enqueued_at: timestamp with time zone NOT NULL default=now()
deliver_after: timestamp with time zone NULL
claim_token: uuid NULL
claimed_at: timestamp with time zone NULL
attempt: integer NOT NULL default=0
max_attempts: integer NOT NULL default=3
enqueued_by_principal: uuid NULL
table: groups
id: uuid NOT NULL default=gen_random_uuid()
parent_id: uuid NULL
slug: text NOT NULL
name: text NOT NULL
description: text NULL
structure_version: bigint NOT NULL default=1
owner_project: uuid NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: kv_entries
app_id: uuid NOT NULL
collection: text NOT NULL
@@ -275,8 +351,11 @@ table: routes
path: text NOT NULL
method: text NULL
created_at: timestamp with time zone NOT NULL default=now()
app_id: uuid NOT NULL
app_id: uuid NULL
dispatch_mode: text NOT NULL default='sync'::text
enabled: boolean NOT NULL default=true
group_id: uuid NULL
sealed: boolean NOT NULL default=false
table: script_imports
app_id: uuid NOT NULL
@@ -295,17 +374,29 @@ table: scripts
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
sandbox: jsonb NOT NULL default='{}'::jsonb
app_id: uuid NOT NULL
app_id: uuid NULL
kind: text NOT NULL default='endpoint'::text
enabled: boolean NOT NULL default=true
group_id: uuid NULL
table: secrets
app_id: uuid NOT NULL
app_id: uuid NULL
name: text NOT NULL
encrypted_value: bytea NOT NULL
nonce: bytea NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
version: smallint NOT NULL default=0
group_id: uuid NULL
environment_scope: text NOT NULL default='*'::text
table: template_suppressions
id: uuid NOT NULL default=gen_random_uuid()
app_id: uuid NULL
target_kind: text NOT NULL
reference: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
group_id: uuid NULL
table: topics
app_id: uuid NOT NULL
@@ -317,7 +408,7 @@ table: topics
table: triggers
id: uuid NOT NULL default=gen_random_uuid()
app_id: uuid NOT NULL
app_id: uuid NULL
script_id: uuid NOT NULL
kind: text NOT NULL
enabled: boolean NOT NULL default=true
@@ -328,6 +419,22 @@ table: triggers
registered_by_principal: uuid NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
name: text NOT NULL default=(gen_random_uuid())::text
group_id: uuid NULL
sealed: boolean NOT NULL default=false
shared: boolean NOT NULL default=false
materialized_from: uuid NULL
table: vars
id: uuid NOT NULL default=gen_random_uuid()
group_id: uuid NULL
app_id: uuid NULL
environment_scope: text NOT NULL default='*'::text
key: text NOT NULL
value: jsonb NOT NULL
is_tombstone: boolean NOT NULL default=false
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
## indexes
@@ -395,6 +502,7 @@ indexes on app_users:
idx_app_users_app_email_lower: public.app_users USING btree (app_id, lower(email))
indexes on apps:
apps_group_id_idx: public.apps USING btree (group_id)
apps_pkey: public.apps USING btree (id)
apps_slug_key: public.apps USING btree (slug)
@@ -426,6 +534,13 @@ indexes on execution_logs:
execution_logs_pkey: public.execution_logs USING btree (id)
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
indexes on extension_points:
extension_points_app_id_idx: public.extension_points USING btree (app_id) WHERE (app_id IS NOT NULL)
extension_points_app_uidx: public.extension_points USING btree (app_id, lower(name)) WHERE (app_id IS NOT NULL)
extension_points_group_id_idx: public.extension_points USING btree (group_id) WHERE (group_id IS NOT NULL)
extension_points_group_uidx: public.extension_points USING btree (group_id, lower(name)) WHERE (group_id IS NOT NULL)
extension_points_pkey: public.extension_points USING btree (id)
indexes on files:
files_pkey: public.files USING btree (app_id, collection, id)
idx_files_app_collection: public.files USING btree (app_id, collection)
@@ -433,6 +548,41 @@ indexes on files:
indexes on files_trigger_details:
files_trigger_details_pkey: public.files_trigger_details USING btree (trigger_id)
indexes on group_collections:
group_collections_app_id_idx: public.group_collections USING btree (app_id) WHERE (app_id IS NOT NULL)
group_collections_app_uidx: public.group_collections USING btree (app_id, lower(name), kind) WHERE (app_id IS NOT NULL)
group_collections_group_id_idx: public.group_collections USING btree (group_id) WHERE (group_id IS NOT NULL)
group_collections_group_uidx: public.group_collections USING btree (group_id, lower(name), kind) WHERE (group_id IS NOT NULL)
group_collections_pkey: public.group_collections USING btree (id)
indexes on group_docs:
group_docs_pkey: public.group_docs USING btree (group_id, collection, id)
idx_group_docs_data_gin: public.group_docs USING gin (data jsonb_path_ops)
idx_group_docs_group_collection: public.group_docs USING btree (group_id, collection)
indexes on group_files:
group_files_pkey: public.group_files USING btree (group_id, collection, id)
idx_group_files_group_collection: public.group_files USING btree (group_id, collection)
indexes on group_kv_entries:
group_kv_entries_pkey: public.group_kv_entries USING btree (group_id, collection, key)
idx_group_kv_entries_group_collection: public.group_kv_entries USING btree (group_id, collection)
indexes on group_members:
group_members_pkey: public.group_members USING btree (group_id, user_id)
group_members_user_id_idx: public.group_members USING btree (user_id)
indexes on group_queue_messages:
group_queue_messages_pkey: public.group_queue_messages USING btree (id)
idx_group_queue_messages_claimed: public.group_queue_messages USING btree (claimed_at) WHERE (claim_token IS NOT NULL)
idx_group_queue_messages_dispatch: public.group_queue_messages USING btree (group_id, collection, enqueued_at) WHERE (claim_token IS NULL)
idx_group_queue_messages_group_collection: public.group_queue_messages USING btree (group_id, collection)
indexes on groups:
groups_parent_id_idx: public.groups USING btree (parent_id)
groups_pkey: public.groups USING btree (id)
groups_slug_key: public.groups USING btree (slug)
indexes on kv_entries:
idx_kv_entries_app_collection: public.kv_entries USING btree (app_id, collection)
kv_entries_pkey: public.kv_entries USING btree (app_id, collection, key)
@@ -460,10 +610,12 @@ indexes on queue_trigger_details:
indexes on routes:
routes_app_id_idx: public.routes USING btree (app_id)
routes_group_binding_idx: public.routes USING btree (group_id, host_kind, host, path_kind, path, COALESCE(method, ''::text)) WHERE (group_id IS NOT NULL)
routes_group_id_idx: public.routes USING btree (group_id) WHERE (group_id IS NOT NULL)
routes_lookup_idx: public.routes USING btree (host_kind, host)
routes_pkey: public.routes USING btree (id)
routes_script_id_idx: public.routes USING btree (script_id)
routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text))
routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text)) WHERE (app_id IS NOT NULL)
indexes on script_imports:
idx_script_imports_app: public.script_imports USING btree (app_id)
@@ -473,22 +625,45 @@ indexes on script_imports:
indexes on scripts:
idx_scripts_app_kind: public.scripts USING btree (app_id, kind)
scripts_app_id_idx: public.scripts USING btree (app_id)
scripts_name_uidx: public.scripts USING btree (app_id, lower(name))
scripts_app_name_uidx: public.scripts USING btree (app_id, lower(name)) WHERE (app_id IS NOT NULL)
scripts_group_id_idx: public.scripts USING btree (group_id) WHERE (group_id IS NOT NULL)
scripts_group_name_uidx: public.scripts USING btree (group_id, lower(name)) WHERE (group_id IS NOT NULL)
scripts_pkey: public.scripts USING btree (id)
indexes on secrets:
idx_secrets_app: public.secrets USING btree (app_id)
secrets_pkey: public.secrets USING btree (app_id, name)
idx_secrets_group: public.secrets USING btree (group_id) WHERE (group_id IS NOT NULL)
secrets_app_uidx: public.secrets USING btree (app_id, environment_scope, name) WHERE (app_id IS NOT NULL)
secrets_group_uidx: public.secrets USING btree (group_id, environment_scope, name) WHERE (group_id IS NOT NULL)
indexes on template_suppressions:
template_suppressions_app_idx: public.template_suppressions USING btree (app_id)
template_suppressions_app_uidx: public.template_suppressions USING btree (app_id, target_kind, reference) WHERE (app_id IS NOT NULL)
template_suppressions_group_idx: public.template_suppressions USING btree (group_id) WHERE (group_id IS NOT NULL)
template_suppressions_group_uidx: public.template_suppressions USING btree (group_id, target_kind, reference) WHERE (group_id IS NOT NULL)
template_suppressions_pkey: public.template_suppressions USING btree (id)
indexes on topics:
topics_pkey: public.topics USING btree (app_id, name)
indexes on triggers:
idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE (enabled = true)
idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (app_id IS NOT NULL))
idx_triggers_app_pubsub_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (kind = 'pubsub'::text))
idx_triggers_group_kind_enabled: public.triggers USING btree (group_id, kind) WHERE ((enabled = true) AND (group_id IS NOT NULL))
idx_triggers_kind_enabled: public.triggers USING btree (kind) WHERE (enabled = true)
idx_triggers_materialized_from: public.triggers USING btree (materialized_from) WHERE (materialized_from IS NOT NULL)
triggers_app_name_uniq: public.triggers USING btree (app_id, name) WHERE (app_id IS NOT NULL)
triggers_group_name_uniq: public.triggers USING btree (group_id, name) WHERE (group_id IS NOT NULL)
triggers_materialized_uidx: public.triggers USING btree (app_id, materialized_from) WHERE (materialized_from IS NOT NULL)
triggers_pkey: public.triggers USING btree (id)
indexes on vars:
vars_app_id_idx: public.vars USING btree (app_id) WHERE (app_id IS NOT NULL)
vars_app_uidx: public.vars USING btree (app_id, environment_scope, key) WHERE (app_id IS NOT NULL)
vars_group_id_idx: public.vars USING btree (group_id) WHERE (group_id IS NOT NULL)
vars_group_uidx: public.vars USING btree (group_id, environment_scope, key) WHERE (group_id IS NOT NULL)
vars_pkey: public.vars USING btree (id)
## constraints
constraints on abandoned_executions:
@@ -561,6 +736,7 @@ constraints on app_users:
[PRIMARY KEY] app_users_pkey: PRIMARY KEY (id)
constraints on apps:
[FOREIGN KEY] apps_group_id_fk: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT
[PRIMARY KEY] apps_pkey: PRIMARY KEY (id)
[UNIQUE] apps_slug_key: UNIQUE (slug)
@@ -597,6 +773,12 @@ constraints on execution_logs:
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE SET NULL
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
constraints on extension_points:
[CHECK] extension_points_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
[FOREIGN KEY] extension_points_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] extension_points_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] extension_points_pkey: PRIMARY KEY (id)
constraints on files:
[FOREIGN KEY] files_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] files_pkey: PRIMARY KEY (app_id, collection, id)
@@ -605,6 +787,40 @@ constraints on files_trigger_details:
[FOREIGN KEY] files_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
[PRIMARY KEY] files_trigger_details_pkey: PRIMARY KEY (trigger_id)
constraints on group_collections:
[CHECK] group_collections_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'docs'::text, 'files'::text, 'topic'::text, 'queue'::text])))
[CHECK] group_collections_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
[FOREIGN KEY] group_collections_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] group_collections_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] group_collections_pkey: PRIMARY KEY (id)
constraints on group_docs:
[FOREIGN KEY] group_docs_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] group_docs_pkey: PRIMARY KEY (group_id, collection, id)
constraints on group_files:
[FOREIGN KEY] group_files_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] group_files_pkey: PRIMARY KEY (group_id, collection, id)
constraints on group_kv_entries:
[FOREIGN KEY] group_kv_entries_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] group_kv_entries_pkey: PRIMARY KEY (group_id, collection, key)
constraints on group_members:
[CHECK] group_members_role_check: CHECK ((role = ANY (ARRAY['app_admin'::text, 'editor'::text, 'viewer'::text])))
[FOREIGN KEY] group_members_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[FOREIGN KEY] group_members_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
[PRIMARY KEY] group_members_pkey: PRIMARY KEY (group_id, user_id)
constraints on group_queue_messages:
[FOREIGN KEY] group_queue_messages_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] group_queue_messages_pkey: PRIMARY KEY (id)
constraints on groups:
[FOREIGN KEY] groups_parent_id_fkey: FOREIGN KEY (parent_id) REFERENCES groups(id) ON DELETE RESTRICT
[PRIMARY KEY] groups_pkey: PRIMARY KEY (id)
[UNIQUE] groups_slug_key: UNIQUE (slug)
constraints on kv_entries:
[FOREIGN KEY] kv_entries_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] kv_entries_pkey: PRIMARY KEY (app_id, collection, key)
@@ -633,8 +849,10 @@ constraints on queue_trigger_details:
constraints on routes:
[CHECK] routes_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
[CHECK] routes_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
[CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
[FOREIGN KEY] routes_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] routes_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT
[FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
@@ -648,13 +866,23 @@ constraints on scripts:
[CHECK] scripts_kind_check: CHECK ((kind = ANY (ARRAY['endpoint'::text, 'module'::text])))
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
[CHECK] scripts_module_name_shape: CHECK (((kind <> 'module'::text) OR (name ~ '^[a-zA-Z_][a-zA-Z0-9_]{0,63}$'::text)))
[CHECK] scripts_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
[FOREIGN KEY] scripts_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
constraints on secrets:
[CHECK] secrets_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
[FOREIGN KEY] secrets_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] secrets_pkey: PRIMARY KEY (app_id, name)
[FOREIGN KEY] secrets_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
constraints on template_suppressions:
[CHECK] template_suppressions_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
[CHECK] template_suppressions_target_kind_check: CHECK ((target_kind = ANY (ARRAY['trigger'::text, 'route'::text])))
[FOREIGN KEY] template_suppressions_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] template_suppressions_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] template_suppressions_pkey: PRIMARY KEY (id)
constraints on topics:
[CHECK] topics_auth_mode_check: CHECK ((auth_mode = ANY (ARRAY['public'::text, 'token'::text, 'session'::text])))
@@ -664,12 +892,21 @@ constraints on topics:
constraints on triggers:
[CHECK] triggers_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
[CHECK] triggers_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'dead_letter'::text, 'docs'::text, 'cron'::text, 'files'::text, 'pubsub'::text, 'email'::text, 'queue'::text])))
[CHECK] triggers_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
[CHECK] triggers_retry_backoff_check: CHECK ((retry_backoff = ANY (ARRAY['exponential'::text, 'linear'::text, 'constant'::text])))
[FOREIGN KEY] triggers_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] triggers_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT
[FOREIGN KEY] triggers_materialized_from_fkey: FOREIGN KEY (materialized_from) REFERENCES triggers(id) ON DELETE CASCADE
[FOREIGN KEY] triggers_registered_by_principal_fkey: FOREIGN KEY (registered_by_principal) REFERENCES admin_users(id) ON DELETE CASCADE
[FOREIGN KEY] triggers_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
[PRIMARY KEY] triggers_pkey: PRIMARY KEY (id)
constraints on vars:
[CHECK] vars_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
[FOREIGN KEY] vars_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] vars_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] vars_pkey: PRIMARY KEY (id)
## applied migrations
0001: init
0002: sandbox
@@ -715,3 +952,24 @@ constraints on triggers:
0042: secrets envelope version
0043: execution logs source
0044: delete reserved path routes
0045: scripts routes enabled
0046: trigger name
0047: groups
0048: vars
0049: group secrets
0050: group scripts
0051: extension points
0052: group collections
0053: group kv entries
0054: group docs
0055: group files
0056: group triggers
0057: group routes
0058: template suppressions
0059: sealed templates
0060: group suppressions
0061: shared triggers
0062: materialized triggers
0063: materialized unique
0064: shared topic queue kinds
0065: group queues

View File

@@ -0,0 +1,136 @@
//! §11.6 D3 integration test: group-shared durable queue store.
//!
//! Proves the competing-consumer primitive: N messages enqueued into one
//! group-keyed store are claimed EXACTLY ONCE across concurrent claimers (the
//! `FOR UPDATE SKIP LOCKED` guarantee that makes per-descendant materialized
//! consumers safe). Also checks ack removes a row and nack re-defers it.
//!
//! Deterministic: drives `GroupQueueRepo` directly (no dispatcher). Skips when
//! `DATABASE_URL` is unset.
#![allow(clippy::too_many_lines, clippy::many_single_char_names)]
use std::collections::HashSet;
use picloud_manager_core::group_queue_repo::{
GroupQueueRepo, NewGroupQueueMessage, PostgresGroupQueueRepo,
};
use picloud_shared::GroupId;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use uuid::Uuid;
async fn pool_or_skip() -> Option<PgPool> {
let Ok(url) = std::env::var("DATABASE_URL") else {
eprintln!("group_queue: DATABASE_URL unset — skipping");
return None;
};
let pool = PgPoolOptions::new()
.max_connections(6)
.connect(&url)
.await
.expect("connect");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrate");
Some(pool)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn competing_consumers_claim_each_message_exactly_once() {
let Some(pool) = pool_or_skip().await else {
return;
};
let sfx = Uuid::new_v4().simple().to_string();
let g: (Uuid,) = sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id")
.bind(format!("gq-g-{sfx}"))
.fetch_one(&pool)
.await
.unwrap();
let group = GroupId::from(g.0);
let repo = PostgresGroupQueueRepo::new(pool.clone());
// Enqueue 50 distinct messages into the shared `tasks` queue.
let n_msgs: usize = 50;
for i in 0..n_msgs {
repo.enqueue(NewGroupQueueMessage {
group_id: group,
collection: "tasks".into(),
payload: serde_json::json!({ "i": i }),
deliver_after: None,
max_attempts: 3,
enqueued_by_principal: None,
})
.await
.unwrap();
}
// Four concurrent "consumers" claim until the queue is drained, ack-ing each.
// Every claimed payload id must be unique — no message delivered twice.
let claim_loop = |repo: PostgresGroupQueueRepo, group: GroupId| async move {
let mut got: Vec<i64> = Vec::new();
while let Some(msg) = repo.claim(group, "tasks").await.unwrap() {
got.push(msg.payload["i"].as_i64().unwrap());
assert!(repo.ack(msg.id, msg.claim_token).await.unwrap(), "ack ok");
}
got
};
let (a, b, c, d) = tokio::join!(
claim_loop(PostgresGroupQueueRepo::new(pool.clone()), group),
claim_loop(PostgresGroupQueueRepo::new(pool.clone()), group),
claim_loop(PostgresGroupQueueRepo::new(pool.clone()), group),
claim_loop(PostgresGroupQueueRepo::new(pool.clone()), group),
);
let mut all: Vec<i64> = Vec::new();
all.extend(a);
all.extend(b);
all.extend(c);
all.extend(d);
let unique: HashSet<i64> = all.iter().copied().collect();
assert_eq!(
all.len(),
n_msgs,
"every message delivered exactly once (no dupes)"
);
assert_eq!(unique.len(), n_msgs, "all distinct ids covered");
assert_eq!(
repo.depth(group, "tasks").await.unwrap(),
0,
"queue drained"
);
// nack re-defers a message (a later claim gets it back).
let id = repo
.enqueue(NewGroupQueueMessage {
group_id: group,
collection: "tasks".into(),
payload: serde_json::json!({ "i": 999 }),
deliver_after: None,
max_attempts: 3,
enqueued_by_principal: None,
})
.await
.unwrap();
let msg = repo.claim(group, "tasks").await.unwrap().expect("claimed");
assert_eq!(msg.id, id);
repo.nack(msg.id, msg.claim_token, chrono::Duration::milliseconds(0))
.await
.unwrap();
// After nack (0ms delay) it is claimable again.
let again = repo.claim(group, "tasks").await.unwrap().expect("re-claim");
assert_eq!(again.id, id, "nacked message is re-delivered");
assert_eq!(again.attempt, 2, "attempt incremented on re-claim");
repo.ack(again.id, again.claim_token).await.unwrap();
// Cleanup (messages cascade on group delete anyway).
let _ = sqlx::query("DELETE FROM group_queue_messages WHERE group_id = $1")
.bind(g.0)
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM groups WHERE id = $1")
.bind(g.0)
.execute(&pool)
.await;
}

View File

@@ -0,0 +1,214 @@
//! §11 tail integration test: the live expansion of group ROUTE templates into
//! per-app match slices. A group-owned route TEMPLATE must land in the compiled
//! slice of a **descendant** app and must NOT land in an app in a **sibling**
//! subtree — the ancestor-chain expansion is the isolation boundary. An app's
//! own route with an identical binding must **shadow** the template (nearest
//! owner wins); a non-identical app route must **coexist**.
//!
//! Deterministic: drives `list_effective` + `compile_effective_routes` directly
//! (no async dispatcher), pinning the security-critical SQL + the shadow rule
//! without flakiness. Skips cleanly when `DATABASE_URL` is unset.
#![allow(
clippy::needless_pass_by_value,
clippy::many_single_char_names,
clippy::too_many_lines
)]
use picloud_manager_core::route_admin::compile_effective_routes;
use picloud_manager_core::route_repo::{PostgresRouteRepository, RouteRepository};
use picloud_shared::AppId;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use uuid::Uuid;
async fn pool_or_skip() -> Option<PgPool> {
let Ok(url) = std::env::var("DATABASE_URL") else {
eprintln!("group_route_templates: DATABASE_URL unset — skipping");
return None;
};
let pool = PgPoolOptions::new()
.max_connections(2)
.connect(&url)
.await
.expect("connect to DATABASE_URL");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("apply migrations");
Some(pool)
}
async fn id1(pool: &PgPool, sql: &str, bind: &str) -> Uuid {
let row: (Uuid,) = sqlx::query_as(sql)
.bind(bind)
.fetch_one(pool)
.await
.expect("insert returning id");
row.0
}
async fn app_under(pool: &PgPool, slug: &str, group: Uuid) -> Uuid {
let row: (Uuid,) =
sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
.bind(slug)
.bind(group)
.fetch_one(pool)
.await
.expect("app insert");
row.0
}
/// The compiled match slice for `app` — its own routes plus inherited
/// ancestor-group templates, after nearest-wins shadowing.
async fn slice_paths(repo: &PostgresRouteRepository, app: Uuid) -> Vec<(String, Uuid)> {
let effective = repo.list_effective().await.expect("list_effective");
let suppressed = repo
.list_route_suppressions()
.await
.expect("list_route_suppressions")
.into_iter()
.collect();
compile_effective_routes(&effective, &suppressed)
.into_iter()
.filter(|c| c.app_id == AppId::from(app))
.map(|c| {
// PathPattern's Debug carries the raw path; pair it with the bound
// script so the shadow assertion can check which handler won.
(format!("{:?}", c.path), c.script_id.into_inner())
})
.collect()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn group_route_template_expands_to_descendant_not_sibling_and_shadows() {
let Some(pool) = pool_or_skip().await else {
return;
};
let sfx = Uuid::new_v4().simple().to_string();
// G owns the template; G2 is an unrelated sibling subtree.
let g = id1(
&pool,
"INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id",
&format!("grt-g-{sfx}"),
)
.await;
let g2 = id1(
&pool,
"INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id",
&format!("grt-g2-{sfx}"),
)
.await;
// App A under G (descendant); app C under G2 (sibling subtree).
let a = app_under(&pool, &format!("grt-a-{sfx}"), g).await;
let c = app_under(&pool, &format!("grt-c-{sfx}"), g2).await;
// Group-owned handler script + a group route TEMPLATE binding it.
let group_script: (Uuid,) = sqlx::query_as(
"INSERT INTO scripts (name, source, group_id) VALUES ($1, 'x', $2) RETURNING id",
)
.bind(format!("ghandler-{sfx}"))
.bind(g)
.fetch_one(&pool)
.await
.expect("group script");
sqlx::query(
"INSERT INTO routes (group_id, script_id, host_kind, host, path_kind, path, method) \
VALUES ($1, $2, 'any', '', 'exact', '/hello', NULL)",
)
.bind(g)
.bind(group_script.0)
.execute(&pool)
.await
.expect("group route template");
let repo = PostgresRouteRepository::new(pool.clone());
// Descendant app A's slice CONTAINS the template, bound to the group script.
let a_slice = slice_paths(&repo, a).await;
assert!(
a_slice
.iter()
.any(|(p, sid)| p.contains("/hello") && *sid == group_script.0),
"a descendant app must inherit the group route template:\n{a_slice:?}"
);
// Sibling-subtree app C's slice does NOT — the chain expansion is the bound.
let c_slice = slice_paths(&repo, c).await;
assert!(
!c_slice.iter().any(|(p, _)| p.contains("/hello")),
"a sibling-subtree app must NOT inherit another subtree's template:\n{c_slice:?}"
);
// A's OWN /hello route (its own app-owned script) must SHADOW the template:
// exactly one compiled /hello for A, bound to the app script, not the group.
let app_script: (Uuid,) = sqlx::query_as(
"INSERT INTO scripts (name, source, app_id) VALUES ($1, 'x', $2) RETURNING id",
)
.bind(format!("ahandler-{sfx}"))
.bind(a)
.fetch_one(&pool)
.await
.expect("app script");
sqlx::query(
"INSERT INTO routes (app_id, script_id, host_kind, host, path_kind, path, method) \
VALUES ($1, $2, 'any', '', 'exact', '/hello', NULL)",
)
.bind(a)
.bind(app_script.0)
.execute(&pool)
.await
.expect("app route shadow");
let a_after = slice_paths(&repo, a).await;
let hellos: Vec<_> = a_after
.iter()
.filter(|(p, _)| p.contains("/hello"))
.collect();
assert_eq!(
hellos.len(),
1,
"nearest-wins shadowing must leave exactly one /hello for A:\n{a_after:?}"
);
assert_eq!(
hellos[0].1, app_script.0,
"the app's own route must win over the inherited group template"
);
// A non-identical app route (different path) must COEXIST with the template.
sqlx::query(
"INSERT INTO routes (app_id, script_id, host_kind, host, path_kind, path, method) \
VALUES ($1, $2, 'any', '', 'exact', '/own', NULL)",
)
.bind(a)
.bind(app_script.0)
.execute(&pool)
.await
.expect("app route coexist");
let a_final = slice_paths(&repo, a).await;
assert!(
a_final.iter().any(|(p, _)| p.contains("/own")),
"a non-identical app route must coexist with the inherited template:\n{a_final:?}"
);
// Cleanup (FK order: routes → scripts → apps → groups).
let _ = sqlx::query("DELETE FROM routes WHERE app_id = ANY($1) OR group_id = $2")
.bind(vec![a, c])
.bind(g)
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM scripts WHERE id = ANY($1)")
.bind(vec![group_script.0, app_script.0])
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM apps WHERE id = ANY($1)")
.bind(vec![a, c])
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM groups WHERE id = ANY($1)")
.bind(vec![g, g2])
.execute(&pool)
.await;
}

View File

@@ -0,0 +1,261 @@
//! §11 tail M1 integration test: GROUP-level template suppression.
//! A child group declares a suppression for a template it inherits from its
//! parent group → EVERY app in the child's subtree declines it (trigger
//! anti-join joins the chain; route rebuild expands the group suppression
//! across descendants), while an app under the parent but NOT under the child
//! still inherits. A `sealed` parent template ignores the child's suppression.
//!
//! Deterministic: drives `list_matching_kv` + `list_effective` /
//! `compile_effective_routes` directly. Skips when `DATABASE_URL` is unset.
#![allow(
clippy::needless_pass_by_value,
clippy::many_single_char_names,
clippy::too_many_lines
)]
use std::collections::HashSet;
use picloud_manager_core::route_admin::compile_effective_routes;
use picloud_manager_core::route_repo::{PostgresRouteRepository, RouteRepository};
use picloud_manager_core::trigger_repo::{PostgresTriggerRepo, TriggerRepo};
use picloud_shared::{AppId, KvEventOp};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use uuid::Uuid;
async fn pool_or_skip() -> Option<PgPool> {
let Ok(url) = std::env::var("DATABASE_URL") else {
eprintln!("group_suppression: DATABASE_URL unset — skipping");
return None;
};
let pool = PgPoolOptions::new()
.max_connections(2)
.connect(&url)
.await
.expect("connect to DATABASE_URL");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("apply migrations");
Some(pool)
}
async fn id1(pool: &PgPool, sql: &str, bind: &str) -> Uuid {
let row: (Uuid,) = sqlx::query_as(sql)
.bind(bind)
.fetch_one(pool)
.await
.expect("insert returning id");
row.0
}
/// A group with an explicit parent.
async fn group_under(pool: &PgPool, slug: &str, parent: Option<Uuid>) -> Uuid {
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO groups (slug, name, parent_id) VALUES ($1, $1, $2) RETURNING id",
)
.bind(slug)
.bind(parent)
.fetch_one(pool)
.await
.expect("group insert");
row.0
}
async fn app_under(pool: &PgPool, slug: &str, group: Uuid) -> Uuid {
let row: (Uuid,) =
sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
.bind(slug)
.bind(group)
.fetch_one(pool)
.await
.expect("app insert");
row.0
}
/// Whether `app`'s compiled route slice serves `/hello`.
async fn serves_hello(repo: &PostgresRouteRepository, app: Uuid) -> bool {
let effective = repo.list_effective().await.expect("list_effective");
let suppressed: HashSet<(AppId, String)> = repo
.list_route_suppressions()
.await
.expect("list_route_suppressions")
.into_iter()
.collect();
compile_effective_routes(&effective, &suppressed)
.into_iter()
.any(|c| c.app_id == AppId::from(app) && format!("{:?}", c.path).contains("/hello"))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn group_suppression_declines_for_whole_subtree_only() {
let Some(pool) = pool_or_skip().await else {
return;
};
let sfx = Uuid::new_v4().simple().to_string();
let admin = id1(
&pool,
"INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id",
&format!("gs-{sfx}"),
)
.await;
// Parent group P owns a handler `audit` + a kv trigger template + a /hello
// route template. Child group C is under P. A sealed route template too.
let p = group_under(&pool, &format!("gs-p-{sfx}"), None).await;
let c = group_under(&pool, &format!("gs-c-{sfx}"), Some(p)).await;
let handler_name = format!("audit-{sfx}");
let handler: (Uuid,) = sqlx::query_as(
"INSERT INTO scripts (name, source, group_id) VALUES ($1, 'x', $2) RETURNING id",
)
.bind(&handler_name)
.bind(p)
.fetch_one(&pool)
.await
.expect("group handler");
let tmpl: (Uuid,) = sqlx::query_as(
"INSERT INTO triggers \
(group_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, name) \
VALUES ($1, $2, 'kv', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id",
)
.bind(p)
.bind(handler.0)
.bind(admin)
.bind(format!("tmpl-{sfx}"))
.fetch_one(&pool)
.await
.expect("kv template");
sqlx::query(
"INSERT INTO kv_trigger_details (trigger_id, collection_glob, ops) \
VALUES ($1, '*', ARRAY['insert'])",
)
.bind(tmpl.0)
.execute(&pool)
.await
.expect("kv details");
sqlx::query(
"INSERT INTO routes (group_id, script_id, host_kind, host, path_kind, path, method) \
VALUES ($1, $2, 'any', '', 'exact', '/hello', NULL)",
)
.bind(p)
.bind(handler.0)
.execute(&pool)
.await
.expect("route template");
// App A under child C (will inherit C's suppression); app D under parent P
// but NOT under C (control — must still inherit).
let a = app_under(&pool, &format!("gs-a-{sfx}"), c).await;
let d = app_under(&pool, &format!("gs-d-{sfx}"), p).await;
// Child group C suppresses BOTH the trigger (by handler name) and the route.
sqlx::query(
"INSERT INTO template_suppressions (group_id, target_kind, reference) \
VALUES ($1, 'trigger', $2), ($1, 'route', '/hello')",
)
.bind(c)
.bind(&handler_name)
.execute(&pool)
.await
.expect("group suppressions for C");
let trig = PostgresTriggerRepo::new(pool.clone());
let routes = PostgresRouteRepository::new(pool.clone());
// App A (under C) → both declined by C's group-level suppression.
let a_trig = trig
.list_matching_kv(AppId::from(a), "users", KvEventOp::Insert)
.await
.expect("match A");
assert!(
!a_trig.iter().any(|m| m.trigger_id == tmpl.0.into()),
"an app under the suppressing group must NOT match the inherited trigger"
);
assert!(
!serves_hello(&routes, a).await,
"an app under the suppressing group must NOT serve the inherited route"
);
// App D (under P only) → still inherits both (isolation: C's suppression
// does not reach a sibling subtree).
let d_trig = trig
.list_matching_kv(AppId::from(d), "users", KvEventOp::Insert)
.await
.expect("match D");
assert!(
d_trig.iter().any(|m| m.trigger_id == tmpl.0.into()),
"an app outside the suppressing group's subtree still inherits the trigger"
);
assert!(
serves_hello(&routes, d).await,
"an app outside the suppressing group's subtree still serves the route"
);
// A SEALED parent route template ignores the child group's suppression.
sqlx::query(
"INSERT INTO routes \
(group_id, script_id, host_kind, host, path_kind, path, method, sealed) \
VALUES ($1, $2, 'any', '', 'exact', '/sealed', NULL, TRUE)",
)
.bind(p)
.bind(handler.0)
.execute(&pool)
.await
.expect("sealed route template");
sqlx::query(
"INSERT INTO template_suppressions (group_id, target_kind, reference) \
VALUES ($1, 'route', '/sealed')",
)
.bind(c)
.execute(&pool)
.await
.expect("suppress sealed");
let effective = routes.list_effective().await.expect("list_effective");
let suppressed: HashSet<(AppId, String)> = routes
.list_route_suppressions()
.await
.expect("suppressions")
.into_iter()
.collect();
let a_serves_sealed = compile_effective_routes(&effective, &suppressed)
.into_iter()
.any(|cr| cr.app_id == AppId::from(a) && format!("{:?}", cr.path).contains("/sealed"));
assert!(
a_serves_sealed,
"a sealed parent template is non-suppressible even by a group"
);
// Cleanup (FK order).
let _ = sqlx::query("DELETE FROM triggers WHERE id = $1")
.bind(tmpl.0)
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM routes WHERE group_id = $1")
.bind(p)
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM template_suppressions WHERE group_id = $1")
.bind(c)
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM apps WHERE id = ANY($1)")
.bind(vec![a, d])
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM scripts WHERE id = $1")
.bind(handler.0)
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM groups WHERE id = ANY($1)")
.bind(vec![c, p])
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM admin_users WHERE id = $1")
.bind(admin)
.execute(&pool)
.await;
}

View File

@@ -0,0 +1,170 @@
//! §11 tail integration test: the live chain-union dispatch for group trigger
//! templates. A group-owned kv trigger TEMPLATE must be matched for a
//! **descendant** app (the union via `CHAIN_LEVELS_CTE`) and must NOT be matched
//! for an app in a **sibling** subtree — that walk is the isolation boundary.
//!
//! Deterministic (no async dispatcher) so it pins the security-critical SQL
//! without flakiness. Skips cleanly when `DATABASE_URL` is unset.
#![allow(
clippy::needless_pass_by_value,
clippy::many_single_char_names,
clippy::too_many_lines
)]
use picloud_manager_core::trigger_repo::{PostgresTriggerRepo, TriggerRepo};
use picloud_shared::{AppId, KvEventOp};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use uuid::Uuid;
async fn pool_or_skip() -> Option<PgPool> {
let Ok(url) = std::env::var("DATABASE_URL") else {
eprintln!("group_trigger_templates: DATABASE_URL unset — skipping");
return None;
};
let pool = PgPoolOptions::new()
.max_connections(2)
.connect(&url)
.await
.expect("connect to DATABASE_URL");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("apply migrations");
Some(pool)
}
async fn id1(pool: &PgPool, sql: &str, bind: &str) -> Uuid {
let row: (Uuid,) = sqlx::query_as(sql)
.bind(bind)
.fetch_one(pool)
.await
.expect("insert returning id");
row.0
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn group_kv_template_matches_descendant_not_sibling() {
let Some(pool) = pool_or_skip().await else {
return;
};
let sfx = Uuid::new_v4().simple().to_string();
// registered_by principal
let admin = id1(
&pool,
"INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id",
&format!("gtt-{sfx}"),
)
.await;
// G owns the template; G2 is an unrelated sibling subtree.
let g = id1(
&pool,
"INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id",
&format!("gtt-g-{sfx}"),
)
.await;
let g2 = id1(
&pool,
"INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id",
&format!("gtt-g2-{sfx}"),
)
.await;
// App A under G (descendant); app C under G2 (sibling subtree).
let a: (Uuid,) =
sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
.bind(format!("gtt-a-{sfx}"))
.bind(g)
.fetch_one(&pool)
.await
.expect("app A");
let c: (Uuid,) =
sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
.bind(format!("gtt-c-{sfx}"))
.bind(g2)
.fetch_one(&pool)
.await
.expect("app C");
// Group-owned handler script under G.
let s: (Uuid,) = sqlx::query_as(
"INSERT INTO scripts (name, source, group_id) VALUES ($1, 'x', $2) RETURNING id",
)
.bind(format!("handler-{sfx}"))
.bind(g)
.fetch_one(&pool)
.await
.expect("group script");
// Group kv trigger TEMPLATE (group_id = G, app_id NULL) + details.
let t: (Uuid,) = sqlx::query_as(
"INSERT INTO triggers \
(group_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, name) \
VALUES ($1, $2, 'kv', TRUE, 'async', 3, 'exponential', 1000, $3, $4) \
RETURNING id",
)
.bind(g)
.bind(s.0)
.bind(admin)
.bind(format!("tmpl-{sfx}"))
.fetch_one(&pool)
.await
.expect("template trigger");
sqlx::query(
"INSERT INTO kv_trigger_details (trigger_id, collection_glob, ops) \
VALUES ($1, '*', ARRAY['insert'])",
)
.bind(t.0)
.execute(&pool)
.await
.expect("kv details");
let repo = PostgresTriggerRepo::new(pool.clone());
// Descendant app A's kv insert matches the ancestor group's template.
let matched = repo
.list_matching_kv(AppId::from(a.0), "users", KvEventOp::Insert)
.await
.expect("match for A");
assert!(
matched.iter().any(|m| m.trigger_id == t.0.into()),
"a descendant app must match the group's kv template"
);
// Sibling-subtree app C must NOT — the chain union is the isolation boundary.
let none = repo
.list_matching_kv(AppId::from(c.0), "users", KvEventOp::Insert)
.await
.expect("match for C");
assert!(
!none.iter().any(|m| m.trigger_id == t.0.into()),
"a sibling-subtree app must NOT match another subtree's group template"
);
// Cleanup (best-effort; ordering respects the FKs).
let _ = sqlx::query("DELETE FROM triggers WHERE id = $1")
.bind(t.0)
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM scripts WHERE id = $1")
.bind(s.0)
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM apps WHERE id = ANY($1)")
.bind(vec![a.0, c.0])
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM groups WHERE id = ANY($1)")
.bind(vec![g, g2])
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM admin_users WHERE id = $1")
.bind(admin)
.execute(&pool)
.await;
}

View File

@@ -0,0 +1,237 @@
//! §11 tail integration test: `sealed` (non-suppressible) group templates.
//! A group marks a TRIGGER and a ROUTE template `sealed = true`; a descendant
//! app that declares a `[suppress]` for both references STILL fires the trigger
//! (dispatch anti-join skips a sealed row) and STILL serves the route
//! (rebuild-time skip is gated on `!sealed`). The gate is exactly `sealed`: an
//! UNSEALED sibling template with the same suppression is still declined.
//!
//! Deterministic: drives `list_matching_kv` + `list_effective` /
//! `compile_effective_routes` directly (no async dispatcher). Skips cleanly
//! when `DATABASE_URL` is unset.
#![allow(
clippy::needless_pass_by_value,
clippy::many_single_char_names,
clippy::too_many_lines
)]
use std::collections::HashSet;
use picloud_manager_core::route_admin::compile_effective_routes;
use picloud_manager_core::route_repo::{PostgresRouteRepository, RouteRepository};
use picloud_manager_core::trigger_repo::{PostgresTriggerRepo, TriggerRepo};
use picloud_shared::{AppId, KvEventOp};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use uuid::Uuid;
async fn pool_or_skip() -> Option<PgPool> {
let Ok(url) = std::env::var("DATABASE_URL") else {
eprintln!("sealed_templates: DATABASE_URL unset — skipping");
return None;
};
let pool = PgPoolOptions::new()
.max_connections(2)
.connect(&url)
.await
.expect("connect to DATABASE_URL");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("apply migrations");
Some(pool)
}
async fn id1(pool: &PgPool, sql: &str, bind: &str) -> Uuid {
let row: (Uuid,) = sqlx::query_as(sql)
.bind(bind)
.fetch_one(pool)
.await
.expect("insert returning id");
row.0
}
/// A group-owned script handler.
async fn group_handler(pool: &PgPool, name: &str, group: Uuid) -> Uuid {
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO scripts (name, source, group_id) VALUES ($1, 'x', $2) RETURNING id",
)
.bind(name)
.bind(group)
.fetch_one(pool)
.await
.expect("group handler");
row.0
}
/// A group-owned kv trigger TEMPLATE (glob `*`, op insert), with `sealed`.
async fn kv_template(pool: &PgPool, group: Uuid, script: Uuid, admin: Uuid, sealed: bool) -> Uuid {
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO triggers \
(group_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, name, sealed) \
VALUES ($1, $2, 'kv', TRUE, 'async', 3, 'exponential', 1000, $3, $4, $5) RETURNING id",
)
.bind(group)
.bind(script)
.bind(admin)
.bind(Uuid::new_v4().simple().to_string())
.bind(sealed)
.fetch_one(pool)
.await
.expect("kv template");
sqlx::query(
"INSERT INTO kv_trigger_details (trigger_id, collection_glob, ops) \
VALUES ($1, '*', ARRAY['insert'])",
)
.bind(row.0)
.execute(pool)
.await
.expect("kv details");
row.0
}
/// A group-owned route TEMPLATE at `path`, with `sealed`.
async fn route_template(pool: &PgPool, group: Uuid, script: Uuid, path: &str, sealed: bool) {
sqlx::query(
"INSERT INTO routes \
(group_id, script_id, host_kind, host, path_kind, path, method, sealed) \
VALUES ($1, $2, 'any', '', 'exact', $3, NULL, $4)",
)
.bind(group)
.bind(script)
.bind(path)
.bind(sealed)
.execute(pool)
.await
.expect("route template");
}
/// Whether `app`'s compiled route slice serves `path`.
async fn serves(repo: &PostgresRouteRepository, app: Uuid, path: &str) -> bool {
let effective = repo.list_effective().await.expect("list_effective");
let suppressed: HashSet<(AppId, String)> = repo
.list_route_suppressions()
.await
.expect("list_route_suppressions")
.into_iter()
.collect();
compile_effective_routes(&effective, &suppressed)
.into_iter()
.any(|c| c.app_id == AppId::from(app) && format!("{:?}", c.path).contains(path))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn sealed_template_survives_suppression_unsealed_still_declined() {
let Some(pool) = pool_or_skip().await else {
return;
};
let sfx = Uuid::new_v4().simple().to_string();
let admin = id1(
&pool,
"INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id",
&format!("seal-{sfx}"),
)
.await;
// Group G owns two handlers, each with a trigger + a route template:
// `audit` → SEALED (non-suppressible)
// `plain` → unsealed (suppressible, the control)
let g = id1(
&pool,
"INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id",
&format!("seal-g-{sfx}"),
)
.await;
let audit_name = format!("audit-{sfx}");
let plain_name = format!("plain-{sfx}");
let audit = group_handler(&pool, &audit_name, g).await;
let plain = group_handler(&pool, &plain_name, g).await;
let sealed_trig = kv_template(&pool, g, audit, admin, true).await;
let plain_trig = kv_template(&pool, g, plain, admin, false).await;
route_template(&pool, g, audit, "/sealed", true).await;
route_template(&pool, g, plain, "/plain", false).await;
// App A suppresses BOTH handlers + BOTH paths.
let a: (Uuid,) =
sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
.bind(format!("seal-a-{sfx}"))
.bind(g)
.fetch_one(&pool)
.await
.expect("app A");
let a = a.0;
sqlx::query(
"INSERT INTO template_suppressions (app_id, target_kind, reference) VALUES \
($1, 'trigger', $2), ($1, 'trigger', $3), \
($1, 'route', '/sealed'), ($1, 'route', '/plain')",
)
.bind(a)
.bind(&audit_name)
.bind(&plain_name)
.execute(&pool)
.await
.expect("suppressions for A");
let trig = PostgresTriggerRepo::new(pool.clone());
let routes = PostgresRouteRepository::new(pool.clone());
let matched = trig
.list_matching_kv(AppId::from(a), "users", KvEventOp::Insert)
.await
.expect("match A");
// Sealed template: suppression is IGNORED — the trigger still fires and the
// route still serves.
assert!(
matched.iter().any(|m| m.trigger_id == sealed_trig.into()),
"a SEALED trigger template fires despite the suppression"
);
assert!(
serves(&routes, a, "/sealed").await,
"a SEALED route template serves despite the suppression"
);
// Unsealed control: the same suppression DOES decline it — proving the gate
// is exactly `sealed`, not a blanket bypass.
assert!(
!matched.iter().any(|m| m.trigger_id == plain_trig.into()),
"an UNSEALED trigger template is still declined by the suppression"
);
assert!(
!serves(&routes, a, "/plain").await,
"an UNSEALED route template is still declined by the suppression"
);
// Cleanup (FK order).
let _ = sqlx::query("DELETE FROM triggers WHERE id = ANY($1)")
.bind(vec![sealed_trig, plain_trig])
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM routes WHERE group_id = $1")
.bind(g)
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM template_suppressions WHERE app_id = $1")
.bind(a)
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM scripts WHERE id = ANY($1)")
.bind(vec![audit, plain])
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM apps WHERE id = $1")
.bind(a)
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM groups WHERE id = $1")
.bind(g)
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM admin_users WHERE id = $1")
.bind(admin)
.execute(&pool)
.await;
}

Some files were not shown because too many files have changed in this diff Show More